mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
69 Commits
| 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 |
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
|
||||||
191
.github/workflows/release.yml
vendored
191
.github/workflows/release.yml
vendored
@@ -98,10 +98,8 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- arch: x64
|
- arch: x64
|
||||||
self_contained: true
|
|
||||||
suffix: ''
|
suffix: ''
|
||||||
- arch: x86
|
- arch: x86
|
||||||
self_contained: true
|
|
||||||
suffix: ''
|
suffix: ''
|
||||||
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
||||||
|
|
||||||
@@ -167,48 +165,80 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish Main App
|
- name: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-${{ matrix.arch }}"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
|
||||||
|
|
||||||
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 `
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./$publishDir `
|
-o ./$publishDir `
|
||||||
--self-contained:false `
|
--self-contained:false `
|
||||||
|
-r win-${{ matrix.arch }} `
|
||||||
|
-p:SelfContained=false `
|
||||||
-p:PublishSingleFile=false `
|
-p:PublishSingleFile=false `
|
||||||
-p:DebugType=none `
|
-p:DebugType=none `
|
||||||
-p:DebugSymbols=false `
|
-p:DebugSymbols=false `
|
||||||
|
-p:SkipAirAppHostBuild=true `
|
||||||
-p:PublishTrimmed=false `
|
-p:PublishTrimmed=false `
|
||||||
-p:PublishReadyToRun=false `
|
-p:PublishReadyToRun=false `
|
||||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_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
|
shell: pwsh
|
||||||
|
|
||||||
- name: Restructure for Launcher
|
- name: Restructure for Launcher
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-$arch"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$newStructure = "publish-launcher/windows-$arch"
|
||||||
|
|
||||||
@@ -220,13 +250,51 @@ jobs:
|
|||||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
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
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||||
|
|
||||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -Path $launcherPublishDir -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
|
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||||
shell: pwsh
|
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
|
- name: Install Inno Setup and 7z
|
||||||
run: |
|
run: |
|
||||||
choco install innosetup -y --no-progress
|
choco install innosetup -y --no-progress
|
||||||
@@ -238,8 +306,7 @@ jobs:
|
|||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$suffix = "${{ matrix.suffix }}"
|
$suffix = "${{ matrix.suffix }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$publishDir = "publish/windows-$arch"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
|
||||||
$outputDir = "build-installer"
|
$outputDir = "build-installer"
|
||||||
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
||||||
|
|
||||||
@@ -273,7 +340,6 @@ jobs:
|
|||||||
"/DMyOutputDir=$outputDir",
|
"/DMyOutputDir=$outputDir",
|
||||||
"/DMyAppArch=$arch",
|
"/DMyAppArch=$arch",
|
||||||
"/DMyAppSuffix=$suffix",
|
"/DMyAppSuffix=$suffix",
|
||||||
"/DIsSelfContained=$selfContained",
|
|
||||||
$installerScript
|
$installerScript
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,7 +360,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$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)) {
|
if (-not (Test-Path $payloadRoot)) {
|
||||||
Write-Error "Payload root not found: $payloadRoot"
|
Write-Error "Payload root not found: $payloadRoot"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -308,7 +374,7 @@ jobs:
|
|||||||
|
|
||||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
$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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +484,7 @@ jobs:
|
|||||||
-p:SelfContained=true \
|
-p:SelfContained=true \
|
||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false \
|
-p:DebugSymbols=false \
|
||||||
|
-p:SkipAirAppHostBuild=true \
|
||||||
-p:PublishTrimmed=false \
|
-p:PublishTrimmed=false \
|
||||||
-p:PublishReadyToRun=false \
|
-p:PublishReadyToRun=false \
|
||||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
@@ -425,12 +492,32 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_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
|
- name: Restructure for Launcher
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
publishDir="publish/linux-x64"
|
publishDir="publish/linux-x64"
|
||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
launcherDir="publish/launcher-linux-x64"
|
launcherDir="publish/launcher-linux-x64"
|
||||||
|
runtimeDir="publish/airapp-runtime-linux-x64"
|
||||||
|
|
||||||
mkdir -p "$publishDir"
|
mkdir -p "$publishDir"
|
||||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||||
@@ -440,8 +527,13 @@ jobs:
|
|||||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$runtimeDir" ]; then
|
||||||
|
cp -r "$runtimeDir"/* "$publishDir/"
|
||||||
|
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
touch "$publishDir/$appDir/.current"
|
touch "$publishDir/$appDir/.current"
|
||||||
rm -rf "$launcherDir"
|
rm -rf "$launcherDir" "$runtimeDir"
|
||||||
|
|
||||||
- name: Package as DEB
|
- name: Package as DEB
|
||||||
run: |
|
run: |
|
||||||
@@ -600,12 +692,13 @@ jobs:
|
|||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||||
--self-contained \
|
--self-contained:false \
|
||||||
-r osx-${{ matrix.arch }} \
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:SelfContained=false \
|
||||||
-p:PublishSingleFile=false \
|
-p:PublishSingleFile=false \
|
||||||
-p:SelfContained=true \
|
|
||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false \
|
-p:DebugSymbols=false \
|
||||||
|
-p:SkipAirAppHostBuild=true \
|
||||||
-p:PublishTrimmed=false \
|
-p:PublishTrimmed=false \
|
||||||
-p:PublishReadyToRun=false \
|
-p:PublishReadyToRun=false \
|
||||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
@@ -613,6 +706,36 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_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
|
- name: Package Payload Zip
|
||||||
run: |
|
run: |
|
||||||
release_dir="$PWD/release-assets"
|
release_dir="$PWD/release-assets"
|
||||||
@@ -635,6 +758,7 @@ jobs:
|
|||||||
app_name="LanMountainDesktop"
|
app_name="LanMountainDesktop"
|
||||||
package_name="${app_name}-${version}-macos-${arch}"
|
package_name="${app_name}-${version}-macos-${arch}"
|
||||||
launcherDir="publish/launcher-macos-$arch"
|
launcherDir="publish/launcher-macos-$arch"
|
||||||
|
runtimeDir="publish/airapp-runtime-macos-$arch"
|
||||||
appSourceDir="publish/macos-$arch-app"
|
appSourceDir="publish/macos-$arch-app"
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
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
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
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"
|
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||||
mkdir -p "${app_name}.app/Contents/Resources"
|
mkdir -p "${app_name}.app/Contents/Resources"
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,6 +6,9 @@
|
|||||||
# dotenv files
|
# dotenv files
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local NuGet global packages (NuGet.Config globalPackagesFolder)
|
||||||
|
.nuget/packages/
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
*.suo
|
*.suo
|
||||||
@@ -515,3 +518,4 @@ nul
|
|||||||
/velopack-output-local-verify
|
/velopack-output-local-verify
|
||||||
/velopack-output-local
|
/velopack-output-local
|
||||||
/test-aot-publish
|
/test-aot-publish
|
||||||
|
/.claude/worktrees
|
||||||
|
|||||||
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: 命名管道(推荐用于进度报告) │
|
│ 方案 2: 命名管道(推荐用于进度报告) │
|
||||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
|
│ │ [历史方案] Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
|
||||||
│ │ 主程序连接并发送进度消息 │ │
|
│ │ 主程序连接并发送进度消息 │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ 消息格式: JSON │ │
|
│ │ 消息格式: JSON │ │
|
||||||
@@ -289,7 +289,7 @@ public static class LauncherIpcConstants
|
|||||||
|
|
||||||
#### 4. 实现 IPC 服务端
|
#### 4. 实现 IPC 服务端
|
||||||
|
|
||||||
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
|
**历史方案,已废弃**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
@@ -428,7 +428,7 @@ public async Task<LauncherResult> RunAsync()
|
|||||||
|
|
||||||
#### 6. 实现 IPC 客户端
|
#### 6. 实现 IPC 客户端
|
||||||
|
|
||||||
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
|
**历史方案,已废弃**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
@@ -672,8 +672,8 @@ public class UpdateInstallationService
|
|||||||
### 新增文件
|
### 新增文件
|
||||||
|
|
||||||
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
|
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
|
||||||
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
|
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - 历史启动进度 IPC 服务端,已由公共 IPC 通知替代
|
||||||
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
|
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - 历史启动进度 IPC 客户端,已由公共 IPC 通知替代
|
||||||
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
|
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
|
||||||
|
|
||||||
### 删除文件
|
### 删除文件
|
||||||
@@ -715,3 +715,11 @@ public class UpdateInstallationService
|
|||||||
- [ ] GitHub Actions 打包成功
|
- [ ] 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.
|
||||||
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.
|
- [ ] New install shows OOBE once.
|
||||||
- [ ] Same-user reinstall does not show OOBE again.
|
- [ ] Same-user reinstall does not show OOBE again.
|
||||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
- [ ] `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.
|
- [ ] Default plugin install does not request UAC.
|
||||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
- [ ] 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:
|
- `launchSource` values are treated as:
|
||||||
- `normal`
|
- `normal`
|
||||||
- `postinstall`
|
- `postinstall`
|
||||||
- `apply-update`
|
|
||||||
- `plugin-install`
|
- `plugin-install`
|
||||||
- `debug-preview`
|
- `debug-preview`
|
||||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
- 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.
|
- `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:
|
- Allowed elevation paths are limited to:
|
||||||
- the installer itself
|
- the installer itself
|
||||||
- full installer update application
|
- full installer update application
|
||||||
|
|||||||
@@ -65,3 +65,19 @@
|
|||||||
- 托盘失败时应用仍保持可恢复。
|
- 托盘失败时应用仍保持可恢复。
|
||||||
- Launcher 与应用设置页显示相同版本。
|
- Launcher 与应用设置页显示相同版本。
|
||||||
- 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口入场、通知位置与动画正常。
|
- 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] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
|
||||||
- [x] 补充规格与版本同步说明文档。
|
- [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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
|
||||||
|
|
||||||
## Acceptance
|
## 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.
|
- 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 tray clicks during shutdown are ignored and logged.
|
||||||
- Repeated component-library clicks focus the existing window instead of opening duplicates.
|
- Repeated component-library clicks focus the existing window instead of opening duplicates.
|
||||||
|
|||||||
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).
|
- 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.
|
- 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
|
## 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.
|
||||||
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;} }
|
||||||
@@ -3,20 +3,21 @@
|
|||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="12.0.2" />
|
<PackageVersion Include="Avalonia" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
|
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.2" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.2" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
||||||
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
|
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
|
||||||
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||||
<PackageVersion Include="Downloader" Version="5.4.0" />
|
<PackageVersion Include="Downloader" Version="5.4.0" />
|
||||||
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview2" />
|
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview4" />
|
||||||
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
||||||
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
|
<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="MaterialColorUtilities" Version="0.3.0" />
|
||||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
|
<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.Data.Sqlite" Version="11.0.0-preview.3.26207.106" />
|
||||||
@@ -29,8 +30,8 @@
|
|||||||
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
|
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
|
||||||
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
|
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
|
||||||
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageVersion Include="PostHog" Version="2.5.0" />
|
<PackageVersion Include="PostHog" Version="2.7.1" />
|
||||||
<PackageVersion Include="Sentry" Version="6.4.1" />
|
<PackageVersion Include="Sentry" Version="6.5.0" />
|
||||||
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
|
<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="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
|
||||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
LanMountainDesktop.AirAppRuntime/IAirAppProcessStarter.cs
Normal file
93
LanMountainDesktop.AirAppRuntime/IAirAppProcessStarter.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal interface IAirAppProcessStarter
|
||||||
|
{
|
||||||
|
Process? Start(string appId, string sessionId, string instanceKey, string? sourceComponentId, string? sourcePlacementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||||
|
{
|
||||||
|
private readonly AirAppHostLocator _locator;
|
||||||
|
private readonly Func<string?> _packageRootProvider;
|
||||||
|
private readonly Func<string?> _hostPathProvider;
|
||||||
|
private readonly Func<string?> _dataRootProvider;
|
||||||
|
|
||||||
|
public AirAppProcessStarter(
|
||||||
|
AirAppHostLocator locator,
|
||||||
|
Func<string?> packageRootProvider,
|
||||||
|
Func<string?> hostPathProvider,
|
||||||
|
Func<string?> dataRootProvider)
|
||||||
|
{
|
||||||
|
_locator = locator;
|
||||||
|
_packageRootProvider = packageRootProvider;
|
||||||
|
_hostPathProvider = hostPathProvider;
|
||||||
|
_dataRootProvider = dataRootProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Process? Start(
|
||||||
|
string appId,
|
||||||
|
string sessionId,
|
||||||
|
string instanceKey,
|
||||||
|
string? sourceComponentId,
|
||||||
|
string? sourcePlacementId)
|
||||||
|
{
|
||||||
|
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||||
|
var startInfo = CreateStartInfo(hostPath);
|
||||||
|
|
||||||
|
AddArgument(startInfo, "--app-id", appId);
|
||||||
|
AddArgument(startInfo, "--session-id", sessionId);
|
||||||
|
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||||
|
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
|
||||||
|
var dataRoot = _dataRootProvider();
|
||||||
|
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||||
|
{
|
||||||
|
AddArgument(startInfo, "--data-root", Path.GetFullPath(dataRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sourceComponentId))
|
||||||
|
{
|
||||||
|
AddArgument(startInfo, "--source-component-id", sourceComponentId.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sourcePlacementId))
|
||||||
|
{
|
||||||
|
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
AirAppRuntimeLogger.Info(
|
||||||
|
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||||
|
var process = Process.Start(startInfo);
|
||||||
|
if (process is not null)
|
||||||
|
{
|
||||||
|
process.EnableRaisingEvents = true;
|
||||||
|
process.Exited += (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AirAppRuntimeLogger.Info(
|
||||||
|
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ProcessStartInfo CreateStartInfo(string hostPath)
|
||||||
|
{
|
||||||
|
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(name);
|
||||||
|
startInfo.ArgumentList.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RollForward>LatestMajor</RollForward>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PublishAot>false</PublishAot>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
public static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
var options = AirAppRuntimeOptions.Parse(args);
|
||||||
|
AirAppRuntimeLogger.Info(
|
||||||
|
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
|
||||||
|
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lifecycleService = new AirAppLifecycleService(
|
||||||
|
new AirAppProcessStarter(
|
||||||
|
new AirAppHostLocator(),
|
||||||
|
() => options.AppRoot,
|
||||||
|
() => null,
|
||||||
|
() => options.DataRoot));
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
|
||||||
|
var controlService = new AirAppRuntimeControlService(lifetime);
|
||||||
|
|
||||||
|
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
|
||||||
|
ipcHost.Start();
|
||||||
|
|
||||||
|
while (lifetime.ShouldKeepAlive())
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||||
@@ -38,6 +38,15 @@ public static class AppearanceCornerRadiusTokenFactory
|
|||||||
Xl: new CornerRadius(40),
|
Xl: new CornerRadius(40),
|
||||||
Island: new CornerRadius(44),
|
Island: new CornerRadius(44),
|
||||||
Component: new CornerRadius(32)),
|
Component: new CornerRadius(32)),
|
||||||
|
GlobalAppearanceSettings.CornerRadiusStyleFluent => new AppearanceCornerRadiusTokens(
|
||||||
|
Micro: new CornerRadius(2),
|
||||||
|
Xs: new CornerRadius(4),
|
||||||
|
Sm: new CornerRadius(4),
|
||||||
|
Md: new CornerRadius(8),
|
||||||
|
Lg: new CornerRadius(8),
|
||||||
|
Xl: new CornerRadius(12),
|
||||||
|
Island: new CornerRadius(16),
|
||||||
|
Component: new CornerRadius(8)),
|
||||||
// Balanced (default)
|
// Balanced (default)
|
||||||
_ => new AppearanceCornerRadiusTokens(
|
_ => new AppearanceCornerRadiusTokens(
|
||||||
Micro: new CornerRadius(6),
|
Micro: new CornerRadius(6),
|
||||||
|
|||||||
@@ -5,5 +5,8 @@
|
|||||||
RequestedThemeVariant="Default">
|
RequestedThemeVariant="Default">
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<sty:FluentAvaloniaTheme />
|
<sty:FluentAvaloniaTheme />
|
||||||
|
<Style Selector="Window">
|
||||||
|
<Setter Property="Topmost" Value="True" />
|
||||||
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Shell;
|
||||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
|
||||||
using LanMountainDesktop.Shared.IPC;
|
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher;
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
@@ -44,8 +39,12 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
var context = LauncherRuntimeContext.Current;
|
var context = LauncherRuntimeContext.Current;
|
||||||
@@ -55,691 +54,24 @@ public partial class App : Application
|
|||||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||||
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
|
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
|
||||||
|
|
||||||
if (HandlePreviewCommand(context, desktop))
|
if (PreviewEntryHandler.TryHandle(context, desktop))
|
||||||
{
|
{
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调试模式:只显示 DevDebugWindow,不走正常启动流程
|
if (context.IsDebugMode && !context.IsPreviewCommand)
|
||||||
// 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI
|
|
||||||
if (context.IsDebugMode && !context.IsPreviewCommand &&
|
|
||||||
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
Logger.Info("Debug mode active — showing DevDebugWindow instead of normal launch flow.");
|
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
||||||
var devDebugWindow = new DevDebugWindow();
|
new DevDebugWindow().Show();
|
||||||
devDebugWindow.Show();
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
|
||||||
{
|
|
||||||
var updateWindow = new UpdateWindow();
|
|
||||||
updateWindow.Show();
|
|
||||||
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var splashWindow = CreateSplashWindow();
|
|
||||||
splashWindow.Show();
|
splashWindow.Show();
|
||||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
_ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
|
||||||
{
|
|
||||||
switch (context.Command.ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "preview-splash":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: splash.");
|
|
||||||
var splashWindow = CreateSplashWindow();
|
|
||||||
splashWindow.SetDebugMode(true);
|
|
||||||
splashWindow.Show();
|
|
||||||
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-error":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: error.");
|
|
||||||
var errorWindow = new ErrorWindow();
|
|
||||||
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
|
|
||||||
errorWindow.Show();
|
|
||||||
_ = WaitForWindowCloseAsync(desktop, errorWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-update":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: update.");
|
|
||||||
var updateWindow = new UpdateWindow();
|
|
||||||
updateWindow.SetDebugMode(true);
|
|
||||||
updateWindow.Show();
|
|
||||||
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-oobe":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: oobe.");
|
|
||||||
var oobeWindow = new OobeWindow();
|
|
||||||
oobeWindow.Show();
|
|
||||||
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-debug":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: debug window.");
|
|
||||||
var devDebugWindow = new DevDebugWindow();
|
|
||||||
devDebugWindow.Show();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SplashWindow CreateSplashWindow()
|
|
||||||
{
|
|
||||||
var window = new SplashWindow();
|
|
||||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
|
||||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
|
||||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
|
||||||
{
|
|
||||||
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
|
||||||
var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" };
|
|
||||||
var reporter = (ISplashStageReporter)window;
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.Length; i++)
|
|
||||||
{
|
|
||||||
reporter.Report(stages[i], messages[i]);
|
|
||||||
await Task.Delay(800).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(5000).ConfigureAwait(false);
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
|
|
||||||
{
|
|
||||||
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.Length; i++)
|
|
||||||
{
|
|
||||||
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
|
|
||||||
await Task.Delay(600).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.ReportComplete(true, null);
|
|
||||||
await Task.Delay(3000).ConfigureAwait(false);
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
|
||||||
Logger.Info("OOBE preview completed by user.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error("OOBE preview failed.", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
|
|
||||||
{
|
|
||||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
window.Closed += (_, _) => tcs.TrySetResult();
|
|
||||||
await tcs.Task.ConfigureAwait(false);
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunCoordinatorWithSplashAsync(
|
|
||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
|
||||||
CommandContext context,
|
|
||||||
SplashWindow splashWindow)
|
|
||||||
{
|
|
||||||
LauncherResult result;
|
|
||||||
SplashWindow? currentSplashWindow = splashWindow;
|
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
|
||||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
|
||||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
|
||||||
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
|
|
||||||
|
|
||||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
|
||||||
context.LaunchSource,
|
|
||||||
successPolicy,
|
|
||||||
coordinatorPipeName,
|
|
||||||
out var reservedAttempt,
|
|
||||||
out var activeCoordinatorAttempt))
|
|
||||||
{
|
|
||||||
result = await AttachToExistingCoordinatorAsync(
|
|
||||||
context,
|
|
||||||
currentSplashWindow,
|
|
||||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
|
||||||
|
|
||||||
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
|
|
||||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
|
||||||
|
|
||||||
Environment.ExitCode = result.Success ? 0 : 1;
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
|
||||||
coordinatorPipeName,
|
|
||||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
|
||||||
HandleCoordinatorRequestAsync,
|
|
||||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
|
||||||
coordinatorServer.Start();
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Logger.Info(
|
|
||||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
|
||||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
|
||||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
|
||||||
|
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
|
||||||
var coordinator = new LauncherFlowCoordinator(
|
|
||||||
context,
|
|
||||||
deploymentLocator,
|
|
||||||
new OobeStateService(appRoot),
|
|
||||||
new UpdateEngineService(deploymentLocator),
|
|
||||||
new PluginInstallerService(),
|
|
||||||
startupAttemptRegistry,
|
|
||||||
coordinatorServer);
|
|
||||||
|
|
||||||
result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
|
||||||
result = new LauncherResult
|
|
||||||
{
|
|
||||||
Success = false,
|
|
||||||
Stage = "launch",
|
|
||||||
Code = "exception",
|
|
||||||
Message = $"Launcher failed: {ex.Message}",
|
|
||||||
ErrorMessage = ex.ToString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Success ||
|
|
||||||
result.Code == "host_not_found" ||
|
|
||||||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
|
||||||
if (failureAction == ErrorWindowResult.Exit)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failureAction == ErrorWindowResult.ActivateExisting &&
|
|
||||||
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
result = new LauncherResult
|
|
||||||
{
|
|
||||||
Success = true,
|
|
||||||
Stage = "launch",
|
|
||||||
Code = "activation_requested",
|
|
||||||
Message = "Launcher activated the existing desktop instance.",
|
|
||||||
Details = result.Details
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSplashWindow = CreateSplashWindow();
|
|
||||||
currentSplashWindow.Show();
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
|
||||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
|
||||||
|
|
||||||
Environment.ExitCode = result.Success ? 0 : 1;
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
|
||||||
CommandContext context,
|
|
||||||
SplashWindow? splashWindow,
|
|
||||||
StartupAttemptRecord? activeCoordinatorAttempt)
|
|
||||||
{
|
|
||||||
var reporter = splashWindow as ISplashStageReporter;
|
|
||||||
reporter?.Report("activation", "Connecting to the active launcher...");
|
|
||||||
|
|
||||||
if (activeCoordinatorAttempt is not null &&
|
|
||||||
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
|
|
||||||
{
|
|
||||||
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? LauncherCoordinatorCommands.Attach
|
|
||||||
: LauncherCoordinatorCommands.ActivateDesktop;
|
|
||||||
var request = new LauncherCoordinatorRequest
|
|
||||||
{
|
|
||||||
Command = command,
|
|
||||||
LaunchSource = context.LaunchSource,
|
|
||||||
SuccessPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context)
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await new LauncherCoordinatorIpcClient()
|
|
||||||
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (response is not null)
|
|
||||||
{
|
|
||||||
reporter?.Report("activation", response.Message);
|
|
||||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
|
||||||
var success = response.Accepted ||
|
|
||||||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
|
||||||
return new LauncherResult
|
|
||||||
{
|
|
||||||
Success = success,
|
|
||||||
Stage = "launch",
|
|
||||||
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
|
||||||
Message = success && !response.Accepted
|
|
||||||
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
|
||||||
: response.Message,
|
|
||||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
|
||||||
if (activation is not null)
|
|
||||||
{
|
|
||||||
reporter?.Report("activation", activation.Message);
|
|
||||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
|
||||||
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
|
||||||
return new LauncherResult
|
|
||||||
{
|
|
||||||
Success = success,
|
|
||||||
Stage = "launch",
|
|
||||||
Code = activation.Accepted
|
|
||||||
? "existing_host_activated"
|
|
||||||
: success
|
|
||||||
? "existing_host_startup_pending"
|
|
||||||
: "existing_host_activation_failed",
|
|
||||||
Message = success && !activation.Accepted
|
|
||||||
? "Existing desktop process is still starting; Launcher attached without starting another process."
|
|
||||||
: activation.Message,
|
|
||||||
Details = BuildCoordinatorResultDetails(null, activation)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
|
||||||
return new LauncherResult
|
|
||||||
{
|
|
||||||
Success = false,
|
|
||||||
Stage = "launch",
|
|
||||||
Code = "launcher_coordinator_unavailable",
|
|
||||||
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
|
|
||||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
|
|
||||||
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
|
|
||||||
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
|
|
||||||
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
|
|
||||||
LauncherCoordinatorRequest request,
|
|
||||||
LauncherCoordinatorStatus status)
|
|
||||||
{
|
|
||||||
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
|
||||||
if (activation is not null)
|
|
||||||
{
|
|
||||||
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
|
|
||||||
{
|
|
||||||
return new LauncherCoordinatorResponse
|
|
||||||
{
|
|
||||||
Accepted = true,
|
|
||||||
Code = "attached_to_launcher_coordinator",
|
|
||||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
|
||||||
Status = status,
|
|
||||||
ActivationResult = activation
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LauncherCoordinatorResponse
|
|
||||||
{
|
|
||||||
Accepted = activation.Accepted,
|
|
||||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
|
||||||
Message = activation.Message,
|
|
||||||
Status = status,
|
|
||||||
ActivationResult = activation
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LauncherCoordinatorResponse
|
|
||||||
{
|
|
||||||
Accepted = true,
|
|
||||||
Code = "attached_to_launcher_coordinator",
|
|
||||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
|
||||||
Status = status
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LauncherCoordinatorResponse
|
|
||||||
{
|
|
||||||
Accepted = true,
|
|
||||||
Code = "attached_to_launcher_coordinator",
|
|
||||||
Message = "Attached to the active Launcher coordinator.",
|
|
||||||
Status = status
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
|
|
||||||
{
|
|
||||||
return new LauncherCoordinatorStatus
|
|
||||||
{
|
|
||||||
AttemptId = attempt.AttemptId,
|
|
||||||
CoordinatorPid = Environment.ProcessId,
|
|
||||||
HostPid = attempt.HostPid,
|
|
||||||
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
|
|
||||||
LaunchSource = attempt.LaunchSource,
|
|
||||||
SuccessPolicy = attempt.SuccessPolicy,
|
|
||||||
LastObservedStage = attempt.LastObservedStage,
|
|
||||||
LastObservedMessage = attempt.LastObservedMessage,
|
|
||||||
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
|
|
||||||
State = attempt.State.ToString(),
|
|
||||||
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
|
|
||||||
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
|
|
||||||
Succeeded = attempt.State == StartupAttemptState.Succeeded,
|
|
||||||
UpdatedAtUtc = attempt.UpdatedAtUtc
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsRecoverableActivationFailure(
|
|
||||||
PublicShellActivationResult? activation,
|
|
||||||
LauncherCoordinatorStatus? status)
|
|
||||||
{
|
|
||||||
if (activation is { Accepted: true })
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status is { Completed: false, HostProcessAlive: true })
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var shellStatus = activation?.Status;
|
|
||||||
if (shellStatus is null || !shellStatus.PublicIpcReady)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !shellStatus.MainWindowOpened ||
|
|
||||||
!shellStatus.DesktopVisible ||
|
|
||||||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
|
||||||
LauncherCoordinatorStatus? status,
|
|
||||||
PublicShellActivationResult? activation)
|
|
||||||
{
|
|
||||||
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
|
|
||||||
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
|
|
||||||
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
|
|
||||||
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
|
|
||||||
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
|
|
||||||
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
|
|
||||||
["startupState"] = status?.State ?? string.Empty,
|
|
||||||
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
|
|
||||||
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
|
|
||||||
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
|
|
||||||
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
|
|
||||||
};
|
|
||||||
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
|
|
||||||
{
|
|
||||||
if (splashWindow is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
|
||||||
{
|
|
||||||
var resultPath = context.GetOption("result");
|
|
||||||
if (string.IsNullOrWhiteSpace(resultPath))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
|
||||||
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
|
|
||||||
{
|
|
||||||
ErrorWindow? errorWindow = null;
|
|
||||||
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
|
|
||||||
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
|
|
||||||
hostProcessAliveValue;
|
|
||||||
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
|
||||||
int.TryParse(hostPidText, out var parsedPid)
|
|
||||||
? parsedPid
|
|
||||||
: (int?)null;
|
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
errorWindow = new ErrorWindow();
|
|
||||||
if (hostProcessAlive)
|
|
||||||
{
|
|
||||||
errorWindow.ConfigureForRunningHostFailure(hostPid);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errorWindow.ConfigureForGenericFailure(allowRetry: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorWindow.SetErrorMessage(
|
|
||||||
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
|
||||||
errorWindow.Show();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error("Failed to show launcher failure window.", ex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (errorWindow is null)
|
|
||||||
{
|
|
||||||
return ErrorWindowResult.Exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
|
||||||
return ErrorWindowResult.Exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
|
||||||
{
|
|
||||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
||||||
return activation?.Accepted == true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
|
||||||
var connectTask = ipcClient.ConnectAsync();
|
|
||||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
|
||||||
if (completedTask != connectTask)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connectTask.ConfigureAwait(false);
|
|
||||||
if (!ipcClient.IsConnected)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
|
||||||
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
|
|
||||||
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
|
|
||||||
if (completedTask != activationTask)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await activationTask.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryGetLiveProcess(int processId)
|
|
||||||
{
|
|
||||||
if (processId <= 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var process = Process.GetProcessById(processId);
|
|
||||||
return !process.HasExited;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunApplyUpdateWithWindowAsync(
|
|
||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
|
||||||
CommandContext context,
|
|
||||||
UpdateWindow window)
|
|
||||||
{
|
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
|
||||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
|
||||||
var pluginInstaller = new PluginInstallerService();
|
|
||||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
|
||||||
|
|
||||||
var success = true;
|
|
||||||
string? errorMessage = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
|
|
||||||
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
|
||||||
if (!updateResult.Success)
|
|
||||||
{
|
|
||||||
success = false;
|
|
||||||
errorMessage = updateResult.Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success)
|
|
||||||
{
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
|
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
|
|
||||||
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
|
||||||
if (!queueResult.Success && queueResult.Code != "noop")
|
|
||||||
{
|
|
||||||
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success)
|
|
||||||
{
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
|
|
||||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
success = false;
|
|
||||||
errorMessage = ex.Message;
|
|
||||||
Logger.Error("Apply-update flow failed.", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
|
|
||||||
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
|
|
||||||
{
|
|
||||||
Success = success,
|
|
||||||
Stage = "apply-update",
|
|
||||||
Code = success ? "ok" : "failed",
|
|
||||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
|
|
||||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["command"] = context.Command,
|
|
||||||
["launchSource"] = context.LaunchSource
|
|
||||||
}
|
|
||||||
}).ConfigureAwait(false);
|
|
||||||
|
|
||||||
Environment.ExitCode = success ? 0 : 1;
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Plugins;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
@@ -11,13 +11,6 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
PropertyNameCaseInsensitive = true)]
|
PropertyNameCaseInsensitive = true)]
|
||||||
[JsonSerializable(typeof(SignedFileMap))]
|
|
||||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileMap))]
|
|
||||||
[JsonSerializable(typeof(PlondsComponentEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
|
||||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||||
[JsonSerializable(typeof(AppVersionInfo))]
|
[JsonSerializable(typeof(AppVersionInfo))]
|
||||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||||
@@ -29,18 +22,18 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(PublicTaskbarStatus))]
|
[JsonSerializable(typeof(PublicTaskbarStatus))]
|
||||||
[JsonSerializable(typeof(PublicShellActivationResult))]
|
[JsonSerializable(typeof(PublicShellActivationResult))]
|
||||||
[JsonSerializable(typeof(LauncherResult))]
|
[JsonSerializable(typeof(LauncherResult))]
|
||||||
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
|
||||||
[JsonSerializable(typeof(PluginManifest))]
|
[JsonSerializable(typeof(PluginManifest))]
|
||||||
[JsonSerializable(typeof(PendingUpgrade))]
|
|
||||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||||
[JsonSerializable(typeof(OobeStateFile))]
|
[JsonSerializable(typeof(OobeStateFile))]
|
||||||
[JsonSerializable(typeof(DataLocationConfig))]
|
[JsonSerializable(typeof(DataLocationConfig))]
|
||||||
[JsonSerializable(typeof(GitHubRelease))]
|
|
||||||
[JsonSerializable(typeof(GitHubAsset))]
|
|
||||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
|
||||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||||
[JsonSerializable(typeof(PrivacyConfig))]
|
[JsonSerializable(typeof(PrivacyConfig))]
|
||||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||||
|
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeStatus))]
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ internal sealed class CommandContext
|
|||||||
private static readonly string[] GuiCommands =
|
private static readonly string[] GuiCommands =
|
||||||
[
|
[
|
||||||
"launch",
|
"launch",
|
||||||
"apply-update",
|
|
||||||
"preview-splash",
|
"preview-splash",
|
||||||
"preview-error",
|
"preview-error",
|
||||||
"preview-update",
|
"preview-update",
|
||||||
@@ -64,9 +63,7 @@ internal sealed class CommandContext
|
|||||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsMaintenanceCommand =>
|
public bool IsMaintenanceCommand =>
|
||||||
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public string? ExplicitAppRoot => GetOption("app-root");
|
public string? ExplicitAppRoot => GetOption("app-root");
|
||||||
@@ -112,11 +109,6 @@ internal sealed class CommandContext
|
|||||||
return "debug-preview";
|
return "debug-preview";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "apply-update";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return "plugin-install";
|
return "plugin-install";
|
||||||
@@ -137,7 +129,6 @@ internal sealed class CommandContext
|
|||||||
"normal" => "normal",
|
"normal" => "normal",
|
||||||
"restart" => "restart",
|
"restart" => "restart",
|
||||||
"postinstall" => "postinstall",
|
"postinstall" => "postinstall",
|
||||||
"apply-update" => "apply-update",
|
|
||||||
"plugin-install" => "plugin-install",
|
"plugin-install" => "plugin-install",
|
||||||
"debug-preview" => "debug-preview",
|
"debug-preview" => "debug-preview",
|
||||||
_ => null
|
_ => null
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user