mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
chore: 更新 .gitignore,忽略 AI 工具配置、临时调试脚本和杂乱文件
This commit is contained in:
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"diffEditor.renderSideBySide": false,
|
|
||||||
"clawMode.mode": "editor",
|
|
||||||
"workbench.activityBar.location": "default"
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")",
|
|
||||||
"Read(//d/github/**)",
|
|
||||||
"Bash(dotnet build *)",
|
|
||||||
"Bash(dotnet test *)",
|
|
||||||
"Bash(python -)",
|
|
||||||
"Bash(py -3 -c \"from pathlib import Path; p=Path\\(r'd:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs'\\); t=p.read_text\\(encoding='utf-8'\\); s=t.find\\('public sealed partial class UpdateSettingsPageViewModel : ViewModelBase'\\); e=t.find\\('public sealed partial class StudySettingsPageViewModel : ViewModelBase', s\\); assert s!=-1 and e!=-1; p.write_text\\(t[:s]+t[e:], encoding='utf-8'\\); print\\('ok'\\)\")",
|
|
||||||
"Bash(perl -0777 -i -pe \"s/public sealed partial class UpdateSettingsPageViewModel : ViewModelBase\\\\R\\\\{.*?\\\\R\\\\}\\\\R\\\\Rpublic sealed partial class StudySettingsPageViewModel : ViewModelBase/public sealed partial class StudySettingsPageViewModel : ViewModelBase/s\" \"d:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs\")"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
|
||||||
version = 1
|
|
||||||
name = "LanMountainDesktop"
|
|
||||||
|
|
||||||
[setup]
|
|
||||||
script = ""
|
|
||||||
|
|
||||||
[[actions]]
|
|
||||||
name = "运行"
|
|
||||||
icon = "run"
|
|
||||||
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
|
|
||||||
|
|
||||||
[[actions]]
|
|
||||||
name = "构建"
|
|
||||||
icon = "tool"
|
|
||||||
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
# StandBy Digital Clock - iPhone 待机风格大数字时钟组件
|
|
||||||
|
|
||||||
## 1. 需求场景与处理逻辑
|
|
||||||
|
|
||||||
### 1.1 需求描述
|
|
||||||
新增一个 4×2 尺寸的数字时钟桌面组件,视觉风格参考 iPhone 横屏充电时的 StandBy 待机显示——大面积、粗体、圆润的数字显示当前时间(HH:MM),数字采用不规则的自由排版(有微妙的垂直偏移,不在一条直线上),颜色使用 Monet 主题色而非纯黑/白,伴随数字切换时的流畅垂直滚动/翻转动画,下方显示日期信息。
|
|
||||||
|
|
||||||
### 1.2 用户体验目标
|
|
||||||
- 大字号、圆润粗体的数字时间,远距离一目了然
|
|
||||||
- 数字采用不规则自由排版(微妙垂直偏移),营造 iPhone StandBy 那种有机、散漫的视觉节奏
|
|
||||||
- 数字使用 Monet 主题色(跟随壁纸/用户选色的强调色),而非死板的纯黑/白
|
|
||||||
- 数字变化时执行垂直滑动动画(旧数字向上滑出,新数字从下方滑入),类似翻页时钟效果
|
|
||||||
- 冒号(:)有呼吸闪烁效果
|
|
||||||
- 支持夜间/日间模式自动切换
|
|
||||||
- 点击组件可打开世界时钟 AirApp
|
|
||||||
- 支持时区配置(与现有桌面时钟共享设置体系)
|
|
||||||
|
|
||||||
### 1.3 处理逻辑
|
|
||||||
1. 组件加载时读取时区设置和秒针模式设置
|
|
||||||
2. `DispatcherTimer` 每秒触发一次更新
|
|
||||||
3. 当检测到分钟数变化时,触发分钟数字的垂直滑动动画
|
|
||||||
4. 当检测到小时数变化时,触发小时数字的垂直滑动动画
|
|
||||||
5. 冒号以 1 秒周期做透明度脉冲动画
|
|
||||||
6. 每 tick 检查是否需要切换日间/夜间视觉模式
|
|
||||||
|
|
||||||
## 2. 架构与技术方案
|
|
||||||
|
|
||||||
### 2.1 组件架构
|
|
||||||
遵循现有桌面组件架构模式:
|
|
||||||
- 继承 `UserControl`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware`
|
|
||||||
- AXAML 定义根布局结构,代码后置处理动画逻辑
|
|
||||||
- 通过 `DesktopComponentDefinition` 注册到组件系统
|
|
||||||
|
|
||||||
### 2.2 数字滚动动画技术方案
|
|
||||||
采用 Avalonia `RenderTransform` + `DoubleTransition` 实现数字滚动:
|
|
||||||
|
|
||||||
**核心思路**:每个数位(共 4 位:H1, H2, M1, M2)使用 `ClipToBounds` 的容器,内含一个垂直排列的 `StackPanel`,包含当前数字和下一个数字。切换时通过 `TranslateTransform.Y` 的 `DoubleTransition` 实现平滑滚动。
|
|
||||||
|
|
||||||
```
|
|
||||||
每位数字的结构:
|
|
||||||
┌─ DigitClip (ClipToBounds=true) ──────────┐
|
|
||||||
│ ┌─ DigitStack (TranslateTransform.Y) ──┐ │
|
|
||||||
│ │ [当前数字 TextBlock] │ │
|
|
||||||
│ │ [新数字 TextBlock] │ │
|
|
||||||
│ └───────────────────────────────────────┘ │
|
|
||||||
└───────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
当数字变化时:
|
|
||||||
1. 在 StackPanel 底部添加新数字的 TextBlock
|
|
||||||
2. 将 `TranslateTransform.Y` 从 0 动画过渡到 `-digitHeight`
|
|
||||||
3. 动画完成后移除旧数字,重置 Y 为 0
|
|
||||||
|
|
||||||
### 2.3 动画参数
|
|
||||||
- 使用项目 `FluttermotionToken` 体系:滚动动画时长 `FluttermotionToken.Standard`(200ms)
|
|
||||||
- 缓动函数:`CubicEaseOut`(与项目现有动画风格一致)
|
|
||||||
- 冒号呼吸动画:透明度 1.0 → 0.3 → 1.0,周期 2 秒,使用 `DoubleTransition`
|
|
||||||
|
|
||||||
### 2.4 尺寸与布局
|
|
||||||
- 组件定义:`MinWidthCells = 4, MinHeightCells = 2`
|
|
||||||
- 缩放规则:2:1 比例(与 WorldClock 一致)
|
|
||||||
- 内部布局采用 `Viewbox` 包裹,确保在不同 cellSize 下自适应缩放
|
|
||||||
- 数字字体大小:基准设计为 130px(在 Viewbox 内),实际显示由 Viewbox 缩放
|
|
||||||
|
|
||||||
### 2.5 布局风格——不规则自由排版(iPhone StandBy 风格)
|
|
||||||
iPhone StandBy 的数字不是规矩地排成一条直线,而是有微妙的垂直偏移和大小差异,营造出自由散漫、有机的视觉节奏:
|
|
||||||
|
|
||||||
```
|
|
||||||
H1 H2 : M1 M2
|
|
||||||
↗↘ ↘↗ ↗↘ ↘↗
|
|
||||||
↕+6 ↕+2 : ↕+4 ↕+2
|
|
||||||
↖ -3° ↗ +4° : ↖ -1° ↗ +5°
|
|
||||||
←+6,↑-10 ←-2,↓+10 →+4,↑-3 ←-2,↓+12
|
|
||||||
```
|
|
||||||
|
|
||||||
每个数字有三个自由度:
|
|
||||||
- **垂直偏移 (Y)**:H1=-10, H2=+10, 冒号=+8, M1=-3, M2=+12
|
|
||||||
- **水平偏移 (X)**:H1=+6, H2=-2, 冒号=0, M1=+4, M2=-2
|
|
||||||
- **旋转角度 (Z)**:H1=-4°, H2=+3°, 冒号=-1°, M1=-2°, M2=+5°
|
|
||||||
|
|
||||||
### 2.6 视觉风格——圆润粗体 + Monet 主题色
|
|
||||||
- **字体**:`FontWeight.Bold`,配合较大的字号,视觉上圆润饱满
|
|
||||||
- **颜色**:使用项目 Monet 主题色系统,数字颜色跟随 `AdaptiveAccentBrush` / `SystemAccentColor`,而非纯黑/白
|
|
||||||
- 数字颜色通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
|
|
||||||
- 跟随系统:使用 `AdaptiveAccentBrush`(Monet 提取的强调色)
|
|
||||||
- 原生模式:使用组件自带的特色色彩
|
|
||||||
- 夜间模式:深色渐变背景 + 主题色数字(亮色调)
|
|
||||||
- 日间模式:浅色渐变背景 + 主题色数字(深色调)
|
|
||||||
- 夜间暗光环境:数字过渡到柔和的红色调(`#FF6B4A`),模拟 iPhone StandBy 夜间红色调
|
|
||||||
- **冒号颜色**:与数字同色,但有呼吸动画
|
|
||||||
- **日期行**:使用 `AdaptiveTextMutedBrush`(跟随主题的弱化文字色),字号约 14-16px 基准
|
|
||||||
- **根容器圆角**:`DesignCornerRadiusComponent`(遵循圆角规范)
|
|
||||||
|
|
||||||
## 3. 受影响文件
|
|
||||||
|
|
||||||
### 3.1 新增文件
|
|
||||||
| 文件 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | 新增 | 组件 AXAML 布局 |
|
|
||||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 新增 | 组件代码后置(动画逻辑、时间更新、模式切换) |
|
|
||||||
|
|
||||||
### 3.2 修改文件
|
|
||||||
| 文件 | 修改类型 | 受影响函数/区域 |
|
|
||||||
|------|----------|-----------------|
|
|
||||||
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增常量 | 新增 `DesktopStandbyDigitalClock` 常量 |
|
|
||||||
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 新增定义 | `CreateDefault()` 中新增组件定义 |
|
|
||||||
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增运行时注册 | `GetDefaultRegistrations()` 中新增运行时注册项 |
|
|
||||||
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | 新增缩放规则 | `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 |
|
|
||||||
|
|
||||||
## 4. 实现细节
|
|
||||||
|
|
||||||
### 4.1 BuiltInComponentIds 新增常量
|
|
||||||
```csharp
|
|
||||||
public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 ComponentRegistry 新增定义
|
|
||||||
```csharp
|
|
||||||
new DesktopComponentDefinition(
|
|
||||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
|
||||||
"StandBy Clock",
|
|
||||||
"Clock",
|
|
||||||
"Clock",
|
|
||||||
MinWidthCells: 4,
|
|
||||||
MinHeightCells: 2,
|
|
||||||
AllowStatusBarPlacement: false,
|
|
||||||
AllowDesktopPlacement: true),
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 DesktopComponentRuntimeRegistry 新增注册
|
|
||||||
```csharp
|
|
||||||
new DesktopComponentRuntimeRegistration(
|
|
||||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
|
||||||
"component.standby_digital_clock",
|
|
||||||
() => new StandbyDigitalClockWidget()),
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 NormalizeAspectRatioForComponent 缩放规则
|
|
||||||
在 `case BuiltInComponentIds.DesktopWorldClock:` 的同一分支中添加 `BuiltInComponentIds.DesktopStandbyDigitalClock`,使用 2:1 比例规则。
|
|
||||||
|
|
||||||
### 4.5 AXAML 布局结构
|
|
||||||
```xml
|
|
||||||
<UserControl x:Class="LanMountainDesktop.Views.Components.StandbyDigitalClockWidget">
|
|
||||||
<Border x:Name="RootBorder"
|
|
||||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
|
||||||
ClipToBounds="True"
|
|
||||||
Padding="14">
|
|
||||||
<!-- 背景在代码后置中设置(渐变,与AnalogClockWidget一致) -->
|
|
||||||
<Viewbox Stretch="Uniform">
|
|
||||||
<Grid Width="400" Height="200">
|
|
||||||
<StackPanel VerticalAlignment="Center"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Orientation="Horizontal">
|
|
||||||
<!-- H1 数位 -->
|
|
||||||
<Border x:Name="H1Clip" ClipToBounds="True" ...>
|
|
||||||
<Panel x:Name="H1Stack" ...>
|
|
||||||
<TextBlock x:Name="H1Text" Text="0" ... />
|
|
||||||
</Panel>
|
|
||||||
</Border>
|
|
||||||
<!-- H2 数位 -->
|
|
||||||
<Border x:Name="H2Clip" ClipToBounds="True" ...>
|
|
||||||
<Panel x:Name="H2Stack" ...>
|
|
||||||
<TextBlock x:Name="H2Text" Text="0" ... />
|
|
||||||
</Panel>
|
|
||||||
</Border>
|
|
||||||
<!-- 冒号 -->
|
|
||||||
<TextBlock x:Name="ColonText" Text=":" ... />
|
|
||||||
<!-- M1 数位 -->
|
|
||||||
<Border x:Name="M1Clip" ClipToBounds="True" ...>
|
|
||||||
<Panel x:Name="M1Stack" ...>
|
|
||||||
<TextBlock x:Name="M1Text" Text="0" ... />
|
|
||||||
</Panel>
|
|
||||||
</Border>
|
|
||||||
<!-- M2 数位 -->
|
|
||||||
<Border x:Name="M2Clip" ClipToBounds="True" ...>
|
|
||||||
<Panel x:Name="M2Stack" ...>
|
|
||||||
<TextBlock x:Name="M2Text" Text="0" ... />
|
|
||||||
</Panel>
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
<!-- 日期行 -->
|
|
||||||
<TextBlock x:Name="DateTextBlock"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
HorizontalAlignment="Center" ... />
|
|
||||||
</Grid>
|
|
||||||
</Viewbox>
|
|
||||||
</Border>
|
|
||||||
</UserControl>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 数字滚动动画核心代码(伪代码)
|
|
||||||
```csharp
|
|
||||||
private void AnimateDigit(Border clip, Panel stack, TextBlock currentText, char newDigit, double digitHeight)
|
|
||||||
{
|
|
||||||
var oldText = currentText;
|
|
||||||
var newTextBlock = new TextBlock
|
|
||||||
{
|
|
||||||
Text = newDigit.ToString(),
|
|
||||||
FontSize = oldText.FontSize,
|
|
||||||
FontWeight = oldText.FontWeight,
|
|
||||||
Foreground = oldText.Foreground,
|
|
||||||
Width = oldText.Width,
|
|
||||||
Height = digitHeight,
|
|
||||||
// 复制旧文本的所有样式属性
|
|
||||||
};
|
|
||||||
stack.Children.Add(newTextBlock);
|
|
||||||
|
|
||||||
// 应用 TranslateTransform 过渡动画
|
|
||||||
var transform = new TranslateTransform { Y = 0 };
|
|
||||||
stack.RenderTransform = transform;
|
|
||||||
stack.Transitions = new Transitions
|
|
||||||
{
|
|
||||||
new DoubleTransition(TranslateTransform.YProperty, FluttermotionToken.Standard, new CubicEaseOut())
|
|
||||||
};
|
|
||||||
|
|
||||||
// 触发动画:从当前位置滑到 -digitHeight
|
|
||||||
transform.Y = -digitHeight;
|
|
||||||
|
|
||||||
// 动画完成后清理
|
|
||||||
_ = DispatcherTimer.RunOnce(() =>
|
|
||||||
{
|
|
||||||
stack.Children.Remove(oldText);
|
|
||||||
transform.Y = 0;
|
|
||||||
stack.Transitions = null; // 移除过渡,避免重置时再次动画
|
|
||||||
// 更新引用
|
|
||||||
UpdateCurrentTextReference(newTextBlock);
|
|
||||||
}, FluttermotionToken.Standard);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.7 冒号呼吸动画
|
|
||||||
使用 `DispatcherTimer` 每秒切换冒号透明度:
|
|
||||||
```csharp
|
|
||||||
private void ToggleColonOpacity()
|
|
||||||
{
|
|
||||||
_colonVisible = !_colonVisible;
|
|
||||||
ColonText.Opacity = _colonVisible ? 1.0 : 0.3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
配合 `DoubleTransition` 使透明度变化平滑过渡。
|
|
||||||
|
|
||||||
### 4.8 日间/夜间模式
|
|
||||||
与 `AnalogClockWidget` 使用完全相同的判断逻辑:
|
|
||||||
- 检查 `ActualThemeVariant`
|
|
||||||
- 回退到 `AdaptiveSurfaceBaseBrush` 亮度计算
|
|
||||||
- 夜间模式:深色渐变背景 + 浅色数字
|
|
||||||
- 日间模式:浅色渐变背景 + 深色数字
|
|
||||||
|
|
||||||
### 4.9 时区与设置
|
|
||||||
- 复用 `AnalogClockWidget` 的时区解析和设置加载逻辑
|
|
||||||
- 使用 `ComponentSettingsSnapshot.DesktopClockTimeZoneId` 读取时区配置
|
|
||||||
- 点击打开世界时钟 AirApp
|
|
||||||
|
|
||||||
## 5. 边界条件与异常处理
|
|
||||||
|
|
||||||
| 场景 | 处理方式 |
|
|
||||||
|------|----------|
|
|
||||||
| 组件首次加载时数字尚未初始化 | 在构造函数中初始化所有数字为当前时间,不触发动画 |
|
|
||||||
| 快速连续触发数字变化(如时间同步导致跳变) | 在动画完成前忽略新的变化请求,或中断当前动画立即跳转到目标值 |
|
|
||||||
| cellSize 极小或极大 | `ApplyCellSize` 中 clamp 缩放因子(0.58-1.95,与 AnalogClockWidget 一致) |
|
|
||||||
| 时区切换 | 重新加载设置并更新所有数字(无动画,直接设置) |
|
|
||||||
| 主题切换 | 通过 `ApplyModeVisualIfNeeded()` 在下一个 tick 自动检测并切换 |
|
|
||||||
| 组件被销毁 | `DetachedFromVisualTree` 停止 timer,清理资源 |
|
|
||||||
| 冒号动画在组件不可见时 | timer 仍在运行但 Opacity 变化无性能开销;若需要可结合 `IDesktopPageVisibilityAwareComponentWidget` |
|
|
||||||
|
|
||||||
## 6. 数据流路径
|
|
||||||
|
|
||||||
```
|
|
||||||
DispatcherTimer (1s interval)
|
|
||||||
→ OnTimerTick
|
|
||||||
→ 计算当前时间 (TimeZoneInfo.ConvertTimeFromUtc)
|
|
||||||
→ 比较新旧时间数字
|
|
||||||
→ 若有变化: AnimateDigit() 执行滚动动画
|
|
||||||
→ ToggleColonOpacity() 切换冒号
|
|
||||||
→ ApplyModeVisualIfNeeded() 检查日/夜间切换
|
|
||||||
→ UpdateDateText() 更新日期文本
|
|
||||||
|
|
||||||
用户点击 → OnPointerReleased → AirAppLauncherServiceProvider.OpenWorldClock()
|
|
||||||
|
|
||||||
时区变更 → TimeZoneChanged event → RefreshFromSettings() → 无动画更新所有数字
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 预期成果
|
|
||||||
|
|
||||||
- 在桌面组件选择器中新增 "StandBy Clock" 组件,位于 Clock 分类
|
|
||||||
- 拖放到桌面后显示 4×2 大数字时钟
|
|
||||||
- 数字切换时有流畅的垂直滑动动画
|
|
||||||
- 冒号有呼吸闪烁效果
|
|
||||||
- 支持日间/夜间自动切换
|
|
||||||
- 支持时区配置
|
|
||||||
- 支持组件缩放(2:1 比例规则)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# StandBy Digital Clock 实现总结
|
|
||||||
|
|
||||||
## 完成状态
|
|
||||||
全部任务已完成,构建通过(0 错误)。
|
|
||||||
|
|
||||||
## 变更清单
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期,Monet 主题色绑定 |
|
|
||||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置:数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 |
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 |
|
|
||||||
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 |
|
|
||||||
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 |
|
|
||||||
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 |
|
|
||||||
|
|
||||||
## 核心设计要点
|
|
||||||
|
|
||||||
### 不规则自由排版(iPhone StandBy 风格)
|
|
||||||
- 每个数字有独立的垂直 Margin 偏移(H1 上移10, H2 下移8, M1 上移5, M2 下移10)
|
|
||||||
- 冒号比数字中心略低(下移6)
|
|
||||||
- 数字间距不等,营造自由散漫的视觉节奏
|
|
||||||
|
|
||||||
### Monet 主题色
|
|
||||||
- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色
|
|
||||||
- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
|
|
||||||
- 跟随系统:使用 Monet 提取的强调色
|
|
||||||
- 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy
|
|
||||||
- 日期文本使用 `AdaptiveTextMutedBrush`
|
|
||||||
|
|
||||||
### 数字滚动动画
|
|
||||||
- `TranslateTransform.Y` + `DoubleTransition`(200ms CubicEaseOut)
|
|
||||||
- 动画完成后清理旧 TextBlock 并重置 transform
|
|
||||||
|
|
||||||
### 冒号呼吸
|
|
||||||
- 每秒切换 Opacity(1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡
|
|
||||||
|
|
||||||
### 日/夜模式
|
|
||||||
- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算
|
|
||||||
- 夜间:深色渐变背景 + 亮调强调色数字
|
|
||||||
- 日间:浅色渐变背景 + 深调强调色数字
|
|
||||||
|
|
||||||
### 组件规格
|
|
||||||
- 尺寸:4×2 (MinWidthCells=4, MinHeightCells=2)
|
|
||||||
- 分类:Clock
|
|
||||||
- 缩放:2:1 比例 (Proportional)
|
|
||||||
- 字体:FontWeight.Bold, 120px 基准
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# StandBy Digital Clock 任务计划
|
|
||||||
|
|
||||||
- [x] Task 1: 注册组件定义与运行时
|
|
||||||
- 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量
|
|
||||||
- 1.2: 在 `ComponentRegistry.cs` 的 `CreateDefault()` 中新增 `DesktopComponentDefinition`(4×2, Clock 分类, Proportional)
|
|
||||||
- 1.3: 在 `DesktopComponentRuntimeRegistry.cs` 的 `GetDefaultRegistrations()` 中新增运行时注册项
|
|
||||||
- 1.4: 在 `MainWindow.ComponentSystem.cs` 的 `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则
|
|
||||||
|
|
||||||
- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局
|
|
||||||
- 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 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)
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
---
|
|
||||||
name: Launcher 单项目解耦
|
|
||||||
overview: 在保持单一 LanMountainDesktop.Launcher 项目、单一 exe、零部署风险的前提下,按职责域增量重构:目录分层、RunAsync→Pipeline+Phase、UpdateEngine→策略类、App→纯 Avalonia+LauncherOrchestrator;执行过程中由 Agent 自主 Git 提交,每域可编译可测。
|
|
||||||
todos:
|
|
||||||
- id: phase-a-diagnostics
|
|
||||||
content: Phase 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 +0,0 @@
|
|||||||
|
|
||||||
59
.gitignore
vendored
59
.gitignore
vendored
@@ -519,3 +519,62 @@ nul
|
|||||||
/velopack-output-local
|
/velopack-output-local
|
||||||
/test-aot-publish
|
/test-aot-publish
|
||||||
/.claude/worktrees
|
/.claude/worktrees
|
||||||
|
|
||||||
|
## ============================================================================
|
||||||
|
## 以下为补充的忽略规则 - 清理杂乱文件
|
||||||
|
## ============================================================================
|
||||||
|
|
||||||
|
# AI 工具配置目录(本地开发工具配置,不应上传到 GitHub)
|
||||||
|
.codex/
|
||||||
|
.comate/
|
||||||
|
.cursor/
|
||||||
|
.kilo/
|
||||||
|
|
||||||
|
# 临时调试/分析脚本(一次性使用,不属于项目构建脚本)
|
||||||
|
/test-launcher.ps1
|
||||||
|
/size_analysis.ps1
|
||||||
|
/size_analysis2.ps1
|
||||||
|
/analyze_commits.ps1
|
||||||
|
/get_commits.ps1
|
||||||
|
/get_commits.bat
|
||||||
|
/analyze_commits.py
|
||||||
|
/run_analysis.py
|
||||||
|
/parse_git_log.py
|
||||||
|
/get_git_log.py
|
||||||
|
/settings_extractor.py
|
||||||
|
/test-omo-resolve.js
|
||||||
|
|
||||||
|
# 临时测试项目(本地调试用,不属于正式项目)
|
||||||
|
/testicon/
|
||||||
|
/TestFluentIcons/
|
||||||
|
/CheckIpcAot/
|
||||||
|
|
||||||
|
# 临时代码片段和测试文件
|
||||||
|
/test_fluenticons.cs
|
||||||
|
/check_ipc.cs
|
||||||
|
|
||||||
|
# 临时文档和笔记(不属于正式项目文档)
|
||||||
|
/noise.md
|
||||||
|
/design.md
|
||||||
|
/phainon.yml
|
||||||
|
/SECURITY_AUDIT_REPORT.md
|
||||||
|
/SECURITY_AUDIT_REPORT_2026-05-24.md
|
||||||
|
/SECURITY_AUDIT_REPORT_2026-06-01.md
|
||||||
|
/CODE_WIKI.md
|
||||||
|
|
||||||
|
# 临时数据文件
|
||||||
|
/_b.txt
|
||||||
|
/diff.txt
|
||||||
|
/tmp.json
|
||||||
|
/misans.zip
|
||||||
|
/mockup-noise-level.html
|
||||||
|
|
||||||
|
# Mock/原型文件
|
||||||
|
/mocks/
|
||||||
|
|
||||||
|
# Git 命令误输出文件(文件名含空格的异常文件)
|
||||||
|
/ago*
|
||||||
|
|
||||||
|
# 临时 AXAML 备份文件
|
||||||
|
/temp_old_main.axaml
|
||||||
|
/temp_old_main_utf8.axaml
|
||||||
|
|||||||
376
.kilo/package-lock.json
generated
376
.kilo/package-lock.json
generated
@@ -1,376 +0,0 @@
|
|||||||
{
|
|
||||||
"name": ".kilo",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"@kilocode/plugin": "7.2.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@kilocode/plugin": {
|
|
||||||
"version": "7.2.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.20.tgz",
|
|
||||||
"integrity": "sha512-M5lMc58Mu9j1zveH+E3ZUKRHefzh+acNAqHGSG3TuF6K2l16KrZlCl38CZlgj2R5Qgaig6Jec/F2p9Rbn3BhCQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@kilocode/sdk": "7.2.20",
|
|
||||||
"effect": "4.0.0-beta.48",
|
|
||||||
"zod": "4.1.8"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@opentui/core": ">=0.1.99",
|
|
||||||
"@opentui/solid": ">=0.1.99"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@opentui/core": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@opentui/solid": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@kilocode/sdk": {
|
|
||||||
"version": "7.2.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.20.tgz",
|
|
||||||
"integrity": "sha512-KUpu1fyzcAyZWpiv//834zGLN+PYzIH65crs15VTtUJ9CDvGqcj08EM0XlkF9jMuGQAjHjfRbvCfml3+YO31+Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cross-spawn": "7.0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-key": "^3.1.0",
|
|
||||||
"shebang-command": "^2.0.0",
|
|
||||||
"which": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/effect": {
|
|
||||||
"version": "4.0.0-beta.48",
|
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
|
||||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.1.0",
|
|
||||||
"fast-check": "^4.6.0",
|
|
||||||
"find-my-way-ts": "^0.1.6",
|
|
||||||
"ini": "^6.0.0",
|
|
||||||
"kubernetes-types": "^1.30.0",
|
|
||||||
"msgpackr": "^1.11.9",
|
|
||||||
"multipasta": "^0.2.7",
|
|
||||||
"toml": "^4.1.1",
|
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"yaml": "^2.8.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-check": {
|
|
||||||
"version": "4.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
|
||||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/dubzzz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fast-check"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pure-rand": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.17.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/find-my-way-ts": {
|
|
||||||
"version": "0.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
|
||||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ini": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.17.0 || >=22.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/isexe": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/kubernetes-types": {
|
|
||||||
"version": "1.30.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
|
||||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/msgpackr": {
|
|
||||||
"version": "1.11.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
|
|
||||||
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"msgpackr-extract": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/msgpackr-extract": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"node-gyp-build-optional-packages": "5.2.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/multipasta": {
|
|
||||||
"version": "0.2.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
|
||||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/node-gyp-build-optional-packages": {
|
|
||||||
"version": "5.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
|
||||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-gyp-build-optional-packages": "bin.js",
|
|
||||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
|
||||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-key": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pure-rand": {
|
|
||||||
"version": "8.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
|
||||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/dubzzz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fast-check"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/shebang-command": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"shebang-regex": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shebang-regex": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/toml": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "13.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
|
||||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist-node/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"isexe": "^2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-which": "bin/node-which"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "4.1.8",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# LanMountainDesktop 启动器无法启动应用 - 问题分析与修复计划
|
|
||||||
|
|
||||||
## 1. 项目架构概述
|
|
||||||
|
|
||||||
LanMountainDesktop 采用**双进程架构**:
|
|
||||||
- **Launcher** (`LanMountainDesktop.Launcher`) - 启动器,负责版本管理、更新、启动主程序
|
|
||||||
- **Host** (`LanMountainDesktop`) - 主应用宿主
|
|
||||||
|
|
||||||
### 启动流程
|
|
||||||
1. 用户启动 `LanMountainDesktop.Launcher.exe`
|
|
||||||
2. Launcher 扫描 `app-*` 目录,选择最佳版本
|
|
||||||
3. 检查并应用待处理的更新
|
|
||||||
4. 处理插件升级队列
|
|
||||||
5. 启动主程序 `app-{version}/LanMountainDesktop.exe`
|
|
||||||
6. 通过 IPC 监控主程序启动进度
|
|
||||||
|
|
||||||
## 2. 问题分析
|
|
||||||
|
|
||||||
### 2.1 核心问题:主机可执行文件找不到
|
|
||||||
|
|
||||||
根据代码分析(`DeploymentLocator.cs`),启动器通过以下顺序查找主机可执行文件:
|
|
||||||
|
|
||||||
1. **显式 app-root**(如果通过命令行指定)
|
|
||||||
2. **已发布部署**(查找 `app-*` 目录)
|
|
||||||
3. **可移植主机**(直接在应用根目录)
|
|
||||||
4. **调试主机**(开发模式,查找构建输出路径)
|
|
||||||
5. **旧版回退路径**
|
|
||||||
|
|
||||||
**当前状态检查**:
|
|
||||||
- ❌ 未找到 `app-*` 目录(生产部署结构不存在)
|
|
||||||
- ❌ 未找到 `bin/Debug/**/*.exe`(项目未构建或构建输出不存在)
|
|
||||||
|
|
||||||
### 2.2 可能的启动失败原因
|
|
||||||
|
|
||||||
| 问题 | 描述 | 优先级 |
|
|
||||||
|------|------|--------|
|
|
||||||
| **项目未构建** | LanMountainDesktop 主程序未编译,没有可执行文件 | P0 |
|
|
||||||
| **部署结构缺失** | 生产模式下缺少 `app-*` 目录结构 | P0 |
|
|
||||||
| **开发模式路径问题** | 调试模式下路径计算错误或构建输出不在预期位置 | P1 |
|
|
||||||
| **.NET 版本问题** | 项目使用 .NET 10.0,运行环境可能缺少对应运行时 | P1 |
|
|
||||||
| **更新应用失败** | `ApplyPendingUpdateAsync` 失败导致无法完成部署 | P2 |
|
|
||||||
| **IPC 连接超时** | 主程序启动后未及时建立 IPC 连接,导致启动器超时 | P2 |
|
|
||||||
|
|
||||||
### 2.3 关键代码位置
|
|
||||||
|
|
||||||
- **主机查找逻辑**: `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs`
|
|
||||||
- `FindCurrentDeploymentDirectory()` - 查找 app-* 目录
|
|
||||||
- `ResolveHostExecutable()` - 解析主机路径
|
|
||||||
|
|
||||||
- **启动协调逻辑**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
|
||||||
- `RunAsync()` - 主启动流程
|
|
||||||
- `LaunchHostWithIpcAsync()` - 启动主机进程
|
|
||||||
|
|
||||||
- **更新引擎**: `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
|
|
||||||
- `ApplyPendingUpdateAsync()` - 应用待处理的更新
|
|
||||||
|
|
||||||
## 3. 诊断步骤
|
|
||||||
|
|
||||||
### 步骤 1:检查构建状态
|
|
||||||
```bash
|
|
||||||
dotnet --info
|
|
||||||
dotnet build LanMountainDesktop.slnx -c Debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 2:验证主机可执行文件是否存在
|
|
||||||
检查以下路径是否存在 `LanMountainDesktop.exe`:
|
|
||||||
- `LanMountainDesktop/bin/Debug/net10.0/`
|
|
||||||
- `LanMountainDesktop/bin/Release/net10.0/`
|
|
||||||
|
|
||||||
### 步骤 3:测试直接运行主程序(跳过 Launcher)
|
|
||||||
```bash
|
|
||||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 4:检查 Launcher 启动日志
|
|
||||||
在开发模式下运行 Launcher 并查看控制台输出:
|
|
||||||
```bash
|
|
||||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 修复计划
|
|
||||||
|
|
||||||
### 方案 A:构建并配置开发环境(推荐)
|
|
||||||
|
|
||||||
**适用场景**:开发或调试环境
|
|
||||||
|
|
||||||
1. **构建整个解决方案**
|
|
||||||
```bash
|
|
||||||
dotnet restore
|
|
||||||
dotnet build LanMountainDesktop.slnx -c Debug
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **验证构建输出**
|
|
||||||
- 确认 `LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe` 存在
|
|
||||||
- 确认 `LanMountainDesktop.Launcher/bin/Debug/net10.0/LanMountainDesktop.Launcher.exe` 存在
|
|
||||||
|
|
||||||
3. **测试 Launcher 启动**
|
|
||||||
```bash
|
|
||||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **如果路径查找失败,检查 `DeploymentLocator.cs` 中的开发路径**
|
|
||||||
- 当前逻辑(第 366-375 行)查找:
|
|
||||||
- `../LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe`
|
|
||||||
- `../LanMountainDesktop/bin/Release/net10.0/LanMountainDesktop.exe`
|
|
||||||
- 确认这些路径与实际的构建输出路径匹配
|
|
||||||
|
|
||||||
### 方案 B:创建生产部署结构
|
|
||||||
|
|
||||||
**适用场景**:生产环境或模拟生产环境
|
|
||||||
|
|
||||||
1. **发布主程序**
|
|
||||||
```bash
|
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Release -o app-1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **创建 .current 标记文件**
|
|
||||||
```bash
|
|
||||||
echo. > app-1.0.0/.current
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **从 Launcher 启动**
|
|
||||||
- Launcher 应该能找到 `app-1.0.0/LanMountainDesktop.exe`
|
|
||||||
|
|
||||||
### 方案 C:修复潜在的代码问题
|
|
||||||
|
|
||||||
如果上述方案无法解决问题,可能需要修复代码:
|
|
||||||
|
|
||||||
#### C1. 增强错误处理和日志
|
|
||||||
在 `DeploymentLocator.cs` 中添加更详细的日志输出,帮助诊断路径查找失败的原因。
|
|
||||||
|
|
||||||
#### C2. 检查更新逻辑
|
|
||||||
如果 `ApplyPendingUpdateAsync` 失败,可能导致启动中止。检查 `.launcher/update/incoming/` 目录是否有残留的更新文件。
|
|
||||||
|
|
||||||
#### C3. 调整超时设置
|
|
||||||
如果主程序启动较慢,可以适当增加 `LauncherFlowCoordinator.cs` 中的超时时间:
|
|
||||||
- `StartupSoftTimeout` (当前 10 秒)
|
|
||||||
- `StartupHardTimeout` (当前 30 秒)
|
|
||||||
|
|
||||||
## 5. 建议执行顺序
|
|
||||||
|
|
||||||
1. ✅ **首先执行方案 A 的步骤 1-2**(构建项目)
|
|
||||||
2. ✅ **执行诊断步骤 3**(测试直接运行主程序)
|
|
||||||
3. ✅ **执行诊断步骤 4**(查看 Launcher 启动日志)
|
|
||||||
4. 根据日志输出决定后续操作:
|
|
||||||
- 如果显示 "host executable was not found" → 检查路径配置
|
|
||||||
- 如果显示 "update apply failed" → 清理更新缓存
|
|
||||||
- 如果主程序启动后超时 → 检查 IPC 连接或增加超时
|
|
||||||
|
|
||||||
## 6. 验证方法
|
|
||||||
|
|
||||||
修复后,通过以下方式验证:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发模式启动
|
|
||||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
|
||||||
|
|
||||||
# 或直接运行 Launcher 可执行文件
|
|
||||||
# (需要先构建 Launcher)
|
|
||||||
```
|
|
||||||
|
|
||||||
启动后应该看到:
|
|
||||||
1. Splash 窗口显示
|
|
||||||
2. 主程序桌面窗口出现
|
|
||||||
3. Launcher 自动退出(或最小化到托盘)
|
|
||||||
|
|
||||||
## 7. 注意事项
|
|
||||||
|
|
||||||
- 项目使用 .NET 10.0(`global.json` 指定版本 10.0.103)
|
|
||||||
- 确保开发环境已安装对应的 .NET SDK
|
|
||||||
- 如果修改了 `DeploymentLocator.cs` 的路径查找逻辑,需要同步更新文档 `docs/DEVELOPMENT.md`
|
|
||||||
1577
CODE_WIKI.md
1577
CODE_WIKI.md
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="dotnetCampus.Ipc" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
[IpcPublic]
|
|
||||||
public interface IMyService {
|
|
||||||
Task<MyResult> DoWork(MyRequest req);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MyResult { public string Msg {get;set;} }
|
|
||||||
public class MyRequest { public string Data {get;set;} }
|
|
||||||
@@ -91,10 +91,9 @@ public sealed record PluginManifest(
|
|||||||
if (requestedVersion.Major != currentVersion.Major)
|
if (requestedVersion.Major != currentVersion.Major)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
|
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', " +
|
||||||
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
|
$"but the host provides '{PluginSdkInfo.ApiVersion}'. " +
|
||||||
$"This host only supports v{currentVersion.Major}.x plugins and rejects v{requestedVersion.Major}.x packages by default. " +
|
$"This host only supports API {PluginSdkInfo.ApiVersion} plugins.");
|
||||||
$"Migrate the plugin manifest and code to API {PluginSdkInfo.ApiVersion}, then rebuild and republish the package.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -1830,7 +1830,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
|||||||
entry.Author,
|
entry.Author,
|
||||||
entry.Version,
|
entry.Version,
|
||||||
entry.ApiVersion,
|
entry.ApiVersion,
|
||||||
string.Empty,
|
entry.EntranceAssembly,
|
||||||
entry.SharedContracts
|
entry.SharedContracts
|
||||||
.Select(contract => new PluginCatalogSharedContractInfo(
|
.Select(contract => new PluginCatalogSharedContractInfo(
|
||||||
contract.Id,
|
contract.Id,
|
||||||
@@ -1858,7 +1858,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
|||||||
entry.UpdatedAt,
|
entry.UpdatedAt,
|
||||||
entry.PackageSizeBytes,
|
entry.PackageSizeBytes,
|
||||||
entry.Sha256,
|
entry.Sha256,
|
||||||
null);
|
entry.Md5);
|
||||||
|
|
||||||
var sources = BuildPackageSources(entry);
|
var sources = BuildPackageSources(entry);
|
||||||
|
|
||||||
@@ -1873,21 +1873,16 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
|||||||
|
|
||||||
private static IReadOnlyList<PluginCapabilityInfo> BuildCapabilities(AirAppMarketPluginEntry entry)
|
private static IReadOnlyList<PluginCapabilityInfo> BuildCapabilities(AirAppMarketPluginEntry entry)
|
||||||
{
|
{
|
||||||
if (entry.Capabilities is null)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var capabilities = new List<PluginCapabilityInfo>();
|
var capabilities = new List<PluginCapabilityInfo>();
|
||||||
capabilities.AddRange(entry.Capabilities.SharedContracts.Select(contract =>
|
capabilities.AddRange(entry.SharedContracts.Select(contract =>
|
||||||
new PluginCapabilityInfo(contract.Id, contract.Version, contract.AssemblyName)));
|
new PluginCapabilityInfo(contract.Id, contract.Version, contract.AssemblyName)));
|
||||||
capabilities.AddRange(entry.Capabilities.DesktopComponents.Select(id =>
|
capabilities.AddRange(entry.DesktopComponents.Select(id =>
|
||||||
new PluginCapabilityInfo(id, null, null)));
|
new PluginCapabilityInfo(id, null, null)));
|
||||||
capabilities.AddRange(entry.Capabilities.SettingsSections.Select(id =>
|
capabilities.AddRange(entry.SettingsSections.Select(id =>
|
||||||
new PluginCapabilityInfo(id, null, null)));
|
new PluginCapabilityInfo(id, null, null)));
|
||||||
capabilities.AddRange(entry.Capabilities.Exports.Select(id =>
|
capabilities.AddRange(entry.Exports.Select(id =>
|
||||||
new PluginCapabilityInfo(id, null, null)));
|
new PluginCapabilityInfo(id, null, null)));
|
||||||
capabilities.AddRange(entry.Capabilities.MessageTypes.Select(id =>
|
capabilities.AddRange(entry.MessageTypes.Select(id =>
|
||||||
new PluginCapabilityInfo(id, null, null)));
|
new PluginCapabilityInfo(id, null, null)));
|
||||||
|
|
||||||
return capabilities
|
return capabilities
|
||||||
|
|||||||
@@ -184,8 +184,6 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
|||||||
services.AddSingleton(_localizationService);
|
services.AddSingleton(_localizationService);
|
||||||
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
||||||
services.AddSingleton<WeatherLocationRefreshService>();
|
services.AddSingleton<WeatherLocationRefreshService>();
|
||||||
services.AddSingleton<AirAppMarketIconService>();
|
|
||||||
services.AddSingleton<AirAppMarketReadmeService>();
|
|
||||||
|
|
||||||
var pluginRuntime = _pluginRuntimeAccessor();
|
var pluginRuntime = _pluginRuntimeAccessor();
|
||||||
if (pluginRuntime is not null)
|
if (pluginRuntime is not null)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public enum PluginCatalogPrimaryActionState
|
|||||||
Incompatible
|
Incompatible
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class PluginCatalogItemViewModel : ViewModelBase
|
public sealed partial class PluginCatalogItemViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
private readonly LocalizationService _localizationService;
|
private readonly LocalizationService _localizationService;
|
||||||
private readonly string _languageCode;
|
private readonly string _languageCode;
|
||||||
@@ -111,6 +111,11 @@ public sealed partial class PluginCatalogItemViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(HasIcon));
|
OnPropertyChanged(nameof(HasIcon));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IconBitmap = null;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task EnsureIconLoadedAsync(AirAppMarketIconService iconService)
|
public async Task EnsureIconLoadedAsync(AirAppMarketIconService iconService)
|
||||||
{
|
{
|
||||||
if (_isLoadingIcon || IconBitmap is not null)
|
if (_isLoadingIcon || IconBitmap is not null)
|
||||||
@@ -376,7 +381,7 @@ public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
|
|||||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
|
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
private readonly IPluginCatalogSettingsService _pluginCatalog;
|
private readonly IPluginCatalogSettingsService _pluginCatalog;
|
||||||
@@ -456,6 +461,19 @@ public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
|
|||||||
await RefreshAsync();
|
await RefreshAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var item in CatalogPlugins)
|
||||||
|
{
|
||||||
|
item.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
CatalogPlugins.Clear();
|
||||||
|
FilteredPlugins.Clear();
|
||||||
|
_iconService.Dispose();
|
||||||
|
_readmeService.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item)
|
public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item)
|
||||||
{
|
{
|
||||||
return new PluginCatalogDetailViewModel(
|
return new PluginCatalogDetailViewModel(
|
||||||
|
|||||||
@@ -44,26 +44,39 @@ public partial class PluginCatalogSettingsPage : SettingsPageBase
|
|||||||
await ViewModel.InitializeAsync();
|
await ViewModel.InitializeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDetachedFromVisualTree(e);
|
||||||
|
// The settings window may keep pages alive while navigating between them; release the
|
||||||
|
// icon bitmaps and HTTP clients held by the view model when this page leaves the tree.
|
||||||
|
if (!Design.IsDesignMode)
|
||||||
|
{
|
||||||
|
ViewModel.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static PluginCatalogSettingsPageViewModel CreateDefaultViewModel()
|
private static PluginCatalogSettingsPageViewModel CreateDefaultViewModel()
|
||||||
{
|
{
|
||||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
var localizationService = new LocalizationService();
|
var localizationService = new LocalizationService();
|
||||||
|
var assetCache = new PluginMarketAssetCacheService(AppDataPathProvider.GetPluginMarketDirectory());
|
||||||
return new PluginCatalogSettingsPageViewModel(
|
return new PluginCatalogSettingsPageViewModel(
|
||||||
settingsFacade,
|
settingsFacade,
|
||||||
localizationService,
|
localizationService,
|
||||||
new AirAppMarketIconService(),
|
new AirAppMarketIconService(assetCache),
|
||||||
new AirAppMarketReadmeService());
|
new AirAppMarketReadmeService(assetCache));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
|
private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
|
||||||
{
|
{
|
||||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
var localizationService = new LocalizationService();
|
var localizationService = new LocalizationService();
|
||||||
|
var assetCache = new PluginMarketAssetCacheService(AppDataPathProvider.GetPluginMarketDirectory());
|
||||||
var viewModel = new PluginCatalogSettingsPageViewModel(
|
var viewModel = new PluginCatalogSettingsPageViewModel(
|
||||||
settingsFacade,
|
settingsFacade,
|
||||||
localizationService,
|
localizationService,
|
||||||
new AirAppMarketIconService(),
|
new AirAppMarketIconService(assetCache),
|
||||||
new AirAppMarketReadmeService());
|
new AirAppMarketReadmeService(assetCache));
|
||||||
|
|
||||||
var previewHostVersion = new Version(1, 2, 0);
|
var previewHostVersion = new Version(1, 2, 0);
|
||||||
var items = new[]
|
var items = new[]
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.PluginMarket;
|
|
||||||
|
|
||||||
internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
||||||
AllowTrailingCommas = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly bool _ownsHttpClient;
|
|
||||||
private readonly ConcurrentDictionary<string, string> _defaultBranchCache = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public AirAppMarketMetadataResolverService(HttpClient? httpClient = null)
|
|
||||||
{
|
|
||||||
if (httpClient is null)
|
|
||||||
{
|
|
||||||
_httpClient = new HttpClient
|
|
||||||
{
|
|
||||||
Timeout = TimeSpan.FromSeconds(20)
|
|
||||||
};
|
|
||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
|
||||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
|
||||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
|
||||||
_ownsHttpClient = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_httpClient = httpClient;
|
|
||||||
_ownsHttpClient = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AirAppMarketIndexDocument> EnrichAsync(
|
|
||||||
AirAppMarketIndexDocument document,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(document);
|
|
||||||
|
|
||||||
if (document.Plugins.Count == 0)
|
|
||||||
{
|
|
||||||
return document;
|
|
||||||
}
|
|
||||||
|
|
||||||
var enrichedPlugins = new List<AirAppMarketPluginEntry>(document.Plugins.Count);
|
|
||||||
foreach (var plugin in document.Plugins)
|
|
||||||
{
|
|
||||||
enrichedPlugins.Add(await EnrichPluginAsync(plugin, cancellationToken).ConfigureAwait(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AirAppMarketIndexDocument
|
|
||||||
{
|
|
||||||
SchemaVersion = document.SchemaVersion,
|
|
||||||
SourceId = document.SourceId,
|
|
||||||
SourceName = document.SourceName,
|
|
||||||
GeneratedAt = document.GeneratedAt,
|
|
||||||
Contracts = document.Contracts,
|
|
||||||
Plugins = enrichedPlugins
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_ownsHttpClient)
|
|
||||||
{
|
|
||||||
_httpClient.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<AirAppMarketPluginEntry> EnrichPluginAsync(
|
|
||||||
AirAppMarketPluginEntry entry,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.RepositoryUrl, out var owner, out var repositoryName) &&
|
|
||||||
!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.ProjectUrl, out owner, out repositoryName))
|
|
||||||
{
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
var branchCandidates = await GetBranchCandidatesAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
|
||||||
PluginManifest? manifest = null;
|
|
||||||
AirAppMarketRepositoryTemplate? template = null;
|
|
||||||
|
|
||||||
foreach (var branch in branchCandidates)
|
|
||||||
{
|
|
||||||
manifest ??= await TryLoadPluginManifestAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
|
||||||
template ??= await TryLoadTemplateAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (manifest is not null && template is not null)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var repository = entry.Repository ?? new AirAppMarketPluginRepositoryEntry();
|
|
||||||
var resolvedManifest = manifest;
|
|
||||||
var resolvedPackageSources = entry.PackageSources.Count > 0
|
|
||||||
? entry.PackageSources
|
|
||||||
: entry.Publication?.PackageSources ?? [];
|
|
||||||
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
|
|
||||||
var existingManifest = entry.Manifest;
|
|
||||||
var existingCompatibility = entry.Compatibility;
|
|
||||||
var existingPublication = entry.Publication;
|
|
||||||
|
|
||||||
return new AirAppMarketPluginEntry
|
|
||||||
{
|
|
||||||
PluginId = AirAppMarketIndexDocument.NormalizeValue(entry.PluginId) ?? entry.PluginId,
|
|
||||||
Manifest = resolvedManifest is null
|
|
||||||
? entry.Manifest
|
|
||||||
: new AirAppMarketPluginManifestEntry
|
|
||||||
{
|
|
||||||
Id = resolvedManifest.Id,
|
|
||||||
Name = resolvedManifest.Name,
|
|
||||||
Description = resolvedManifest.Description ?? string.Empty,
|
|
||||||
Author = resolvedManifest.Author ?? string.Empty,
|
|
||||||
Version = resolvedManifest.Version ?? string.Empty,
|
|
||||||
ApiVersion = resolvedManifest.ApiVersion ?? string.Empty,
|
|
||||||
EntranceAssembly = resolvedManifest.EntranceAssembly,
|
|
||||||
SharedContracts = resolvedManifest.SharedContracts?
|
|
||||||
.Select(contract => new AirAppMarketPluginDependencyEntry
|
|
||||||
{
|
|
||||||
Id = contract.Id,
|
|
||||||
Version = contract.Version,
|
|
||||||
AssemblyName = contract.AssemblyName
|
|
||||||
})
|
|
||||||
.ToList()
|
|
||||||
?? []
|
|
||||||
},
|
|
||||||
Compatibility = entry.Compatibility is not null || template is not null || !string.IsNullOrWhiteSpace(entry.MinHostVersion) || !string.IsNullOrWhiteSpace(entry.ApiVersion)
|
|
||||||
? new AirAppMarketPluginCompatibilityEntry
|
|
||||||
{
|
|
||||||
MinHostVersion = FirstNonEmpty(
|
|
||||||
template?.MinHostVersion,
|
|
||||||
existingCompatibility?.MinHostVersion,
|
|
||||||
entry.MinHostVersion),
|
|
||||||
PluginApiVersion = FirstNonEmpty(
|
|
||||||
resolvedManifest?.ApiVersion,
|
|
||||||
existingCompatibility?.PluginApiVersion,
|
|
||||||
existingCompatibility?.ApiVersion,
|
|
||||||
existingManifest?.ApiVersion,
|
|
||||||
entry.ApiVersion)
|
|
||||||
?? string.Empty
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
Repository = new AirAppMarketPluginRepositoryEntry
|
|
||||||
{
|
|
||||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
|
||||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
|
||||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
|
||||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
|
||||||
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
|
||||||
?? string.Empty,
|
|
||||||
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
|
||||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
|
||||||
},
|
|
||||||
Publication = entry.Publication,
|
|
||||||
Capabilities = entry.Capabilities,
|
|
||||||
Id = FirstNonEmpty(resolvedManifest?.Id, existingManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
|
||||||
Name = FirstNonEmpty(resolvedManifest?.Name, existingManifest?.Name, entry.Name) ?? string.Empty,
|
|
||||||
Description = FirstNonEmpty(resolvedManifest?.Description, existingManifest?.Description, entry.Description) ?? string.Empty,
|
|
||||||
Author = FirstNonEmpty(resolvedManifest?.Author, existingManifest?.Author, entry.Author) ?? string.Empty,
|
|
||||||
Version = FirstNonEmpty(resolvedManifest?.Version, existingManifest?.Version, entry.Version) ?? string.Empty,
|
|
||||||
ApiVersion = FirstNonEmpty(
|
|
||||||
resolvedManifest?.ApiVersion,
|
|
||||||
existingCompatibility?.PluginApiVersion,
|
|
||||||
existingCompatibility?.ApiVersion,
|
|
||||||
existingManifest?.ApiVersion,
|
|
||||||
entry.ApiVersion) ?? string.Empty,
|
|
||||||
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, existingCompatibility?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
|
||||||
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
|
|
||||||
Sha256 = FirstNonEmpty(existingPublication?.Sha256, entry.Sha256) ?? string.Empty,
|
|
||||||
PackageSizeBytes = existingPublication?.PackageSizeBytes > 0 ? existingPublication.PackageSizeBytes : entry.PackageSizeBytes,
|
|
||||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
|
||||||
ReleaseTag = FirstNonEmpty(existingPublication?.ReleaseTag, entry.ReleaseTag) ?? string.Empty,
|
|
||||||
ReleaseAssetName = FirstNonEmpty(existingPublication?.ReleaseAssetName, entry.ReleaseAssetName) ?? string.Empty,
|
|
||||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
|
||||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
|
||||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
|
||||||
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
|
||||||
?? string.Empty,
|
|
||||||
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
|
||||||
SharedContracts = resolvedManifest?.SharedContracts
|
|
||||||
?.Select(contract => new AirAppMarketPluginDependencyEntry
|
|
||||||
{
|
|
||||||
Id = contract.Id,
|
|
||||||
Version = contract.Version,
|
|
||||||
AssemblyName = contract.AssemblyName
|
|
||||||
})
|
|
||||||
.ToList()
|
|
||||||
?? entry.SharedContracts,
|
|
||||||
PackageSources = resolvedPackageSources,
|
|
||||||
Md5 = FirstNonEmpty(existingPublication?.Md5, entry.Md5) ?? string.Empty,
|
|
||||||
PublishedAt = existingPublication?.PublishedAt ?? entry.PublishedAt,
|
|
||||||
UpdatedAt = existingPublication?.UpdatedAt ?? entry.UpdatedAt,
|
|
||||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<PluginManifest?> TryLoadPluginManifestAsync(
|
|
||||||
string owner,
|
|
||||||
string repositoryName,
|
|
||||||
string branch,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "plugin.json");
|
|
||||||
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
|
||||||
if (text is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text));
|
|
||||||
return PluginManifest.Load(stream, candidateUrl);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<AirAppMarketRepositoryTemplate?> TryLoadTemplateAsync(
|
|
||||||
string owner,
|
|
||||||
string repositoryName,
|
|
||||||
string branch,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "airappmarket-entry.template.json");
|
|
||||||
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
|
||||||
if (text is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<AirAppMarketRepositoryTemplate>(text, JsonOptions);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IReadOnlyList<string>> GetBranchCandidatesAsync(
|
|
||||||
string owner,
|
|
||||||
string repositoryName,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var candidates = new List<string>(4);
|
|
||||||
|
|
||||||
if (_defaultBranchCache.TryGetValue(FormatRepositoryKey(owner, repositoryName), out var cachedBranch) &&
|
|
||||||
!string.IsNullOrWhiteSpace(cachedBranch))
|
|
||||||
{
|
|
||||||
candidates.Add(cachedBranch);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var defaultBranch = await TryGetDefaultBranchAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
|
||||||
if (!string.IsNullOrWhiteSpace(defaultBranch))
|
|
||||||
{
|
|
||||||
_defaultBranchCache[FormatRepositoryKey(owner, repositoryName)] = defaultBranch;
|
|
||||||
candidates.Add(defaultBranch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates.Add("main");
|
|
||||||
candidates.Add("master");
|
|
||||||
|
|
||||||
return candidates
|
|
||||||
.Where(branch => !string.IsNullOrWhiteSpace(branch))
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string?> TryGetDefaultBranchAsync(
|
|
||||||
string owner,
|
|
||||||
string repositoryName,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var url = $"https://api.github.com/repos/{owner}/{repositoryName}";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
|
||||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var document = JsonDocument.Parse(responseText);
|
|
||||||
if (document.RootElement.TryGetProperty("default_branch", out var branchNode))
|
|
||||||
{
|
|
||||||
return AirAppMarketIndexDocument.NormalizeValue(branchNode.GetString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fallback to conventional branches.
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string?> TryReadTextAsync(string url, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(url, out var localPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await File.ReadAllTextAsync(localPath, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatRepositoryKey(string owner, string repositoryName)
|
|
||||||
{
|
|
||||||
return $"{owner.Trim()}/{repositoryName.Trim()}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? FirstNonEmpty(params string?[] values)
|
|
||||||
{
|
|
||||||
foreach (var value in values)
|
|
||||||
{
|
|
||||||
var normalized = AirAppMarketIndexDocument.NormalizeValue(value);
|
|
||||||
if (!string.IsNullOrWhiteSpace(normalized))
|
|
||||||
{
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<string> FirstNonEmptyList(params IReadOnlyList<string>?[] lists)
|
|
||||||
{
|
|
||||||
foreach (var list in lists)
|
|
||||||
{
|
|
||||||
if (list is null || list.Count == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = list
|
|
||||||
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
|
||||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
||||||
.Select(value => value!)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
if (normalized.Count > 0)
|
|
||||||
{
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record AirAppMarketRepositoryTemplate(
|
|
||||||
string? MinHostVersion,
|
|
||||||
string? IconUrl,
|
|
||||||
string? ProjectUrl,
|
|
||||||
string? ReadmeUrl,
|
|
||||||
string? HomepageUrl,
|
|
||||||
string? RepositoryUrl,
|
|
||||||
List<string>? Tags,
|
|
||||||
string? ReleaseNotes);
|
|
||||||
}
|
|
||||||
336
LanMountainDesktop/plugins/PluginMarketAssetCacheService.cs
Normal file
336
LanMountainDesktop/plugins/PluginMarketAssetCacheService.cs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.PluginMarket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local disk cache for plugin market assets (README markdown and icon images).
|
||||||
|
/// Cache validity is driven by index refresh: an entry is reused while its source URL and
|
||||||
|
/// plugin version are unchanged, and refreshed only when the market index reports a change.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginMarketAssetCacheService : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions ManifestSerializerOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _cacheDirectory;
|
||||||
|
private readonly string _readmeDirectory;
|
||||||
|
private readonly string _iconsDirectory;
|
||||||
|
private readonly string _manifestPath;
|
||||||
|
private readonly object _manifestGate = new();
|
||||||
|
private AssetCacheManifest _manifest;
|
||||||
|
|
||||||
|
public PluginMarketAssetCacheService(string pluginMarketDataDirectory)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginMarketDataDirectory);
|
||||||
|
|
||||||
|
_cacheDirectory = Path.Combine(pluginMarketDataDirectory, "cache", "assets");
|
||||||
|
_readmeDirectory = Path.Combine(_cacheDirectory, "readme");
|
||||||
|
_iconsDirectory = Path.Combine(_cacheDirectory, "icons");
|
||||||
|
_manifestPath = Path.Combine(_cacheDirectory, "manifest.json");
|
||||||
|
_manifest = LoadManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the cached README path for the plugin when the cache is fresh, or null when it
|
||||||
|
/// must be (re)fetched. Callers then download and store via <see cref="StoreReadmeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string? TryGetReadme(string pluginId, string sourceUrl, string pluginVersion)
|
||||||
|
{
|
||||||
|
return TryGetAsset(pluginId, sourceUrl, pluginVersion, "readme", _readmeDirectory, ".md");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StoreReadmeAsync(
|
||||||
|
string pluginId,
|
||||||
|
string sourceUrl,
|
||||||
|
string pluginVersion,
|
||||||
|
Stream content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_readmeDirectory);
|
||||||
|
var path = Path.Combine(_readmeDirectory, SanitizeFileName(pluginId) + ".md");
|
||||||
|
await WriteAtomicallyAsync(path, content, cancellationToken).ConfigureAwait(false);
|
||||||
|
RecordEntry(pluginId, sourceUrl, pluginVersion, AssetKind.Readme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the cached icon path for the plugin when the cache is fresh, or null when it
|
||||||
|
/// must be (re)fetched.
|
||||||
|
/// </summary>
|
||||||
|
public string? TryGetIcon(string pluginId, string sourceUrl, string pluginVersion)
|
||||||
|
{
|
||||||
|
var extension = InferIconExtension(sourceUrl);
|
||||||
|
return TryGetAsset(pluginId, sourceUrl, pluginVersion, "icon", _iconsDirectory, extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StoreIconAsync(
|
||||||
|
string pluginId,
|
||||||
|
string sourceUrl,
|
||||||
|
string pluginVersion,
|
||||||
|
Stream content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_iconsDirectory);
|
||||||
|
var extension = InferIconExtension(sourceUrl);
|
||||||
|
var path = Path.Combine(_iconsDirectory, SanitizeFileName(pluginId) + extension);
|
||||||
|
await WriteAtomicallyAsync(path, content, cancellationToken).ConfigureAwait(false);
|
||||||
|
RecordEntry(pluginId, sourceUrl, pluginVersion, AssetKind.Icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the cached assets for a plugin (for example after an uninstall).
|
||||||
|
/// </summary>
|
||||||
|
public void Invalidate(string pluginId)
|
||||||
|
{
|
||||||
|
lock (_manifestGate)
|
||||||
|
{
|
||||||
|
if (!_manifest.Entries.Remove(pluginId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TryDelete(Path.Combine(_readmeDirectory, SanitizeFileName(pluginId) + ".md"));
|
||||||
|
foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp" })
|
||||||
|
{
|
||||||
|
TryDelete(Path.Combine(_iconsDirectory, SanitizeFileName(pluginId) + extension));
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears every cached asset and the manifest.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearAll()
|
||||||
|
{
|
||||||
|
lock (_manifestGate)
|
||||||
|
{
|
||||||
|
_manifest = new AssetCacheManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
TryDeleteDirectory(_readmeDirectory);
|
||||||
|
TryDeleteDirectory(_iconsDirectory);
|
||||||
|
SaveManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
SaveManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? TryGetAsset(
|
||||||
|
string pluginId,
|
||||||
|
string sourceUrl,
|
||||||
|
string pluginVersion,
|
||||||
|
string assetLabel,
|
||||||
|
string directory,
|
||||||
|
string extension)
|
||||||
|
{
|
||||||
|
lock (_manifestGate)
|
||||||
|
{
|
||||||
|
if (!_manifest.Entries.TryGetValue(pluginId, out var entry))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedAsset = assetLabel == "readme" ? AssetKind.Readme : AssetKind.Icon;
|
||||||
|
if (entry.AssetKind != expectedAsset)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(entry.SourceUrl, sourceUrl, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!string.Equals(entry.PluginVersion, pluginVersion, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Path.Combine(directory, SanitizeFileName(pluginId) + extension);
|
||||||
|
return File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordEntry(string pluginId, string sourceUrl, string pluginVersion, AssetKind assetKind)
|
||||||
|
{
|
||||||
|
lock (_manifestGate)
|
||||||
|
{
|
||||||
|
_manifest.Entries[pluginId] = new AssetCacheEntry(
|
||||||
|
assetKind,
|
||||||
|
sourceUrl,
|
||||||
|
pluginVersion,
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AssetCacheManifest LoadManifest()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_manifestPath))
|
||||||
|
{
|
||||||
|
return new AssetCacheManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(_manifestPath);
|
||||||
|
return JsonSerializer.Deserialize<AssetCacheManifest>(json, ManifestSerializerOptions)
|
||||||
|
?? new AssetCacheManifest();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new AssetCacheManifest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveManifest()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_cacheDirectory);
|
||||||
|
AssetCacheManifest snapshot;
|
||||||
|
lock (_manifestGate)
|
||||||
|
{
|
||||||
|
snapshot = _manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(snapshot, ManifestSerializerOptions);
|
||||||
|
var tempPath = _manifestPath + ".tmp";
|
||||||
|
File.WriteAllText(tempPath, json);
|
||||||
|
if (File.Exists(_manifestPath))
|
||||||
|
{
|
||||||
|
File.Delete(_manifestPath);
|
||||||
|
}
|
||||||
|
File.Move(tempPath, _manifestPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Cache persistence is best-effort; never fail the asset load because of it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteAtomicallyAsync(string path, Stream content, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tempPath = path + ".tmp";
|
||||||
|
await using (var target = File.Create(tempPath))
|
||||||
|
{
|
||||||
|
await content.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
File.Move(tempPath, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string InferIconExtension(string sourceUrl)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(sourceUrl, UriKind.Absolute);
|
||||||
|
var extension = Path.GetExtension(uri.AbsolutePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(extension))
|
||||||
|
{
|
||||||
|
return extension.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore malformed URLs; default below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string value)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
return string.Create(value.Length, value, (span, src) =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < src.Length; i++)
|
||||||
|
{
|
||||||
|
span[i] = invalid.Contains(src[i]) ? '_' : src[i];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AssetKind
|
||||||
|
{
|
||||||
|
Readme = 0,
|
||||||
|
Icon = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AssetCacheManifest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("entries")]
|
||||||
|
public Dictionary<string, AssetCacheEntry> Entries { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AssetCacheEntry
|
||||||
|
{
|
||||||
|
public AssetCacheEntry()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetCacheEntry(AssetKind assetKind, string sourceUrl, string pluginVersion, DateTimeOffset cachedAt)
|
||||||
|
{
|
||||||
|
AssetKind = assetKind;
|
||||||
|
SourceUrl = sourceUrl;
|
||||||
|
PluginVersion = pluginVersion;
|
||||||
|
CachedAt = cachedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("assetKind")]
|
||||||
|
public AssetKind AssetKind { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("sourceUrl")]
|
||||||
|
public string SourceUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("pluginVersion")]
|
||||||
|
public string PluginVersion { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("cachedAt")]
|
||||||
|
public DateTimeOffset CachedAt { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,27 @@ using System.Net.Http;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.PluginMarket;
|
namespace LanMountainDesktop.Services.PluginMarket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads plugin icons from the local workspace, the on-disk asset cache, or the network,
|
||||||
|
/// writing successful network fetches back into the cache so subsequent loads are offline-friendly.
|
||||||
|
/// </summary>
|
||||||
public sealed class AirAppMarketIconService : IDisposable
|
public sealed class AirAppMarketIconService : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly PluginMarketAssetCacheService? _cache;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
public AirAppMarketIconService()
|
public AirAppMarketIconService()
|
||||||
|
: this(cache: null)
|
||||||
{
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AirAppMarketIconService(PluginMarketAssetCacheService? cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
_httpClient = new HttpClient
|
_httpClient = new HttpClient
|
||||||
{
|
{
|
||||||
Timeout = TimeSpan.FromSeconds(20)
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
@@ -20,28 +32,8 @@ public sealed class AirAppMarketIconService : IDisposable
|
|||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task<Bitmap> LoadAsync(
|
|
||||||
AirAppMarketPluginEntry plugin,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
|
||||||
|
|
||||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.IconUrl, out var localIconPath))
|
|
||||||
{
|
|
||||||
return new Bitmap(localIconPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(plugin.IconUrl, cancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
||||||
using var memory = new MemoryStream();
|
|
||||||
await stream.CopyToAsync(memory, cancellationToken);
|
|
||||||
memory.Position = 0;
|
|
||||||
return new Bitmap(memory);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Bitmap> LoadAsync(
|
public async Task<Bitmap> LoadAsync(
|
||||||
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
|
PluginCatalogItemInfo plugin,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
@@ -51,11 +43,37 @@ public sealed class AirAppMarketIconService : IDisposable
|
|||||||
return new Bitmap(localIconPath);
|
return new Bitmap(localIconPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_cache is not null &&
|
||||||
|
_cache.TryGetIcon(plugin.Id, plugin.IconUrl, plugin.Version) is { } cachedIconPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new Bitmap(cachedIconPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Stale or corrupt cache entry — fall through to a fresh fetch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(plugin.IconUrl, cancellationToken);
|
using var response = await _httpClient.GetAsync(plugin.IconUrl, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
await using var networkStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (_cache is not null)
|
||||||
|
{
|
||||||
|
using var cachedCopy = new MemoryStream();
|
||||||
|
await networkStream.CopyToAsync(cachedCopy, cancellationToken);
|
||||||
|
cachedCopy.Position = 0;
|
||||||
|
using var storeCopy = new MemoryStream(cachedCopy.ToArray());
|
||||||
|
await _cache.StoreIconAsync(plugin.Id, plugin.IconUrl, plugin.Version, storeCopy, cancellationToken);
|
||||||
|
|
||||||
|
cachedCopy.Position = 0;
|
||||||
|
return new Bitmap(cachedCopy);
|
||||||
|
}
|
||||||
|
|
||||||
using var memory = new MemoryStream();
|
using var memory = new MemoryStream();
|
||||||
await stream.CopyToAsync(memory, cancellationToken);
|
await networkStream.CopyToAsync(memory, cancellationToken);
|
||||||
memory.Position = 0;
|
memory.Position = 0;
|
||||||
return new Bitmap(memory);
|
return new Bitmap(memory);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
|||||||
internal sealed class AirAppMarketIndexService : IDisposable
|
internal sealed class AirAppMarketIndexService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly AirAppMarketCacheService _cacheService;
|
private readonly AirAppMarketCacheService _cacheService;
|
||||||
private readonly AirAppMarketMetadataResolverService _metadataResolver;
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
|
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
|
||||||
@@ -23,20 +22,19 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
|||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
_metadataResolver = new AirAppMarketMetadataResolverService(_httpClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
|
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Exception? networkError = null;
|
Exception? networkError = null;
|
||||||
|
|
||||||
|
// The index is self-contained, so there is no per-plugin enrichment step anymore.
|
||||||
if (AirAppMarketDefaults.TryGetWorkspaceIndexPath() is { } localIndexPath)
|
if (AirAppMarketDefaults.TryGetWorkspaceIndexPath() is { } localIndexPath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken).ConfigureAwait(false);
|
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken).ConfigureAwait(false);
|
||||||
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
|
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
|
||||||
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
|
|
||||||
_cacheService.SaveIndexJson(json);
|
_cacheService.SaveIndexJson(json);
|
||||||
return new AirAppMarketLoadResult(
|
return new AirAppMarketLoadResult(
|
||||||
true,
|
true,
|
||||||
@@ -69,7 +67,6 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
|
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
|
||||||
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
|
|
||||||
_cacheService.SaveIndexJson(json);
|
_cacheService.SaveIndexJson(json);
|
||||||
return new AirAppMarketLoadResult(
|
return new AirAppMarketLoadResult(
|
||||||
true,
|
true,
|
||||||
@@ -97,7 +94,6 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
|
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
|
||||||
cachedDocument = await _metadataResolver.EnrichAsync(cachedDocument, cancellationToken).ConfigureAwait(false);
|
|
||||||
return new AirAppMarketLoadResult(
|
return new AirAppMarketLoadResult(
|
||||||
true,
|
true,
|
||||||
cachedDocument,
|
cachedDocument,
|
||||||
@@ -129,7 +125,6 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_metadataResolver.Dispose();
|
|
||||||
_httpClient.Dispose();
|
_httpClient.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,27 @@ using System.IO;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.PluginMarket;
|
namespace LanMountainDesktop.Services.PluginMarket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads plugin README markdown from the local workspace, the on-disk asset cache, or the network,
|
||||||
|
/// writing successful network fetches back into the cache so subsequent loads are offline-friendly.
|
||||||
|
/// </summary>
|
||||||
public sealed class AirAppMarketReadmeService : IDisposable
|
public sealed class AirAppMarketReadmeService : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly PluginMarketAssetCacheService? _cache;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
public AirAppMarketReadmeService()
|
public AirAppMarketReadmeService()
|
||||||
|
: this(cache: null)
|
||||||
{
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AirAppMarketReadmeService(PluginMarketAssetCacheService? cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
_httpClient = new HttpClient
|
_httpClient = new HttpClient
|
||||||
{
|
{
|
||||||
Timeout = TimeSpan.FromSeconds(20)
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
@@ -19,24 +31,8 @@ public sealed class AirAppMarketReadmeService : IDisposable
|
|||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task<string> LoadAsync(
|
|
||||||
AirAppMarketPluginEntry plugin,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
|
||||||
|
|
||||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.ReadmeUrl, out var localReadmePath))
|
|
||||||
{
|
|
||||||
return await File.ReadAllTextAsync(localReadmePath, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(plugin.ReadmeUrl, cancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> LoadAsync(
|
public async Task<string> LoadAsync(
|
||||||
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
|
PluginCatalogItemInfo plugin,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
@@ -46,9 +42,38 @@ public sealed class AirAppMarketReadmeService : IDisposable
|
|||||||
return await File.ReadAllTextAsync(localReadmePath, cancellationToken);
|
return await File.ReadAllTextAsync(localReadmePath, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_cache is not null &&
|
||||||
|
_cache.TryGetReadme(plugin.Id, plugin.ReadmeUrl, plugin.Version) is { } cachedReadmePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(cachedReadmePath, cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Stale cache entry — fall through to a fresh fetch and overwrite it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(plugin.ReadmeUrl, cancellationToken);
|
using var response = await _httpClient.GetAsync(plugin.ReadmeUrl, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
await using var networkStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (_cache is not null)
|
||||||
|
{
|
||||||
|
using var cachedCopy = new MemoryStream();
|
||||||
|
await networkStream.CopyToAsync(cachedCopy, cancellationToken);
|
||||||
|
cachedCopy.Position = 0;
|
||||||
|
using var storeCopy = new MemoryStream(cachedCopy.ToArray());
|
||||||
|
await _cache.StoreReadmeAsync(plugin.Id, plugin.ReadmeUrl, plugin.Version, storeCopy, cancellationToken);
|
||||||
|
|
||||||
|
cachedCopy.Position = 0;
|
||||||
|
using var reader = new StreamReader(cachedCopy);
|
||||||
|
return await reader.ReadToEndAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var directReader = new StreamReader(networkStream);
|
||||||
|
return await directReader.ReadToEndAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -22,14 +22,15 @@ internal sealed class PluginSharedContractManager : IDisposable
|
|||||||
private readonly Dictionary<string, LoadedSharedContract> _loadedContracts =
|
private readonly Dictionary<string, LoadedSharedContract> _loadedContracts =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public PluginSharedContractManager(string cacheDirectory)
|
public PluginSharedContractManager(string dataDirectory)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
|
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||||
|
|
||||||
_contractsDirectory = Path.Combine(
|
// Shared contracts live alongside the rest of the plugin market data so that a single
|
||||||
GetSharedContractRootDirectory(),
|
// storage location (driven by AppDataPathProvider.GetDataRoot() / the OOBE-chosen path)
|
||||||
"SharedContracts");
|
// owns every plugin asset: index cache, downloads, and shared contracts.
|
||||||
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(cacheDirectory));
|
_contractsDirectory = Path.Combine(dataDirectory, "SharedContracts");
|
||||||
|
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
|
||||||
_httpClient = new HttpClient
|
_httpClient = new HttpClient
|
||||||
{
|
{
|
||||||
Timeout = TimeSpan.FromMinutes(2)
|
Timeout = TimeSpan.FromMinutes(2)
|
||||||
@@ -255,11 +256,6 @@ internal sealed class PluginSharedContractManager : IDisposable
|
|||||||
reference.AssemblyName);
|
reference.AssemblyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetSharedContractRootDirectory()
|
|
||||||
{
|
|
||||||
return AppDataPathProvider.GetDataRoot();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Sanitize(string value)
|
private static string Sanitize(string value)
|
||||||
{
|
{
|
||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
# LanMountainDesktop 安全审计报告
|
|
||||||
|
|
||||||
**审计日期**: 2026-05-31
|
|
||||||
**审计范围**: LanMountainDesktop 主仓库
|
|
||||||
**审计方法**: 静态代码分析 + 架构审查
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 执行摘要
|
|
||||||
|
|
||||||
本次安全审计系统性地检查了 LanMountainDesktop 代码库的高风险攻击面,包括认证与访问控制、注入向量、外部交互和敏感数据处理。
|
|
||||||
|
|
||||||
**结论**: **未发现中等或更高严重度的已确认漏洞。**
|
|
||||||
|
|
||||||
代码库展示了多项积极的安全设计:
|
|
||||||
- 更新包使用 RSA 签名验证
|
|
||||||
- 使用路径遍历防护机制
|
|
||||||
- SHA-256/SHA-512 哈希校验
|
|
||||||
- 插件沙箱隔离 (AssemblyLoadContext)
|
|
||||||
- 命令行参数解析验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 审计范围与方法
|
|
||||||
|
|
||||||
### 审计的攻击面分组
|
|
||||||
|
|
||||||
| 分组 | 审计内容 |
|
|
||||||
|------|---------|
|
|
||||||
| **认证与访问控制** | OOBE 流程、隐私协议、会话管理、权限校验 |
|
|
||||||
| **注入向量** | SQL 查询、Shell 命令拼接、模板渲染、文件路径操作 |
|
|
||||||
| **外部交互** | Webhook 处理器、出站网络请求、第三方 API 集成 |
|
|
||||||
| **敏感数据处理** | 密钥/凭证、日志记录、加密实践 |
|
|
||||||
|
|
||||||
### 审计的代码模块
|
|
||||||
|
|
||||||
- `LanMountainDesktop/` - 主宿主应用
|
|
||||||
- `LanMountainDesktop.Launcher/` - 启动器 (OOBE、更新、插件管理)
|
|
||||||
- `LanMountainDesktop.PluginSdk/` - 插件 SDK
|
|
||||||
- `LanMountainDesktop.Services/` - 服务层
|
|
||||||
- `LanMountainDesktop.plugins/` - 插件运行时
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 详细审计结果
|
|
||||||
|
|
||||||
### 1. 认证与访问控制
|
|
||||||
|
|
||||||
#### 审计项目
|
|
||||||
|
|
||||||
| 项目 | 位置 | 状态 |
|
|
||||||
|------|------|------|
|
|
||||||
| OOBE 状态持久化 | `LanMountainDesktop.Launcher/Oobe/OobeStateService.cs` | ✅ 安全 |
|
|
||||||
| 隐私协议管理 | `LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs` | ✅ 安全 |
|
|
||||||
| 命令行参数解析 | `LanMountainDesktop.Launcher/CommandContext.cs` | ✅ 安全 |
|
|
||||||
| 提升权限控制 | `LanMountainDesktop.Launcher/` | ✅ 安全 |
|
|
||||||
|
|
||||||
#### 分析结果
|
|
||||||
|
|
||||||
**OOBE 状态持久化** 采用原子写入模式 (先写临时文件再 Move),避免状态损坏。使用 JSON Schema 版本控制便于迁移。`LaunchSource` 参数白名单验证防止非法来源。
|
|
||||||
|
|
||||||
**命令行参数解析** 对 `Options` 字典使用 `StringComparer.OrdinalIgnoreCase`,解析逻辑清晰,不存在注入风险。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 注入向量
|
|
||||||
|
|
||||||
#### 审计项目
|
|
||||||
|
|
||||||
| 项目 | 位置 | 风险评估 |
|
|
||||||
|------|------|---------|
|
|
||||||
| 路径遍历防护 | `Services/Update/UpdatePathGuard.cs` | ✅ 有防护 |
|
|
||||||
| 文件操作 | `PlondsUpdateApplier.cs` | ✅ 安全 |
|
|
||||||
| 插件加载 | `plugins/PluginLoader.cs` | ✅ 隔离 |
|
|
||||||
| Shell 执行 | 各组件 Process.Start | ⚠️ 需注意 |
|
|
||||||
|
|
||||||
#### 关键代码审查
|
|
||||||
|
|
||||||
**路径遍历防护** ([UpdatePathGuard.cs:L11-18](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdatePathGuard.cs#L11-L18)):
|
|
||||||
```csharp
|
|
||||||
public static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
|
||||||
{
|
|
||||||
var fullTarget = Path.GetFullPath(targetPath);
|
|
||||||
var fullRoot = Path.GetFullPath(rootPath);
|
|
||||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
✅ 使用 `OrdinalIgnoreCase` 防止大小写绕过,使用 `GetFullPath` 规范化路径。
|
|
||||||
|
|
||||||
**插件包路径清理** ([PluginMarketInstallService.cs:L349-353](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketInstallService.cs#L349-L353)):
|
|
||||||
```csharp
|
|
||||||
private static string SanitizeFileName(string value)
|
|
||||||
{
|
|
||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
|
||||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
✅ 插件包文件名经过清理,避免路径注入。
|
|
||||||
|
|
||||||
**Shell 执行上下文**:
|
|
||||||
|
|
||||||
检查了 30+ 处 `Process.Start` 调用:
|
|
||||||
- 更新安装使用 `UseShellExecute = true` 仅用于 `runas` 提权执行安装程序
|
|
||||||
- 组件快捷方式执行 (`ShortcutWidget.axaml.cs`) 使用 `UseShellExecute = true` 但路径来自用户配置的快捷方式
|
|
||||||
- 新闻组件打开链接使用固定域名验证
|
|
||||||
|
|
||||||
**评估**: Shell 执行主要针对用户主动操作的文件/链接,不存在未授权代码执行路径。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 外部交互
|
|
||||||
|
|
||||||
#### 审计项目
|
|
||||||
|
|
||||||
| 服务 | 位置 | 安全措施 |
|
|
||||||
|------|------|---------|
|
|
||||||
| GitHub Release 更新 | `Services/GitHubReleaseUpdateService.cs` | HTTPS + Hash 验证 |
|
|
||||||
| PLONDS 更新 | `Services/PlondsStaticUpdateService.cs` | RSA 签名验证 |
|
|
||||||
| 插件市场 | `plugins/PluginMarketInstallService.cs` | SHA-256 校验 |
|
|
||||||
| 天气服务 | `Services/XiaomiWeatherService.cs` | API Key 管理 |
|
|
||||||
| 遥测服务 | `Services/TelemetryServices.cs` | 用户同意控制 |
|
|
||||||
|
|
||||||
#### 关键安全机制
|
|
||||||
|
|
||||||
**更新包签名验证** ([UpdateSignatureVerifier.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs)):
|
|
||||||
```csharp
|
|
||||||
using var rsa = RSA.Create(384);
|
|
||||||
rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath)); // 内置公钥
|
|
||||||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
|
||||||
return rsa.VerifyData(
|
|
||||||
sha256.ComputeHash(File.OpenRead(fileMapPath)),
|
|
||||||
Convert.FromBase64String(signatureBase64),
|
|
||||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
```
|
|
||||||
✅ 使用 PKCS#1 签名验证更新清单。
|
|
||||||
|
|
||||||
**插件包完整性验证** ([PluginMarketInstallService.cs:L240-261](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketInstallService.cs#L240-L261)):
|
|
||||||
```csharp
|
|
||||||
// 大小校验
|
|
||||||
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
|
|
||||||
return verification failed;
|
|
||||||
|
|
||||||
// SHA-256 校验
|
|
||||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return verification failed;
|
|
||||||
```
|
|
||||||
✅ 下载的插件包经过大小和哈希双重校验。
|
|
||||||
|
|
||||||
**HTTP 客户端配置**:
|
|
||||||
- 所有 HTTP 请求设置 `User-Agent` 头
|
|
||||||
- 超时配置合理 (20-30 秒)
|
|
||||||
- 响应状态码检查完善
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 敏感数据处理
|
|
||||||
|
|
||||||
#### 审计项目
|
|
||||||
|
|
||||||
| 项目 | 状态 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| API 密钥硬编码 | ⚠️ 需关注 | 小米天气 API 密钥 |
|
|
||||||
| 日志记录 | ✅ 安全 | 未发现敏感信息日志 |
|
|
||||||
| 遥测数据 | ✅ 安全 | 受用户同意控制 |
|
|
||||||
| 设置存储 | ✅ 安全 | 本地 AppData 目录 |
|
|
||||||
|
|
||||||
#### API 密钥问题说明
|
|
||||||
|
|
||||||
在 [XiaomiWeatherService.cs:L13-36](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L13-L36) 中发现:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record XiaomiWeatherApiOptions
|
|
||||||
{
|
|
||||||
public string AppKey { get; init; } = "weather20151024";
|
|
||||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**风险评估**: 低
|
|
||||||
|
|
||||||
- 这些是天气数据 API 的凭证,用于访问公开天气数据
|
|
||||||
- 根据小米天气 API 设计,这些密钥通常为公开密钥,供免费/开源应用使用
|
|
||||||
- API 返回的是天气数据,不涉及用户敏感信息
|
|
||||||
- 即使密钥泄露,影响范围限于天气数据获取
|
|
||||||
|
|
||||||
**建议**: 如需增强安全,可考虑:
|
|
||||||
1. 将密钥移至配置系统
|
|
||||||
2. 实现密钥轮换机制
|
|
||||||
3. 使用服务端代理访问天气 API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 架构安全评估
|
|
||||||
|
|
||||||
#### 插件运行时隔离
|
|
||||||
|
|
||||||
**当前设计**:
|
|
||||||
- 插件使用 `AssemblyLoadContext` 进行程序集隔离
|
|
||||||
- 共享类型白名单机制
|
|
||||||
- 插件运行在同一进程中
|
|
||||||
|
|
||||||
**评估**: 中等风险 (架构设计)
|
|
||||||
|
|
||||||
当前插件运行时属于进程内加载,这是已知的架构权衡。代码库文档 (`.trae/specs/plugin-process-isolation/`) 已规划未来版本的进程隔离方案:
|
|
||||||
|
|
||||||
- Phase 1: 后台逻辑移至独立工作进程
|
|
||||||
- Phase 2: 插件 UI 渲染进程外
|
|
||||||
|
|
||||||
**当前缓解措施**:
|
|
||||||
- 插件 API 版本兼容性检查
|
|
||||||
- 插件清单验证
|
|
||||||
- 签名验证 (市场下载的插件)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安全最佳实践符合性
|
|
||||||
|
|
||||||
| 最佳实践 | 符合性 | 说明 |
|
|
||||||
|---------|-------|------|
|
|
||||||
| 输入验证 | ✅ | 参数解析、路径规范化、Schema 验证 |
|
|
||||||
| 输出编码 | ✅ | JSON 序列化使用 System.Text.Json |
|
|
||||||
| 加密标准 | ✅ | SHA-256/SHA-512, RSA 384-bit |
|
|
||||||
| 安全默认值 | ✅ | UseShellExecute=false 优先 |
|
|
||||||
| 错误处理 | ✅ | 异常被捕获并记录,不泄露敏感信息 |
|
|
||||||
| 更新签名 | ✅ | RSA 签名验证更新包 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
### 审计状态: 通过
|
|
||||||
|
|
||||||
经过系统性审计,**未发现中等或更高严重度的已确认漏洞**。
|
|
||||||
|
|
||||||
### 代码质量评价
|
|
||||||
|
|
||||||
代码库展现了良好的安全意识:
|
|
||||||
- 关键操作 (更新安装、插件加载) 有多层安全验证
|
|
||||||
- 路径操作使用标准化防护机制
|
|
||||||
- 外部数据源完整性校验完善
|
|
||||||
- 遥测和隐私设置尊重用户选择
|
|
||||||
|
|
||||||
### 建议改进 (非紧急)
|
|
||||||
|
|
||||||
1. **API 密钥管理**: 将天气 API 密钥移至配置系统或使用服务端代理
|
|
||||||
2. **插件进程隔离**: 加速推进 `plugin-process-isolation` 规划
|
|
||||||
3. **安全清单**: 建立安全相关的持续集成检查
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。*
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
# LanMountainDesktop 安全审计报告
|
|
||||||
|
|
||||||
**项目**: LanMountainDesktop
|
|
||||||
**审计日期**: 2026-05-24
|
|
||||||
**审计范围**: 代码库安全性系统性评估
|
|
||||||
**审计方法**: 静态代码分析 + 架构审查 + 攻击面映射
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 执行摘要
|
|
||||||
|
|
||||||
本次审计对 LanMountainDesktop 代码库进行了全面的安全评估,系统性地检查了认证与访问控制、注入向量、外部交互以及敏感数据处理等高风险攻击面。
|
|
||||||
|
|
||||||
**审计结论**: 发现 **5 个已确认的中等及以上严重度漏洞**,均具有可论证的利用路径。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 已确认漏洞
|
|
||||||
|
|
||||||
### 漏洞 #1 - PostHog API Key 硬编码(高严重度)
|
|
||||||
|
|
||||||
| 属性 | 详情 |
|
|
||||||
|------|------|
|
|
||||||
| **严重度** | 高 |
|
|
||||||
| **CWE** | CWE-798 - 使用硬编码凭证 |
|
|
||||||
| **位置** | [PostHogUsageTelemetryService.cs:14](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs#L14) |
|
|
||||||
| **攻击者画像** | 源代码仓库的任何访问者(通过代码泄露、供应链攻击或Git历史) |
|
|
||||||
| **可控输入** | 无(静态硬编码密钥) |
|
|
||||||
|
|
||||||
**代码路径**:
|
|
||||||
```csharp
|
|
||||||
// PostHogUsageTelemetryService.cs:14
|
|
||||||
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
|
||||||
```
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- 攻击者可滥用此 API Key 向 PostHog 项目发送伪造遥测数据
|
|
||||||
- 可能导致遥测数据污染,干扰产品分析决策
|
|
||||||
- API Key 暴露在公开仓库中,任何人都能获取并滥用
|
|
||||||
|
|
||||||
**修复建议**:
|
|
||||||
```csharp
|
|
||||||
private static string GetPostHogApiKey()
|
|
||||||
{
|
|
||||||
var key = Environment.GetEnvironmentVariable("POSTHOG_API_KEY");
|
|
||||||
if (string.IsNullOrEmpty(key))
|
|
||||||
throw new InvalidOperationException("PostHog API key not configured.");
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 漏洞 #2 - Sentry DSN 硬编码(高严重度)
|
|
||||||
|
|
||||||
| 属性 | 详情 |
|
|
||||||
|------|------|
|
|
||||||
| **严重度** | 高 |
|
|
||||||
| **CWE** | CWE-798 - 使用硬编码凭证 |
|
|
||||||
| **位置** | [SentryCrashTelemetryService.cs:15](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/SentryCrashTelemetryService.cs#L15) |
|
|
||||||
| **攻击者画像** | 源代码仓库的任何访问者 |
|
|
||||||
| **可控输入** | 无(静态硬编码密钥) |
|
|
||||||
|
|
||||||
**代码路径**:
|
|
||||||
```csharp
|
|
||||||
// SentryCrashTelemetryService.cs:15
|
|
||||||
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
|
||||||
```
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- Sentry DSN 等同于项目的访问凭证
|
|
||||||
- 攻击者可利用此 DSN 向项目发送伪造崩溃报告
|
|
||||||
- 可能导致崩溃数据污染,干扰错误追踪
|
|
||||||
- 如 DSN 配置不当,可导致敏感崩溃信息被发送至攻击者控制的端点
|
|
||||||
|
|
||||||
**修复建议**:
|
|
||||||
```csharp
|
|
||||||
private static string GetSentryDsn()
|
|
||||||
{
|
|
||||||
var dsn = Environment.GetEnvironmentVariable("SENTRY_DSN");
|
|
||||||
if (string.IsNullOrEmpty(dsn))
|
|
||||||
throw new InvalidOperationException("Sentry DSN not configured.");
|
|
||||||
return dsn;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 漏洞 #3 - 小米天气 API 签名密钥硬编码(高严重度)
|
|
||||||
|
|
||||||
| 属性 | 详情 |
|
|
||||||
|------|------|
|
|
||||||
| **严重度** | 高 |
|
|
||||||
| **CWE** | CWE-798 - 使用硬编码凭证 |
|
|
||||||
| **位置** | [XiaomiWeatherService.cs:25](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L25) |
|
|
||||||
| **攻击者画像** | 源代码仓库的任何访问者 |
|
|
||||||
| **可控输入** | 无(静态硬编码密钥) |
|
|
||||||
|
|
||||||
**代码路径**:
|
|
||||||
```csharp
|
|
||||||
// XiaomiWeatherService.cs:25
|
|
||||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
|
||||||
```
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- API 签名凭证暴露在公开仓库
|
|
||||||
- 攻击者可能利用此凭证访问天气服务 API
|
|
||||||
- 可能导致 API 配额滥用或服务成本增加
|
|
||||||
- 如密钥具有更高权限,可能导致数据泄露
|
|
||||||
|
|
||||||
**修复建议**:
|
|
||||||
```csharp
|
|
||||||
public string Sign { get; init; } = Environment.GetEnvironmentVariable("XIAOMI_WEATHER_SIGN") ?? "";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 漏洞 #4 - Sentry PII 收集配置(中等严重度)
|
|
||||||
|
|
||||||
| 属性 | 详情 |
|
|
||||||
|------|------|
|
|
||||||
| **严重度** | 中等 |
|
|
||||||
| **CWE** | CWE-359 - 个人身份信息(PII)意外暴露 |
|
|
||||||
| **位置** | [SentryCrashTelemetryService.cs:212](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/SentryCrashTelemetryService.cs#L212) |
|
|
||||||
| **攻击者画像** | Sentry 后端管理员、内部威胁或数据泄露事件 |
|
|
||||||
| **可控输入** | 用户环境的机器名、用户名、IP地址等系统信息 |
|
|
||||||
|
|
||||||
**代码路径**:
|
|
||||||
```csharp
|
|
||||||
// SentryCrashTelemetryService.cs:212
|
|
||||||
options.SendDefaultPii = true;
|
|
||||||
```
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- `SendDefaultPii = true` 配置会收集和上报用户 IP 地址
|
|
||||||
- 可能违反隐私法规(如 GDPR、中国个人信息保护法)要求
|
|
||||||
- 在崩溃报告中可能暴露用户敏感信息
|
|
||||||
- 用户未明确同意即被收集 PII
|
|
||||||
|
|
||||||
**修复建议**:
|
|
||||||
```csharp
|
|
||||||
// 根据用户同意状态动态设置
|
|
||||||
options.SendDefaultPii = TelemetryEnvironmentInfo.IsTelemetryPiiAllowed();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 漏洞 #5 - SSL 证书验证被禁用(中等严重度)
|
|
||||||
|
|
||||||
| 属性 | 详情 |
|
|
||||||
|------|------|
|
|
||||||
| **严重度** | 中等 |
|
|
||||||
| **CWE** | CWE-295 - 证书验证不正确 |
|
|
||||||
| **位置** | [RecommendationDataService.cs:105](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/RecommendationDataService.cs#L105) |
|
|
||||||
| **攻击者画像** | 网络中间人攻击者(在同一网络环境的攻击者) |
|
|
||||||
| **可控输入** | 用户网络流量 |
|
|
||||||
| **利用路径** | 用户发起API请求 → 攻击者拦截流量 → 伪造响应 |
|
|
||||||
|
|
||||||
**代码路径**:
|
|
||||||
```csharp
|
|
||||||
// RecommendationDataService.cs:100-106
|
|
||||||
var handler = new HttpClientHandler
|
|
||||||
{
|
|
||||||
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
|
|
||||||
System.Security.Authentication.SslProtocols.Tls13,
|
|
||||||
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- 禁用了服务器证书验证,使应用程序容易受到中间人(MITM)攻击
|
|
||||||
- 攻击者可以拦截和篡改 API 响应数据
|
|
||||||
- 可能导致注入恶意内容或数据操纵
|
|
||||||
- 即使使用 TLS 1.2/1.3,证书验证被禁用仍然不安全
|
|
||||||
|
|
||||||
**修复建议**:
|
|
||||||
```csharp
|
|
||||||
var handler = new HttpClientHandler
|
|
||||||
{
|
|
||||||
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
|
|
||||||
System.Security.Authentication.SslProtocols.Tls13,
|
|
||||||
// 删除 ServerCertificateCustomValidationCallback 或实现正确的验证
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 未发现漏洞的区域
|
|
||||||
|
|
||||||
经过系统性审计,以下区域未发现中等及以上严重度的已确认漏洞:
|
|
||||||
|
|
||||||
### 认证与访问控制
|
|
||||||
- 单实例服务实现正确(使用互斥体)
|
|
||||||
- IPC 通信使用命名管道,无明显认证绕过风险
|
|
||||||
- 插件隔离使用独立进程边界
|
|
||||||
- 插件加载使用 AppDomain/AssemblyLoadContext 隔离
|
|
||||||
|
|
||||||
### 注入向量
|
|
||||||
- SQLite 使用参数化查询,无 SQL 注入风险 ([ComponentDomainStorage.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs))
|
|
||||||
- JSON 反序列化使用强类型上下文 (`JsonSerializerContext`),无反序列化漏洞
|
|
||||||
- 文件路径操作使用 `Path.Combine` 和 `Path.GetInvalidFileNameChars()` 过滤
|
|
||||||
- 未发现命令执行注入(Process.Start 使用固定参数)
|
|
||||||
|
|
||||||
### 外部交互
|
|
||||||
- HTTP 请求使用 `HttpClient` 和超时配置
|
|
||||||
- Webhook/回调 URL 使用 `Uri.EscapeDataString` 编码
|
|
||||||
- 下载服务验证目标路径,无路径遍历风险
|
|
||||||
- URL 参数正确使用编码函数
|
|
||||||
|
|
||||||
### 敏感数据处理
|
|
||||||
- 数据库本地存储,使用 WAL 模式
|
|
||||||
- 设置数据通过 JSON 序列化存储在用户目录
|
|
||||||
- 日志文件路径正确隔离在应用数据目录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 架构安全评估
|
|
||||||
|
|
||||||
| 组件 | 安全评级 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 插件系统 | 良好 | 使用独立进程隔离 |
|
|
||||||
| IPC 通信 | 良好 | 命名管道通信,进程边界隔离 |
|
|
||||||
| 更新系统 | 良好 | 支持签名验证 |
|
|
||||||
| 遥测系统 | **需改进** | 存在硬编码凭证和 PII 配置问题 |
|
|
||||||
| 数据存储 | 良好 | 使用标准加密实践 |
|
|
||||||
| 网络通信 | **需改进** | 存在证书验证绕过问题 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 修复优先级
|
|
||||||
|
|
||||||
| 优先级 | 漏洞 | 严重度 | 预计工作量 |
|
|
||||||
|--------|------|--------|------------|
|
|
||||||
| P0 - 紧急 | #1 PostHog API Key | 高 | 低 |
|
|
||||||
| P0 - 紧急 | #2 Sentry DSN | 高 | 低 |
|
|
||||||
| P0 - 紧急 | #3 Xiaomi Weather Sign | 高 | 低 |
|
|
||||||
| P1 - 高 | #4 SendDefaultPii | 中 | 低 |
|
|
||||||
| P1 - 高 | #5 SSL 证书验证禁用 | 中 | 中 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 建议的安全改进
|
|
||||||
|
|
||||||
1. **实施密钥管理**: 使用环境变量或密钥管理服务存储所有 API 凭证
|
|
||||||
2. **添加密钥扫描**: 在 CI/CD 流程中集成 secrets scanning(如 GitGuardian、trufflehog)
|
|
||||||
3. **隐私合规审查**: 确认遥测数据收集符合当地隐私法规要求
|
|
||||||
4. **证书验证修复**: 移除禁用的证书验证,确保 HTTPS 通信安全
|
|
||||||
5. **代码审计**: 建议进行定期安全审计
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*报告生成工具: 自动安全审计系统*
|
|
||||||
*审计方法: 静态代码分析 + 架构审查 + 攻击面映射*
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
# LanMountainDesktop 安全审计报告
|
|
||||||
|
|
||||||
**审计日期**: 2026-06-01
|
|
||||||
**审计范围**: LanMountainDesktop 主仓库
|
|
||||||
**审计方法**: 静态代码分析 + 架构审查 + 威胁建模
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 执行摘要
|
|
||||||
|
|
||||||
本次安全审计系统性地检查了 LanMountainDesktop 代码库的高风险攻击面,包括认证与访问控制、注入向量、外部交互和敏感数据处理。
|
|
||||||
|
|
||||||
**审计结论**: **未发现中等或更高严重度的已确认漏洞。**
|
|
||||||
|
|
||||||
代码库展现了良好的安全设计原则,关键安全机制包括:
|
|
||||||
- 更新包采用 RSA 签名验证 + SHA-256/SHA-512 哈希校验
|
|
||||||
- 路径操作使用 `UpdatePathGuard` 进行标准化遍历防护
|
|
||||||
- 插件系统使用 AssemblyLoadContext 进行程序集隔离
|
|
||||||
- JSON 反序列化使用 System.Text.Json(默认安全)
|
|
||||||
- 遥测数据发送完全受用户同意控制
|
|
||||||
- Shell 执行针对用户主动操作,URL 打开前经过验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、架构概述与信任边界
|
|
||||||
|
|
||||||
### 1.1 系统组件
|
|
||||||
|
|
||||||
| 组件 | 角色 | 信任级别 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `LanMountainDesktop.Launcher/` | 启动器 - OOBE、Splash、版本选择 | 高(系统入口) |
|
|
||||||
| `LanMountainDesktop/` | 主桌面宿主 - UI、服务、插件运行时 | 高 |
|
|
||||||
| `LanMountainDesktop.AirAppRuntime/` | AirApp 独立容器 | 中 |
|
|
||||||
| 插件系统 | 用户安装的扩展代码 | 低(需沙箱) |
|
|
||||||
|
|
||||||
### 1.2 数据流边界
|
|
||||||
|
|
||||||
```
|
|
||||||
用户输入 → 新闻组件(RSS) → 解析后显示
|
|
||||||
用户安装插件 → SHA256验证 → AssemblyLoadContext隔离 → 加载执行
|
|
||||||
更新检查 → RSA签名验证 → SHA256校验 → 应用
|
|
||||||
遥测数据 → 用户同意检查 → PostHog SDK → 上报
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、详细审计结果
|
|
||||||
|
|
||||||
### 2.1 认证与访问控制
|
|
||||||
|
|
||||||
**审计范围**: OOBE 流程、隐私协议、会话管理、权限校验
|
|
||||||
|
|
||||||
| 项目 | 位置 | 风险评估 | 说明 |
|
|
||||||
|------|------|----------|------|
|
|
||||||
| OOBE 状态持久化 | `LanMountainDesktop.Launcher/Oobe/OobeStateService.cs` | ✅ 安全 | 原子写入,JSON Schema 版本控制 |
|
|
||||||
| 隐私协议管理 | `PrivacyAgreementService.cs` | ✅ 安全 | 用户同意机制完善 |
|
|
||||||
| LaunchSource 验证 | `CommandContext.cs` | ✅ 安全 | 参数白名单验证 |
|
|
||||||
| 提权控制 | `ElevatedPluginInstallService.cs` | ✅ 安全 | 仅用于更新安装,需用户确认 |
|
|
||||||
|
|
||||||
**分析结论**: 本应用为本地桌面应用,无传统用户认证机制。隐私设置和遥测同意机制完善,用户可完全控制数据收集。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 注入向量
|
|
||||||
|
|
||||||
#### 2.2.1 路径遍历防护
|
|
||||||
|
|
||||||
**验证代码** ([UpdatePathGuard.cs:L11-18](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdatePathGuard.cs#L11-L18)):
|
|
||||||
```csharp
|
|
||||||
public static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
|
||||||
{
|
|
||||||
var fullTarget = Path.GetFullPath(targetPath);
|
|
||||||
var fullRoot = Path.GetFullPath(rootPath);
|
|
||||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
✅ 使用 `OrdinalIgnoreCase` 防止大小写绕过,使用 `GetFullPath` 规范化路径。
|
|
||||||
|
|
||||||
#### 2.2.2 插件包文件名清理
|
|
||||||
|
|
||||||
**验证代码** ([PluginLoader.cs:L715-726](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginLoader.cs#L715-L726)):
|
|
||||||
```csharp
|
|
||||||
private static string SanitizeDirectoryName(string value)
|
|
||||||
{
|
|
||||||
var invalidCharacters = Path.GetInvalidFileNameChars();
|
|
||||||
var builder = new StringBuilder(value.Length);
|
|
||||||
foreach (var ch in value)
|
|
||||||
{
|
|
||||||
builder.Append(invalidCharacters.Contains(ch) ? '_' : ch);
|
|
||||||
}
|
|
||||||
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
✅ 插件目录名经过清理,避免路径注入。
|
|
||||||
|
|
||||||
#### 2.2.3 Shell 执行上下文
|
|
||||||
|
|
||||||
检查了 40+ 处 `Process.Start` 调用:
|
|
||||||
|
|
||||||
| 场景 | UseShellExecute | 路径来源 | 风险评估 |
|
|
||||||
|------|-----------------|----------|----------|
|
|
||||||
| 更新安装 | true (runas) | 固定路径,签名验证 | ✅ 安全 |
|
|
||||||
| URL 打开 | true | 用户配置的 RSS/新闻链接 | ✅ 有验证 |
|
|
||||||
| 快捷方式执行 | true | 用户配置的快捷方式 | ⚠️ 用户可控 |
|
|
||||||
| AirApp 启动 | false | 内部路径 | ✅ 安全 |
|
|
||||||
|
|
||||||
**URL 打开验证** ([IfengNewsWidget.axaml.cs:L534-554](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs#L534-L554)):
|
|
||||||
```csharp
|
|
||||||
private static string? NormalizeHttpUrl(string? rawUrl)
|
|
||||||
{
|
|
||||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
|
||||||
return null;
|
|
||||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return null;
|
|
||||||
return uri.ToString();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
✅ URL 打开前验证协议必须为 http/https。
|
|
||||||
|
|
||||||
#### 2.2.4 JSON 反序列化
|
|
||||||
|
|
||||||
代码库广泛使用 `System.Text.Json` 进行反序列化:
|
|
||||||
```csharp
|
|
||||||
JsonSerializer.Deserialize<List<string>>(json); // PluginRuntimeService.cs:992
|
|
||||||
JsonSerializer.Deserialize(text, AppJsonContext.Default.Options); // 多个位置
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ System.Text.Json 默认禁用类型元数据,可防止反序列化攻击。
|
|
||||||
|
|
||||||
**审计结论**: 注入向量风险评估为 **低**。路径操作有标准化防护,Shell 执行主要针对用户主动操作且 URL 有验证。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 外部交互
|
|
||||||
|
|
||||||
#### 2.3.1 更新系统安全机制
|
|
||||||
|
|
||||||
**RSA 签名验证** ([UpdateSignatureVerifier.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs)):
|
|
||||||
```csharp
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath));
|
|
||||||
var isValid = rsa.VerifyData(
|
|
||||||
payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
```
|
|
||||||
✅ 使用 PKCS#1 签名验证更新清单。
|
|
||||||
|
|
||||||
**文件哈希验证**:
|
|
||||||
- 下载文件经过 SHA-256 校验
|
|
||||||
- 插件包经过 SHA-256 + 大小双重校验
|
|
||||||
- 支持 SHA-512 增强校验
|
|
||||||
|
|
||||||
#### 2.3.2 插件市场安全
|
|
||||||
|
|
||||||
**插件包完整性验证** ([PluginMarketInstallService.cs:L248-282](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketInstallService.cs#L248-L282)):
|
|
||||||
```csharp
|
|
||||||
// 大小校验
|
|
||||||
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
|
|
||||||
return verification failed;
|
|
||||||
// SHA-256 校验
|
|
||||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return verification failed;
|
|
||||||
```
|
|
||||||
✅ 下载的插件包经过大小和哈希双重校验。
|
|
||||||
|
|
||||||
#### 2.3.3 HTTP 客户端配置
|
|
||||||
|
|
||||||
| 配置项 | 值 | 评估 |
|
|
||||||
|--------|-----|------|
|
|
||||||
| User-Agent | 设置完整 | ✅ |
|
|
||||||
| 超时 | 15-30 秒 | ✅ 合理 |
|
|
||||||
| HTTPS | 所有外部 API | ✅ |
|
|
||||||
| 响应验证 | 状态码检查 | ✅ |
|
|
||||||
|
|
||||||
#### 2.3.4 外部 RSS/新闻数据
|
|
||||||
|
|
||||||
新闻组件从以下来源获取数据:
|
|
||||||
- `imjuya.github.io/juya-ai-daily/rss.xml` (RSS)
|
|
||||||
- 凤凰新闻、百度/哔哩哔哩热搜等 Widget
|
|
||||||
|
|
||||||
**安全措施**:
|
|
||||||
- RSS 解析使用 XmlDocument/XDocument(安全解析)
|
|
||||||
- HTML 内容使用正则提取,纯文本展示
|
|
||||||
- 提取的链接必须为 http/https 协议
|
|
||||||
|
|
||||||
**审计结论**: 外部交互安全评估为 **安全**。所有更新和插件下载都有完整性验证。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.4 敏感数据处理
|
|
||||||
|
|
||||||
#### 2.4.1 API 密钥分析
|
|
||||||
|
|
||||||
| 服务 | 位置 | 评估 |
|
|
||||||
|------|------|------|
|
|
||||||
| Xiaomi Weather API | `XiaomiWeatherService.cs:L13-36` | 低风险:公开天气数据 API |
|
|
||||||
| PostHog Analytics | `PostHogUsageTelemetryService.cs:L14` | 低风险:分析 SDK 公钥 |
|
|
||||||
|
|
||||||
**XiaomiWeatherService** ([XiaomiWeatherService.cs:L13-36](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L13-L36)):
|
|
||||||
```csharp
|
|
||||||
public sealed record XiaomiWeatherApiOptions
|
|
||||||
{
|
|
||||||
public string AppKey { get; init; } = "weather20151024";
|
|
||||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
⚠️ **说明**: 这些是天气数据 API 的公开凭证,用于获取公开天气数据,无用户敏感信息泄露风险。
|
|
||||||
|
|
||||||
#### 2.4.2 遥测服务
|
|
||||||
|
|
||||||
**遥测同意机制** ([PostHogUsageTelemetryService.cs:L71-100](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs#L71-L100)):
|
|
||||||
```csharp
|
|
||||||
public void RefreshEnabledState(bool forceSessionStart = false)
|
|
||||||
{
|
|
||||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
|
||||||
var enabled = snapshot.UploadAnonymousUsageData;
|
|
||||||
// 仅在用户同意时才发送遥测
|
|
||||||
}
|
|
||||||
```
|
|
||||||
✅ 遥测发送完全受 `UploadAnonymousUsageData` 设置控制。
|
|
||||||
|
|
||||||
**遥测收集的数据**:
|
|
||||||
- 安装 ID、应用版本、操作系统信息
|
|
||||||
- 桌面组件交互事件
|
|
||||||
- 设置页面导航事件
|
|
||||||
|
|
||||||
❌ **不包括**: 用户文件内容、个人文档、密码、API 密钥等敏感信息。
|
|
||||||
|
|
||||||
#### 2.4.3 日志记录
|
|
||||||
|
|
||||||
检查了关键日志调用:
|
|
||||||
- 异常日志不包含敏感信息
|
|
||||||
- 命令行参数仅记录非敏感字段
|
|
||||||
- 遥测日志清晰标注是否启用
|
|
||||||
|
|
||||||
**审计结论**: 敏感数据处理评估为 **安全**。遥测受用户同意控制,无敏感信息日志记录。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.5 架构安全评估
|
|
||||||
|
|
||||||
#### 2.5.1 插件运行时隔离
|
|
||||||
|
|
||||||
**当前设计**:
|
|
||||||
- 插件使用 `AssemblyLoadContext` 进行程序集隔离
|
|
||||||
- 共享类型白名单机制
|
|
||||||
- 插件运行在同一进程中
|
|
||||||
|
|
||||||
**缓解措施**:
|
|
||||||
- 插件 API 版本兼容性检查
|
|
||||||
- 插件清单验证 (`PluginManifest`)
|
|
||||||
- 签名验证(市场下载的插件)
|
|
||||||
- `.deps.json` 依赖验证
|
|
||||||
|
|
||||||
**风险说明**: 当前插件运行时属于进程内加载,这是已知的架构权衡。代码库已在 `.trae/specs/plugin-process-isolation/` 规划未来版本采用进程隔离方案。
|
|
||||||
|
|
||||||
#### 2.5.2 IPC 通信安全
|
|
||||||
|
|
||||||
外部 IPC 使用 `dotnetCampus.Ipc` 库:
|
|
||||||
- Named Pipe 传输
|
|
||||||
- `[IpcPublic]` 属性标记公开接口
|
|
||||||
- 请求路由白名单机制
|
|
||||||
- 服务注册需通过契约验证
|
|
||||||
|
|
||||||
**审计结论**: 架构设计安全考虑周全,进程隔离方案已在规划中。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、安全最佳实践符合性
|
|
||||||
|
|
||||||
| 最佳实践 | 符合性 | 说明 |
|
|
||||||
|---------|-------|------|
|
|
||||||
| 输入验证 | ✅ | 参数解析、路径规范化、Schema 验证 |
|
|
||||||
| 输出编码 | ✅ | JSON 序列化使用 System.Text.Json |
|
|
||||||
| 加密标准 | ✅ | SHA-256/SHA-512, RSA 384-bit (PKCS#1) |
|
|
||||||
| 安全默认值 | ✅ | UseShellExecute=false 优先 |
|
|
||||||
| 错误处理 | ✅ | 异常捕获并记录,不泄露敏感信息 |
|
|
||||||
| 更新签名 | ✅ | RSA 签名验证更新包 |
|
|
||||||
| 插件隔离 | ⚠️ | AssemblyLoadContext 隔离,进程隔离规划中 |
|
|
||||||
| 密钥管理 | ⚠️ | 天气/遥测 API 密钥硬编码(低风险) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、非紧急改进建议
|
|
||||||
|
|
||||||
以下建议不属于安全漏洞,仅作为安全加固建议:
|
|
||||||
|
|
||||||
### 4.1 API 密钥管理
|
|
||||||
- 将天气 API 密钥移至配置系统
|
|
||||||
- 考虑使用服务端代理访问天气 API
|
|
||||||
- API 密钥轮换机制
|
|
||||||
|
|
||||||
### 4.2 插件进程隔离
|
|
||||||
- 加速推进 `plugin-process-isolation` 规划
|
|
||||||
- 评估 `dotnetCampus.Ipc` 进程间通信方案
|
|
||||||
|
|
||||||
### 4.3 安全清单
|
|
||||||
- 建立安全相关的持续集成检查
|
|
||||||
- 添加依赖漏洞扫描 (SAST)
|
|
||||||
- 考虑添加 HTTPS 证书固定
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、结论
|
|
||||||
|
|
||||||
### 审计状态: ✅ 通过
|
|
||||||
|
|
||||||
经过系统性审计,**未发现中等或更高严重度的已确认漏洞**。
|
|
||||||
|
|
||||||
### 代码质量评价
|
|
||||||
|
|
||||||
代码库展现了良好的安全意识:
|
|
||||||
|
|
||||||
1. **关键操作多层防护**: 更新安装、插件加载都有完整性校验
|
|
||||||
2. **路径操作标准化**: 使用 `UpdatePathGuard` 防止路径遍历
|
|
||||||
3. **外部数据验证完善**: 插件包 SHA-256 校验、RSA 签名验证
|
|
||||||
4. **用户隐私尊重**: 遥测完全受用户同意控制
|
|
||||||
5. **Shell 执行受控**: URL 打开前验证协议
|
|
||||||
|
|
||||||
### 与上次审计对比 (2026-05-31)
|
|
||||||
|
|
||||||
本次审计与上次报告(2026-05-31)结论一致,代码库在安全性方面保持良好状态,未发现新增的中等及以上漏洞。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。*
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using FluentAvalonia.UI.Controls;
|
|
||||||
|
|
||||||
class Test
|
|
||||||
{
|
|
||||||
static void Main()
|
|
||||||
{
|
|
||||||
var faSymbols = new System.Collections.Generic.HashSet<string>(Enum.GetNames(typeof(FASymbol)));
|
|
||||||
|
|
||||||
// 从错误信息中提取的图标名称
|
|
||||||
var usedIcons = new[]
|
|
||||||
{
|
|
||||||
"Info", "Color", "Apps", "Code", "Home", "Settings",
|
|
||||||
"WeatherMoon", "Search", "Location", "City", "Warning",
|
|
||||||
"ShieldDismiss", "Shield", "Announcements", "Package",
|
|
||||||
"StatusCircle", "Book", "BranchFork", "ArrowSync",
|
|
||||||
"GlobeArrowForward", "Options", "Store", "Layer",
|
|
||||||
"FolderOpen", "Clock", "Maximize"
|
|
||||||
};
|
|
||||||
|
|
||||||
Console.WriteLine("Checking icon availability in FASymbol:");
|
|
||||||
foreach (var icon in usedIcons.Distinct().OrderBy(i => i))
|
|
||||||
{
|
|
||||||
bool exists = faSymbols.Contains(icon);
|
|
||||||
Console.WriteLine($" {icon}: {(exists ? "OK" : "MISSING")}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="FluentAvaloniaUI" />
|
|
||||||
<PackageReference Include="FluentIcons.Avalonia" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
|
|
||||||
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
Commands marked with * may be preceded by a number, _N.
|
|
||||||
Notes in parentheses indicate the behavior if _N is given.
|
|
||||||
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
|
|
||||||
|
|
||||||
h H Display this help.
|
|
||||||
q :q Q :Q ZZ Exit.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMOOVVIINNGG
|
|
||||||
|
|
||||||
e ^E j ^N CR * Forward one line (or _N lines).
|
|
||||||
y ^Y k ^K ^P * Backward one line (or _N lines).
|
|
||||||
ESC-j * Forward one file line (or _N file lines).
|
|
||||||
ESC-k * Backward one file line (or _N file lines).
|
|
||||||
f ^F ^V SPACE * Forward one window (or _N lines).
|
|
||||||
b ^B ESC-v * Backward one window (or _N lines).
|
|
||||||
z * Forward one window (and set window to _N).
|
|
||||||
w * Backward one window (and set window to _N).
|
|
||||||
ESC-SPACE * Forward one window, but don't stop at end-of-file.
|
|
||||||
ESC-b * Backward one window, but don't stop at beginning-of-file.
|
|
||||||
d ^D * Forward one half-window (and set half-window to _N).
|
|
||||||
u ^U * Backward one half-window (and set half-window to _N).
|
|
||||||
ESC-) RightArrow * Right one half screen width (or _N positions).
|
|
||||||
ESC-( LeftArrow * Left one half screen width (or _N positions).
|
|
||||||
ESC-} ^RightArrow Right to last column displayed.
|
|
||||||
ESC-{ ^LeftArrow Left to first column.
|
|
||||||
F Forward forever; like "tail -f".
|
|
||||||
ESC-F Like F but stop when search pattern is found.
|
|
||||||
r ^R ^L Repaint screen.
|
|
||||||
R Repaint screen, discarding buffered input.
|
|
||||||
---------------------------------------------------
|
|
||||||
Default "window" is the screen height.
|
|
||||||
Default "half-window" is half of the screen height.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SSEEAARRCCHHIINNGG
|
|
||||||
|
|
||||||
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
|
|
||||||
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
|
|
||||||
n * Repeat previous search (for _N-th occurrence).
|
|
||||||
N * Repeat previous search in reverse direction.
|
|
||||||
ESC-n * Repeat previous search, spanning files.
|
|
||||||
ESC-N * Repeat previous search, reverse dir. & spanning files.
|
|
||||||
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
|
|
||||||
ESC-u Undo (toggle) search highlighting.
|
|
||||||
ESC-U Clear search highlighting.
|
|
||||||
&_p_a_t_t_e_r_n * Display only matching lines.
|
|
||||||
---------------------------------------------------
|
|
||||||
Search is case-sensitive unless changed with -i or -I.
|
|
||||||
A search pattern may begin with one or more of:
|
|
||||||
^N or ! Search for NON-matching lines.
|
|
||||||
^E or * Search multiple files (pass thru END OF FILE).
|
|
||||||
^F or @ Start search at FIRST file (for /) or last file (for ?).
|
|
||||||
^K Highlight matches, but don't move (KEEP position).
|
|
||||||
^R Don't use REGULAR EXPRESSIONS.
|
|
||||||
^S _n Search for match in _n-th parenthesized subpattern.
|
|
||||||
^W WRAP search if no match found.
|
|
||||||
^L Enter next character literally into pattern.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
JJUUMMPPIINNGG
|
|
||||||
|
|
||||||
g < ESC-< * Go to first line in file (or line _N).
|
|
||||||
G > ESC-> * Go to last line in file (or line _N).
|
|
||||||
p % * Go to beginning of file (or _N percent into file).
|
|
||||||
t * Go to the (_N-th) next tag.
|
|
||||||
T * Go to the (_N-th) previous tag.
|
|
||||||
{ ( [ * Find close bracket } ) ].
|
|
||||||
} ) ] * Find open bracket { ( [.
|
|
||||||
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
|
|
||||||
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
|
|
||||||
---------------------------------------------------
|
|
||||||
Each "find close bracket" command goes forward to the close bracket
|
|
||||||
matching the (_N-th) open bracket in the top line.
|
|
||||||
Each "find open bracket" command goes backward to the open bracket
|
|
||||||
matching the (_N-th) close bracket in the bottom line.
|
|
||||||
|
|
||||||
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
|
|
||||||
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
|
|
||||||
'_<_l_e_t_t_e_r_> Go to a previously marked position.
|
|
||||||
'' Go to the previous position.
|
|
||||||
^X^X Same as '.
|
|
||||||
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
|
|
||||||
---------------------------------------------------
|
|
||||||
A mark is any upper-case or lower-case letter.
|
|
||||||
Certain marks are predefined:
|
|
||||||
^ means beginning of the file
|
|
||||||
$ means end of the file
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CCHHAANNGGIINNGG FFIILLEESS
|
|
||||||
|
|
||||||
:e [_f_i_l_e] Examine a new file.
|
|
||||||
^X^V Same as :e.
|
|
||||||
:n * Examine the (_N-th) next file from the command line.
|
|
||||||
:p * Examine the (_N-th) previous file from the command line.
|
|
||||||
:x * Examine the first (or _N-th) file from the command line.
|
|
||||||
^O^O Open the currently selected OSC8 hyperlink.
|
|
||||||
:d Delete the current file from the command line list.
|
|
||||||
= ^G :f Print current file name.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
|
|
||||||
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
Commands marked with * may be preceded by a number, _N.
|
|
||||||
Notes in parentheses indicate the behavior if _N is given.
|
|
||||||
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
|
|
||||||
|
|
||||||
h H Display this help.
|
|
||||||
q :q Q :Q ZZ Exit.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMOOVVIINNGG
|
|
||||||
|
|
||||||
e ^E j ^N CR * Forward one line (or _N lines).
|
|
||||||
y ^Y k ^K ^P * Backward one line (or _N lines).
|
|
||||||
ESC-j * Forward one file line (or _N file lines).
|
|
||||||
ESC-k * Backward one file line (or _N file lines).
|
|
||||||
f ^F ^V SPACE * Forward one window (or _N lines).
|
|
||||||
b ^B ESC-v * Backward one window (or _N lines).
|
|
||||||
z * Forward one window (and set window to _N).
|
|
||||||
w * Backward one window (and set window to _N).
|
|
||||||
ESC-SPACE * Forward one window, but don't stop at end-of-file.
|
|
||||||
ESC-b * Backward one window, but don't stop at beginning-of-file.
|
|
||||||
d ^D * Forward one half-window (and set half-window to _N).
|
|
||||||
u ^U * Backward one half-window (and set half-window to _N).
|
|
||||||
ESC-) RightArrow * Right one half screen width (or _N positions).
|
|
||||||
ESC-( LeftArrow * Left one half screen width (or _N positions).
|
|
||||||
ESC-} ^RightArrow Right to last column displayed.
|
|
||||||
ESC-{ ^LeftArrow Left to first column.
|
|
||||||
F Forward forever; like "tail -f".
|
|
||||||
ESC-F Like F but stop when search pattern is found.
|
|
||||||
r ^R ^L Repaint screen.
|
|
||||||
R Repaint screen, discarding buffered input.
|
|
||||||
---------------------------------------------------
|
|
||||||
Default "window" is the screen height.
|
|
||||||
Default "half-window" is half of the screen height.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SSEEAARRCCHHIINNGG
|
|
||||||
|
|
||||||
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
|
|
||||||
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
|
|
||||||
n * Repeat previous search (for _N-th occurrence).
|
|
||||||
N * Repeat previous search in reverse direction.
|
|
||||||
ESC-n * Repeat previous search, spanning files.
|
|
||||||
ESC-N * Repeat previous search, reverse dir. & spanning files.
|
|
||||||
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
|
|
||||||
ESC-u Undo (toggle) search highlighting.
|
|
||||||
ESC-U Clear search highlighting.
|
|
||||||
&_p_a_t_t_e_r_n * Display only matching lines.
|
|
||||||
---------------------------------------------------
|
|
||||||
Search is case-sensitive unless changed with -i or -I.
|
|
||||||
A search pattern may begin with one or more of:
|
|
||||||
^N or ! Search for NON-matching lines.
|
|
||||||
^E or * Search multiple files (pass thru END OF FILE).
|
|
||||||
^F or @ Start search at FIRST file (for /) or last file (for ?).
|
|
||||||
^K Highlight matches, but don't move (KEEP position).
|
|
||||||
^R Don't use REGULAR EXPRESSIONS.
|
|
||||||
^S _n Search for match in _n-th parenthesized subpattern.
|
|
||||||
^W WRAP search if no match found.
|
|
||||||
^L Enter next character literally into pattern.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
JJUUMMPPIINNGG
|
|
||||||
|
|
||||||
g < ESC-< * Go to first line in file (or line _N).
|
|
||||||
G > ESC-> * Go to last line in file (or line _N).
|
|
||||||
p % * Go to beginning of file (or _N percent into file).
|
|
||||||
t * Go to the (_N-th) next tag.
|
|
||||||
T * Go to the (_N-th) previous tag.
|
|
||||||
{ ( [ * Find close bracket } ) ].
|
|
||||||
} ) ] * Find open bracket { ( [.
|
|
||||||
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
|
|
||||||
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
|
|
||||||
---------------------------------------------------
|
|
||||||
Each "find close bracket" command goes forward to the close bracket
|
|
||||||
matching the (_N-th) open bracket in the top line.
|
|
||||||
Each "find open bracket" command goes backward to the open bracket
|
|
||||||
matching the (_N-th) close bracket in the bottom line.
|
|
||||||
|
|
||||||
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
|
|
||||||
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
|
|
||||||
'_<_l_e_t_t_e_r_> Go to a previously marked position.
|
|
||||||
'' Go to the previous position.
|
|
||||||
^X^X Same as '.
|
|
||||||
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
|
|
||||||
---------------------------------------------------
|
|
||||||
A mark is any upper-case or lower-case letter.
|
|
||||||
Certain marks are predefined:
|
|
||||||
^ means beginning of the file
|
|
||||||
$ means end of the file
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CCHHAANNGGIINNGG FFIILLEESS
|
|
||||||
|
|
||||||
:e [_f_i_l_e] Examine a new file.
|
|
||||||
^X^V Same as :e.
|
|
||||||
:n * Examine the (_N-th) next file from the command line.
|
|
||||||
:p * Examine the (_N-th) previous file from the command line.
|
|
||||||
:x * Examine the first (or _N-th) file from the command line.
|
|
||||||
^O^O Open the currently selected OSC8 hyperlink.
|
|
||||||
:d Delete the current file from the command line list.
|
|
||||||
= ^G :f Print current file name.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
|
|
||||||
--_<_n_a_m_e_> Toggle a command line option, by name.
|
|
||||||
__<_f_l_a_g_> Display the setting of a command line option.
|
|
||||||
___<_n_a_m_e_> Display the setting of an option, by name.
|
|
||||||
+_c_m_d Execute the less cmd each time a new file is examined.
|
|
||||||
|
|
||||||
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|
|
||||||
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|
|
||||||
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
|
|
||||||
s _f_i_l_e Save input to a file.
|
|
||||||
v Edit the current file with $VISUAL or $EDITOR.
|
|
||||||
V Print version number of "less".
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
OOPPTTIIOONNSS
|
|
||||||
|
|
||||||
Most options may be changed either on the command line,
|
|
||||||
or from within less by using the - or -- command.
|
|
||||||
Options may be given in one of two forms: either a single
|
|
||||||
character preceded by a -, or a name preceded by --.
|
|
||||||
|
|
||||||
-? ........ --help
|
|
||||||
Display help (from command line).
|
|
||||||
-a ........ --search-skip-screen
|
|
||||||
Search skips current screen.
|
|
||||||
-A ........ --SEARCH-SKIP-SCREEN
|
|
||||||
Search starts just after target line.
|
|
||||||
-b [_N] .... --buffers=[_N]
|
|
||||||
Number of buffers.
|
|
||||||
-B ........ --auto-buffers
|
|
||||||
Don't automatically allocate buffers for pipes.
|
|
||||||
-c ........ --clear-screen
|
|
||||||
Repaint by clearing rather than scrolling.
|
|
||||||
-d ........ --dumb
|
|
||||||
Dumb terminal.
|
|
||||||
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
|
|
||||||
Set screen colors.
|
|
||||||
-e -E .... --quit-at-eof --QUIT-AT-EOF
|
|
||||||
Quit at end of file.
|
|
||||||
-f ........ --force
|
|
||||||
Force open non-regular files.
|
|
||||||
-F ........ --quit-if-one-screen
|
|
||||||
Quit if entire file fits on first screen.
|
|
||||||
-g ........ --hilite-search
|
|
||||||
Highlight only last match for searches.
|
|
||||||
-G ........ --HILITE-SEARCH
|
|
||||||
Don't highlight any matches for searches.
|
|
||||||
-h [_N] .... --max-back-scroll=[_N]
|
|
||||||
Backward scroll limit.
|
|
||||||
-i ........ --ignore-case
|
|
||||||
Ignore case in searches that do not contain uppercase.
|
|
||||||
-I ........ --IGNORE-CASE
|
|
||||||
Ignore case in all searches.
|
|
||||||
-j [_N] .... --jump-target=[_N]
|
|
||||||
Screen position of target lines.
|
|
||||||
-J ........ --status-column
|
|
||||||
Display a status column at left edge of screen.
|
|
||||||
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
|
|
||||||
Use a compiled lesskey file.
|
|
||||||
-K ........ --quit-on-intr
|
|
||||||
Exit less in response to ctrl-C.
|
|
||||||
-L ........ --no-lessopen
|
|
||||||
Ignore the LESSOPEN environment variable.
|
|
||||||
-m -M .... --long-prompt --LONG-PROMPT
|
|
||||||
Set prompt style.
|
|
||||||
-n ......... --line-numbers
|
|
||||||
Suppress line numbers in prompts and messages.
|
|
||||||
-N ......... --LINE-NUMBERS
|
|
||||||
Display line number at start of each line.
|
|
||||||
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
|
|
||||||
Copy to log file (standard input only).
|
|
||||||
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
|
|
||||||
Copy to log file (unconditionally overwrite).
|
|
||||||
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
|
|
||||||
Start at pattern (from command line).
|
|
||||||
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
|
|
||||||
Define new prompt.
|
|
||||||
-q -Q .... --quiet --QUIET --silent --SILENT
|
|
||||||
Quiet the terminal bell.
|
|
||||||
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
|
|
||||||
Output "raw" control characters.
|
|
||||||
-s ........ --squeeze-blank-lines
|
|
||||||
Squeeze multiple blank lines.
|
|
||||||
-S ........ --chop-long-lines
|
|
||||||
Chop (truncate) long lines rather than wrapping.
|
|
||||||
-t _t_a_g .... --tag=[_t_a_g]
|
|
||||||
Find a tag.
|
|
||||||
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
|
|
||||||
Use an alternate tags file.
|
|
||||||
-u -U .... --underline-special --UNDERLINE-SPECIAL
|
|
||||||
Change handling of backspaces, tabs and carriage returns.
|
|
||||||
-V ........ --version
|
|
||||||
Display the version number of "less".
|
|
||||||
-w ........ --hilite-unread
|
|
||||||
Highlight first new line after forward-screen.
|
|
||||||
-W ........ --HILITE-UNREAD
|
|
||||||
Highlight first new line after any forward movement.
|
|
||||||
-x [_N[,...]] --tabs=[_N[,...]]
|
|
||||||
Set tab stops.
|
|
||||||
-X ........ --no-init
|
|
||||||
Don't use termcap init/deinit strings.
|
|
||||||
-y [_N] .... --max-forw-scroll=[_N]
|
|
||||||
Forward scroll limit.
|
|
||||||
-z [_N] .... --window=[_N]
|
|
||||||
Set size of window.
|
|
||||||
-" [_c[_c]] . --quotes=[_c[_c]]
|
|
||||||
Set shell quote characters.
|
|
||||||
-~ ........ --tilde
|
|
||||||
Don't display tildes after end of file.
|
|
||||||
-# [_N] .... --shift=[_N]
|
|
||||||
Set horizontal scroll amount (0 = one half screen width).
|
|
||||||
|
|
||||||
--exit-follow-on-close
|
|
||||||
Exit F command on a pipe when writer closes pipe.
|
|
||||||
--file-size
|
|
||||||
Automatically determine the size of the input file.
|
|
||||||
--follow-name
|
|
||||||
The F command changes files if the input file is renamed.
|
|
||||||
--form-feed
|
|
||||||
Stop scrolling when a form feed character is reached.
|
|
||||||
--header=[_L[,_C[,_N]]]
|
|
||||||
Use _L lines (starting at line _N) and _C columns as headers.
|
|
||||||
--incsearch
|
|
||||||
Search file as each pattern character is typed in.
|
|
||||||
--intr=[_C]
|
|
||||||
Use _C instead of ^X to interrupt a read.
|
|
||||||
--lesskey-context=_t_e_x_t
|
|
||||||
Use lesskey source file contents.
|
|
||||||
--lesskey-src=_f_i_l_e
|
|
||||||
Use a lesskey source file.
|
|
||||||
--line-num-width=[_N]
|
|
||||||
Set the width of the -N line number field to _N characters.
|
|
||||||
--match-shift=[_N]
|
|
||||||
Show at least _N characters to the left of a search match.
|
|
||||||
--modelines=[_N]
|
|
||||||
Read _N lines from the input file and look for vim modelines.
|
|
||||||
--mouse
|
|
||||||
Enable mouse input.
|
|
||||||
--no-edit-warn
|
|
||||||
Don't warn when using v command on a file opened via LESSOPEN.
|
|
||||||
--no-keypad
|
|
||||||
Don't send termcap keypad init/deinit strings.
|
|
||||||
--no-histdups
|
|
||||||
Remove duplicates from command history.
|
|
||||||
--no-number-headers
|
|
||||||
Don't give line numbers to header lines.
|
|
||||||
--no-paste
|
|
||||||
Ignore pasted input.
|
|
||||||
--no-search-header-lines
|
|
||||||
Searches do not include header lines.
|
|
||||||
--no-search-header-columns
|
|
||||||
Searches do not include header columns.
|
|
||||||
--no-search-headers
|
|
||||||
Searches do not include header lines or columns.
|
|
||||||
--no-vbell
|
|
||||||
Disable the terminal's visual bell.
|
|
||||||
--redraw-on-quit
|
|
||||||
Redraw final screen when quitting.
|
|
||||||
--rscroll=[_C]
|
|
||||||
Set the character used to mark truncated lines.
|
|
||||||
--save-marks
|
|
||||||
Retain marks across invocations of less.
|
|
||||||
--search-options=[EFKNRW-]
|
|
||||||
Set default options for every search.
|
|
||||||
--show-preproc-errors
|
|
||||||
Display a message if preprocessor exits with an error status.
|
|
||||||
--proc-backspace
|
|
||||||
Process backspaces for bold/underline.
|
|
||||||
--PROC-BACKSPACE
|
|
||||||
Treat backspaces as control characters.
|
|
||||||
--proc-return
|
|
||||||
Delete carriage returns before newline.
|
|
||||||
--PROC-RETURN
|
|
||||||
Treat carriage returns as control characters.
|
|
||||||
--proc-tab
|
|
||||||
Expand tabs to spaces.
|
|
||||||
--PROC-TAB
|
|
||||||
Treat tabs as control characters.
|
|
||||||
--status-col-width=[_N]
|
|
||||||
Set the width of the -J status column to _N characters.
|
|
||||||
--status-line
|
|
||||||
Highlight or color the entire line containing a mark.
|
|
||||||
--use-backslash
|
|
||||||
Subsequent options use backslash as escape char.
|
|
||||||
--use-color
|
|
||||||
Enables colored text.
|
|
||||||
--wheel-lines=[_N]
|
|
||||||
Each click of the mouse wheel moves _N lines.
|
|
||||||
--wordwrap
|
|
||||||
Wrap lines at spaces.
|
|
||||||
|
|
||||||
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
LLIINNEE EEDDIITTIINNGG
|
|
||||||
|
|
||||||
These keys can be used to edit text being entered
|
|
||||||
on the "command line" at the bottom of the screen.
|
|
||||||
|
|
||||||
RightArrow ..................... ESC-l ... Move cursor right one character.
|
|
||||||
LeftArrow ...................... ESC-h ... Move cursor left one character.
|
|
||||||
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
|
|
||||||
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
|
|
||||||
HOME ........................... ESC-0 ... Move cursor to start of line.
|
|
||||||
END ............................ ESC-$ ... Move cursor to end of line.
|
|
||||||
BACKSPACE ................................ Delete char to left of cursor.
|
|
||||||
DELETE ......................... ESC-x ... Delete char under cursor.
|
|
||||||
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
|
|
||||||
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
|
|
||||||
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
|
|
||||||
UpArrow ........................ ESC-k ... Retrieve previous command line.
|
|
||||||
DownArrow ...................... ESC-j ... Retrieve next command line.
|
|
||||||
TAB ...................................... Complete filename & cycle.
|
|
||||||
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
|
|
||||||
ctrl-L ................................... Complete filename, list all.
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
|
|
||||||
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
Commands marked with * may be preceded by a number, _N.
|
|
||||||
Notes in parentheses indicate the behavior if _N is given.
|
|
||||||
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
|
|
||||||
|
|
||||||
h H Display this help.
|
|
||||||
q :q Q :Q ZZ Exit.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMOOVVIINNGG
|
|
||||||
|
|
||||||
e ^E j ^N CR * Forward one line (or _N lines).
|
|
||||||
y ^Y k ^K ^P * Backward one line (or _N lines).
|
|
||||||
ESC-j * Forward one file line (or _N file lines).
|
|
||||||
ESC-k * Backward one file line (or _N file lines).
|
|
||||||
f ^F ^V SPACE * Forward one window (or _N lines).
|
|
||||||
b ^B ESC-v * Backward one window (or _N lines).
|
|
||||||
z * Forward one window (and set window to _N).
|
|
||||||
w * Backward one window (and set window to _N).
|
|
||||||
ESC-SPACE * Forward one window, but don't stop at end-of-file.
|
|
||||||
ESC-b * Backward one window, but don't stop at beginning-of-file.
|
|
||||||
d ^D * Forward one half-window (and set half-window to _N).
|
|
||||||
u ^U * Backward one half-window (and set half-window to _N).
|
|
||||||
ESC-) RightArrow * Right one half screen width (or _N positions).
|
|
||||||
ESC-( LeftArrow * Left one half screen width (or _N positions).
|
|
||||||
ESC-} ^RightArrow Right to last column displayed.
|
|
||||||
ESC-{ ^LeftArrow Left to first column.
|
|
||||||
F Forward forever; like "tail -f".
|
|
||||||
ESC-F Like F but stop when search pattern is found.
|
|
||||||
r ^R ^L Repaint screen.
|
|
||||||
R Repaint screen, discarding buffered input.
|
|
||||||
---------------------------------------------------
|
|
||||||
Default "window" is the screen height.
|
|
||||||
Default "half-window" is half of the screen height.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SSEEAARRCCHHIINNGG
|
|
||||||
|
|
||||||
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
|
|
||||||
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
|
|
||||||
n * Repeat previous search (for _N-th occurrence).
|
|
||||||
N * Repeat previous search in reverse direction.
|
|
||||||
ESC-n * Repeat previous search, spanning files.
|
|
||||||
ESC-N * Repeat previous search, reverse dir. & spanning files.
|
|
||||||
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
|
|
||||||
ESC-u Undo (toggle) search highlighting.
|
|
||||||
ESC-U Clear search highlighting.
|
|
||||||
&_p_a_t_t_e_r_n * Display only matching lines.
|
|
||||||
---------------------------------------------------
|
|
||||||
Search is case-sensitive unless changed with -i or -I.
|
|
||||||
A search pattern may begin with one or more of:
|
|
||||||
^N or ! Search for NON-matching lines.
|
|
||||||
^E or * Search multiple files (pass thru END OF FILE).
|
|
||||||
^F or @ Start search at FIRST file (for /) or last file (for ?).
|
|
||||||
^K Highlight matches, but don't move (KEEP position).
|
|
||||||
^R Don't use REGULAR EXPRESSIONS.
|
|
||||||
^S _n Search for match in _n-th parenthesized subpattern.
|
|
||||||
^W WRAP search if no match found.
|
|
||||||
^L Enter next character literally into pattern.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
JJUUMMPPIINNGG
|
|
||||||
|
|
||||||
g < ESC-< * Go to first line in file (or line _N).
|
|
||||||
G > ESC-> * Go to last line in file (or line _N).
|
|
||||||
p % * Go to beginning of file (or _N percent into file).
|
|
||||||
t * Go to the (_N-th) next tag.
|
|
||||||
T * Go to the (_N-th) previous tag.
|
|
||||||
{ ( [ * Find close bracket } ) ].
|
|
||||||
} ) ] * Find open bracket { ( [.
|
|
||||||
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
|
|
||||||
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
|
|
||||||
---------------------------------------------------
|
|
||||||
Each "find close bracket" command goes forward to the close bracket
|
|
||||||
matching the (_N-th) open bracket in the top line.
|
|
||||||
Each "find open bracket" command goes backward to the open bracket
|
|
||||||
matching the (_N-th) close bracket in the bottom line.
|
|
||||||
|
|
||||||
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
|
|
||||||
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
|
|
||||||
'_<_l_e_t_t_e_r_> Go to a previously marked position.
|
|
||||||
'' Go to the previous position.
|
|
||||||
^X^X Same as '.
|
|
||||||
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
|
|
||||||
---------------------------------------------------
|
|
||||||
A mark is any upper-case or lower-case letter.
|
|
||||||
Certain marks are predefined:
|
|
||||||
^ means beginning of the file
|
|
||||||
$ means end of the file
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CCHHAANNGGIINNGG FFIILLEESS
|
|
||||||
|
|
||||||
:e [_f_i_l_e] Examine a new file.
|
|
||||||
^X^V Same as :e.
|
|
||||||
:n * Examine the (_N-th) next file from the command line.
|
|
||||||
:p * Examine the (_N-th) previous file from the command line.
|
|
||||||
:x * Examine the first (or _N-th) file from the command line.
|
|
||||||
^O^O Open the currently selected OSC8 hyperlink.
|
|
||||||
:d Delete the current file from the command line list.
|
|
||||||
= ^G :f Print current file name.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
|
|
||||||
--_<_n_a_m_e_> Toggle a command line option, by name.
|
|
||||||
__<_f_l_a_g_> Display the setting of a command line option.
|
|
||||||
___<_n_a_m_e_> Display the setting of an option, by name.
|
|
||||||
+_c_m_d Execute the less cmd each time a new file is examined.
|
|
||||||
|
|
||||||
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|
|
||||||
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|
|
||||||
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
|
|
||||||
s _f_i_l_e Save input to a file.
|
|
||||||
v Edit the current file with $VISUAL or $EDITOR.
|
|
||||||
V Print version number of "less".
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
OOPPTTIIOONNSS
|
|
||||||
|
|
||||||
Most options may be changed either on the command line,
|
|
||||||
or from within less by using the - or -- command.
|
|
||||||
Options may be given in one of two forms: either a single
|
|
||||||
character preceded by a -, or a name preceded by --.
|
|
||||||
|
|
||||||
-? ........ --help
|
|
||||||
Display help (from command line).
|
|
||||||
-a ........ --search-skip-screen
|
|
||||||
Search skips current screen.
|
|
||||||
-A ........ --SEARCH-SKIP-SCREEN
|
|
||||||
Search starts just after target line.
|
|
||||||
-b [_N] .... --buffers=[_N]
|
|
||||||
Number of buffers.
|
|
||||||
-B ........ --auto-buffers
|
|
||||||
Don't automatically allocate buffers for pipes.
|
|
||||||
-c ........ --clear-screen
|
|
||||||
Repaint by clearing rather than scrolling.
|
|
||||||
-d ........ --dumb
|
|
||||||
Dumb terminal.
|
|
||||||
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
|
|
||||||
Set screen colors.
|
|
||||||
-e -E .... --quit-at-eof --QUIT-AT-EOF
|
|
||||||
Quit at end of file.
|
|
||||||
-f ........ --force
|
|
||||||
Force open non-regular files.
|
|
||||||
-F ........ --quit-if-one-screen
|
|
||||||
Quit if entire file fits on first screen.
|
|
||||||
-g ........ --hilite-search
|
|
||||||
Highlight only last match for searches.
|
|
||||||
-G ........ --HILITE-SEARCH
|
|
||||||
Don't highlight any matches for searches.
|
|
||||||
-h [_N] .... --max-back-scroll=[_N]
|
|
||||||
Backward scroll limit.
|
|
||||||
-i ........ --ignore-case
|
|
||||||
Ignore case in searches that do not contain uppercase.
|
|
||||||
-I ........ --IGNORE-CASE
|
|
||||||
Ignore case in all searches.
|
|
||||||
-j [_N] .... --jump-target=[_N]
|
|
||||||
Screen position of target lines.
|
|
||||||
-J ........ --status-column
|
|
||||||
Display a status column at left edge of screen.
|
|
||||||
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
|
|
||||||
Use a compiled lesskey file.
|
|
||||||
-K ........ --quit-on-intr
|
|
||||||
Exit less in response to ctrl-C.
|
|
||||||
-L ........ --no-lessopen
|
|
||||||
Ignore the LESSOPEN environment variable.
|
|
||||||
-m -M .... --long-prompt --LONG-PROMPT
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
|
|
||||||
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
Commands marked with * may be preceded by a number, _N.
|
|
||||||
Notes in parentheses indicate the behavior if _N is given.
|
|
||||||
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
|
|
||||||
|
|
||||||
h H Display this help.
|
|
||||||
q :q Q :Q ZZ Exit.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMOOVVIINNGG
|
|
||||||
|
|
||||||
e ^E j ^N CR * Forward one line (or _N lines).
|
|
||||||
y ^Y k ^K ^P * Backward one line (or _N lines).
|
|
||||||
ESC-j * Forward one file line (or _N file lines).
|
|
||||||
ESC-k * Backward one file line (or _N file lines).
|
|
||||||
f ^F ^V SPACE * Forward one window (or _N lines).
|
|
||||||
b ^B ESC-v * Backward one window (or _N lines).
|
|
||||||
z * Forward one window (and set window to _N).
|
|
||||||
w * Backward one window (and set window to _N).
|
|
||||||
ESC-SPACE * Forward one window, but don't stop at end-of-file.
|
|
||||||
ESC-b * Backward one window, but don't stop at beginning-of-file.
|
|
||||||
d ^D * Forward one half-window (and set half-window to _N).
|
|
||||||
u ^U * Backward one half-window (and set half-window to _N).
|
|
||||||
ESC-) RightArrow * Right one half screen width (or _N positions).
|
|
||||||
ESC-( LeftArrow * Left one half screen width (or _N positions).
|
|
||||||
ESC-} ^RightArrow Right to last column displayed.
|
|
||||||
ESC-{ ^LeftArrow Left to first column.
|
|
||||||
F Forward forever; like "tail -f".
|
|
||||||
ESC-F Like F but stop when search pattern is found.
|
|
||||||
r ^R ^L Repaint screen.
|
|
||||||
R Repaint screen, discarding buffered input.
|
|
||||||
---------------------------------------------------
|
|
||||||
Default "window" is the screen height.
|
|
||||||
Default "half-window" is half of the screen height.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SSEEAARRCCHHIINNGG
|
|
||||||
|
|
||||||
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
|
|
||||||
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
|
|
||||||
n * Repeat previous search (for _N-th occurrence).
|
|
||||||
N * Repeat previous search in reverse direction.
|
|
||||||
ESC-n * Repeat previous search, spanning files.
|
|
||||||
ESC-N * Repeat previous search, reverse dir. & spanning files.
|
|
||||||
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
|
|
||||||
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
|
|
||||||
ESC-u Undo (toggle) search highlighting.
|
|
||||||
ESC-U Clear search highlighting.
|
|
||||||
&_p_a_t_t_e_r_n * Display only matching lines.
|
|
||||||
---------------------------------------------------
|
|
||||||
Search is case-sensitive unless changed with -i or -I.
|
|
||||||
A search pattern may begin with one or more of:
|
|
||||||
^N or ! Search for NON-matching lines.
|
|
||||||
^E or * Search multiple files (pass thru END OF FILE).
|
|
||||||
^F or @ Start search at FIRST file (for /) or last file (for ?).
|
|
||||||
^K Highlight matches, but don't move (KEEP position).
|
|
||||||
^R Don't use REGULAR EXPRESSIONS.
|
|
||||||
^S _n Search for match in _n-th parenthesized subpattern.
|
|
||||||
^W WRAP search if no match found.
|
|
||||||
^L Enter next character literally into pattern.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
JJUUMMPPIINNGG
|
|
||||||
|
|
||||||
g < ESC-< * Go to first line in file (or line _N).
|
|
||||||
G > ESC-> * Go to last line in file (or line _N).
|
|
||||||
p % * Go to beginning of file (or _N percent into file).
|
|
||||||
t * Go to the (_N-th) next tag.
|
|
||||||
T * Go to the (_N-th) previous tag.
|
|
||||||
{ ( [ * Find close bracket } ) ].
|
|
||||||
} ) ] * Find open bracket { ( [.
|
|
||||||
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
|
|
||||||
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
|
|
||||||
---------------------------------------------------
|
|
||||||
Each "find close bracket" command goes forward to the close bracket
|
|
||||||
matching the (_N-th) open bracket in the top line.
|
|
||||||
Each "find open bracket" command goes backward to the open bracket
|
|
||||||
matching the (_N-th) close bracket in the bottom line.
|
|
||||||
|
|
||||||
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
|
|
||||||
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
|
|
||||||
'_<_l_e_t_t_e_r_> Go to a previously marked position.
|
|
||||||
'' Go to the previous position.
|
|
||||||
^X^X Same as '.
|
|
||||||
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
|
|
||||||
---------------------------------------------------
|
|
||||||
A mark is any upper-case or lower-case letter.
|
|
||||||
Certain marks are predefined:
|
|
||||||
^ means beginning of the file
|
|
||||||
$ means end of the file
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CCHHAANNGGIINNGG FFIILLEESS
|
|
||||||
|
|
||||||
:e [_f_i_l_e] Examine a new file.
|
|
||||||
^X^V Same as :e.
|
|
||||||
:n * Examine the (_N-th) next file from the command line.
|
|
||||||
:p * Examine the (_N-th) previous file from the command line.
|
|
||||||
:x * Examine the first (or _N-th) file from the command line.
|
|
||||||
^O^O Open the currently selected OSC8 hyperlink.
|
|
||||||
:d Delete the current file from the command line list.
|
|
||||||
= ^G :f Print current file name.
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
|
||||||
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>课程表组件 Mock - 阑山桌面</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-primary: #F7F8FC;
|
|
||||||
--bg-secondary: #ECEFF6;
|
|
||||||
--bg-card: #FFFFFF;
|
|
||||||
--text-primary: #151821;
|
|
||||||
--text-secondary: #667084;
|
|
||||||
--text-muted: #9AA3B2;
|
|
||||||
--border-color: rgba(0,0,0,0.06);
|
|
||||||
--surface-raised: #FFFFFF;
|
|
||||||
--accent: #FF4D5A;
|
|
||||||
--time-color: #848B99;
|
|
||||||
--divider-color: rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--bg-primary: #171A21;
|
|
||||||
--bg-secondary: #0C0E14;
|
|
||||||
--bg-card: rgba(255,255,255,0.04);
|
|
||||||
--text-primary: #F9FBFF;
|
|
||||||
--text-secondary: #848B99;
|
|
||||||
--text-muted: #5A6170;
|
|
||||||
--border-color: rgba(255,255,255,0.08);
|
|
||||||
--surface-raised: #1E2230;
|
|
||||||
--accent: #4FC3F7;
|
|
||||||
--time-color: #6B7280;
|
|
||||||
--divider-color: rgba(255,255,255,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
||||||
background: #1a1a2e;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 32px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button {
|
|
||||||
padding: 8px 20px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:hover { background: rgba(255,255,255,0.2); }
|
|
||||||
.controls button.active { background: rgba(79,195,247,0.3); border-color: #4FC3F7; }
|
|
||||||
|
|
||||||
.mock-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 32px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget {
|
|
||||||
width: 320px;
|
|
||||||
border-radius: 24px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(135deg, var(--bg-primary), var(--bg-secondary));
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget.large {
|
|
||||||
width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-header {
|
|
||||||
padding: 16px 16px 10px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-group .month, .date-group .day {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-group .slash {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-center {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weekday {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.class-count-badge {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(79,195,247,0.12);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .class-count-badge {
|
|
||||||
background: rgba(79,195,247,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-list {
|
|
||||||
padding: 4px 12px 12px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 44px 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: stretch;
|
|
||||||
min-height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding-right: 4px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-start {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--time-color);
|
|
||||||
line-height: 1.2;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-end {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.2;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s;
|
|
||||||
min-height: 52px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-card.current {
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-card .accent-bar {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 0 2px 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-detail {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.3;
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-container {
|
|
||||||
margin-top: 4px;
|
|
||||||
padding-left: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
flex: 1;
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: rgba(255,255,255,0.15);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .progress-bar {
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-text {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 28px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.break-indicator {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 44px 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.break-line {
|
|
||||||
grid-column: 2;
|
|
||||||
border-top: 1px dashed var(--divider-color);
|
|
||||||
margin: 2px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-empty {
|
|
||||||
padding: 40px 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: -16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button id="btnLight" class="active" onclick="setTheme('light')">☀️ 亮色</button>
|
|
||||||
<button id="btnDark" onclick="setTheme('dark')">🌙 暗色</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mock-container">
|
|
||||||
<div>
|
|
||||||
<div class="widget" id="widgetLight">
|
|
||||||
<div class="widget-header">
|
|
||||||
<div class="date-group">
|
|
||||||
<span class="month">7</span>
|
|
||||||
<span class="slash">/</span>
|
|
||||||
<span class="day">24</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-center">
|
|
||||||
<span class="weekday">周一</span>
|
|
||||||
</div>
|
|
||||||
<div class="class-count-badge">6节课</div>
|
|
||||||
</div>
|
|
||||||
<div class="course-list" id="courseListLight"></div>
|
|
||||||
</div>
|
|
||||||
<p class="label">2×4 标准尺寸</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="widget large" id="widgetDark">
|
|
||||||
<div class="widget-header">
|
|
||||||
<div class="date-group">
|
|
||||||
<span class="month">7</span>
|
|
||||||
<span class="slash">/</span>
|
|
||||||
<span class="day">24</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-center">
|
|
||||||
<span class="weekday">周一</span>
|
|
||||||
</div>
|
|
||||||
<div class="class-count-badge">6节课</div>
|
|
||||||
</div>
|
|
||||||
<div class="course-list" id="courseListDark"></div>
|
|
||||||
</div>
|
|
||||||
<p class="label">4×4 大尺寸</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const SUBJECT_COLORS = {
|
|
||||||
'语文': '#5B8FF9',
|
|
||||||
'数学': '#F6903D',
|
|
||||||
'英语': '#5AD8A6',
|
|
||||||
'物理': '#E8684A',
|
|
||||||
'化学': '#9270CA',
|
|
||||||
'生物': '#FF9845',
|
|
||||||
'历史': '#1E9493',
|
|
||||||
'地理': '#FF99C3',
|
|
||||||
'政治': '#7262FD',
|
|
||||||
'体育': '#78D3F8',
|
|
||||||
'音乐': '#F25E7E',
|
|
||||||
'美术': '#C2A1FD',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_COLOR = '#8B95A5';
|
|
||||||
|
|
||||||
function getColor(name) {
|
|
||||||
for (const [key, val] of Object.entries(SUBJECT_COLORS)) {
|
|
||||||
if (name.includes(key)) return val;
|
|
||||||
}
|
|
||||||
let hash = 5381;
|
|
||||||
for (let i = 0; i < name.length; i++) hash = ((hash << 5) + hash) ^ name.charCodeAt(i);
|
|
||||||
const keys = Object.keys(SUBJECT_COLORS);
|
|
||||||
return SUBJECT_COLORS[keys[Math.abs(hash) % keys.length]];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToRgba(hex, alpha) {
|
|
||||||
const r = parseInt(hex.slice(1,3), 16);
|
|
||||||
const g = parseInt(hex.slice(3,5), 16);
|
|
||||||
const b = parseInt(hex.slice(5,7), 16);
|
|
||||||
return `rgba(${r},${g},${b},${alpha})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const courses = [
|
|
||||||
{ name: '语文', start: '08:00', end: '08:45', detail: '王老师 · 教室301', isCurrent: false, progress: 0 },
|
|
||||||
{ name: '数学', start: '08:55', end: '09:40', detail: '李老师 · 教室205', isCurrent: true, progress: 0.62 },
|
|
||||||
{ name: '英语', start: '09:50', end: '10:35', detail: '张老师 · 教室108', isCurrent: false, progress: 0 },
|
|
||||||
{ name: '物理', start: '10:45', end: '11:30', detail: '赵老师 · 实验室2', isCurrent: false, progress: 0 },
|
|
||||||
{ name: '化学', start: '14:00', end: '14:45', detail: '陈老师 · 实验室1', isCurrent: false, progress: 0 },
|
|
||||||
{ name: '生物', start: '14:55', end: '15:40', detail: '刘老师 · 教室303', isCurrent: false, progress: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderCourseList(containerId, isDark) {
|
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
courses.forEach((course, idx) => {
|
|
||||||
const color = getColor(course.name);
|
|
||||||
const bgAlpha = course.isCurrent ? 0.15 : 0.07;
|
|
||||||
const bgColor = hexToRgba(color, bgAlpha);
|
|
||||||
const fgColor = isDark
|
|
||||||
? hexToRgba(color, 1).replace('rgb', 'rgb').replace(')', ',1)') || color
|
|
||||||
: color;
|
|
||||||
|
|
||||||
if (idx > 0) {
|
|
||||||
const breakEl = document.createElement('div');
|
|
||||||
breakEl.className = 'break-indicator';
|
|
||||||
breakEl.innerHTML = `<div></div><div class="break-line"></div>`;
|
|
||||||
container.appendChild(breakEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'course-item';
|
|
||||||
|
|
||||||
const timeCol = document.createElement('div');
|
|
||||||
timeCol.className = 'time-column';
|
|
||||||
timeCol.innerHTML = `
|
|
||||||
<span class="time-start">${course.start}</span>
|
|
||||||
<span class="time-end">${course.end}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'course-card' + (course.isCurrent ? ' current' : '');
|
|
||||||
card.style.background = bgColor;
|
|
||||||
|
|
||||||
let cardHTML = '';
|
|
||||||
if (course.isCurrent) {
|
|
||||||
cardHTML += `<div class="accent-bar" style="background:${color}"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cardHTML += `<div class="course-name" style="color:${fgColor}">${course.name}</div>`;
|
|
||||||
cardHTML += `<div class="course-detail">${course.detail}</div>`;
|
|
||||||
|
|
||||||
if (course.isCurrent) {
|
|
||||||
const pct = Math.round(course.progress * 100);
|
|
||||||
cardHTML += `
|
|
||||||
<div class="progress-container">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill" style="width:${pct}%;background:${color}"></div>
|
|
||||||
</div>
|
|
||||||
<span class="progress-text" style="color:${fgColor}">${pct}%</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
card.innerHTML = cardHTML;
|
|
||||||
item.appendChild(timeCol);
|
|
||||||
item.appendChild(card);
|
|
||||||
container.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTheme(theme) {
|
|
||||||
const btnLight = document.getElementById('btnLight');
|
|
||||||
const btnDark = document.getElementById('btnDark');
|
|
||||||
const widgetLight = document.getElementById('widgetLight');
|
|
||||||
const widgetDark = document.getElementById('widgetDark');
|
|
||||||
|
|
||||||
if (theme === 'dark') {
|
|
||||||
btnDark.classList.add('active');
|
|
||||||
btnLight.classList.remove('active');
|
|
||||||
widgetLight.classList.add('dark');
|
|
||||||
widgetDark.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
btnLight.classList.add('active');
|
|
||||||
btnDark.classList.remove('active');
|
|
||||||
widgetLight.classList.remove('dark');
|
|
||||||
widgetDark.classList.remove('dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCourseList('courseListLight', theme === 'dark');
|
|
||||||
renderCourseList('courseListDark', theme === 'dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCourseList('courseListLight', false);
|
|
||||||
renderCourseList('courseListDark', false);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>天气组件 Mock V2 - 阑山桌面</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
||||||
background: #1a1a2e; color: #fff; padding: 32px 20px; min-height: 100vh;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex; gap: 12px; align-items: center; justify-content: center;
|
|
||||||
margin-bottom: 32px; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.controls button {
|
|
||||||
padding: 8px 18px; border: 1px solid rgba(255,255,255,0.2);
|
|
||||||
border-radius: 8px; background: rgba(255,255,255,0.08);
|
|
||||||
color: #fff; cursor: pointer; font-size: 13px; transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.controls button:hover { background: rgba(255,255,255,0.16); }
|
|
||||||
.controls button.active { background: rgba(79,195,247,0.25); border-color: #4FC3F7; }
|
|
||||||
.section-title { text-align: center; font-size: 16px; font-weight: 600; margin: 24px 0 12px; color: #888; }
|
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; max-width: 1400px; margin: 0 auto; }
|
|
||||||
.widget { border-radius: 24px; overflow: hidden; position: relative; height: 280px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); }
|
|
||||||
.widget-bg { position: absolute; inset: 0; z-index: 0; }
|
|
||||||
.widget-overlay { position: absolute; inset: 0; z-index: 1; }
|
|
||||||
.widget-content { position: relative; z-index: 2; padding: 20px 22px; height: 100%; display: flex; flex-direction: column; justify-content: space-between; }
|
|
||||||
.widget-top { display: flex; justify-content: space-between; align-items: flex-start; }
|
|
||||||
.temp { font-size: 64px; font-weight: 700; line-height: 1; letter-spacing: -2px; }
|
|
||||||
.condition { font-size: 17px; font-weight: 600; opacity: 0.88; margin-top: 2px; }
|
|
||||||
.icon-block { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
|
||||||
.weather-icon { width: 72px; height: 72px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 36px; background: rgba(255,255,255,0.12); backdrop-filter: blur(8px); }
|
|
||||||
.range { font-size: 13px; font-weight: 500; opacity: 0.72; }
|
|
||||||
.widget-bottom { display: flex; justify-content: space-between; align-items: flex-end; }
|
|
||||||
.location { font-size: 14px; font-weight: 600; opacity: 0.78; }
|
|
||||||
.metrics { display: flex; gap: 6px; }
|
|
||||||
.metric-tag { padding: 3px 8px; border-radius: 8px; font-size: 11px; font-weight: 600; background: rgba(255,255,255,0.12); backdrop-filter: blur(4px); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button id="btnDay" class="active" onclick="setTime('day')">☀️ 白天</button>
|
|
||||||
<button id="btnNight" onclick="setTime('night')">🌙 夜晚</button>
|
|
||||||
<span style="color:#666;margin:0 8px">|</span>
|
|
||||||
<button id="btnClear" class="active" onclick="setWeather('clear')">☀️ 晴</button>
|
|
||||||
<button id="btnRain" onclick="setWeather('rain')">🌧️ 雨</button>
|
|
||||||
<button id="btnCloudy" onclick="setWeather('cloudy')">☁️ 多云</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-title">Google Weather — 纯渐变,无装饰</div>
|
|
||||||
<div class="grid" id="gridGoogle"></div>
|
|
||||||
|
|
||||||
<div class="section-title">Geometric — 径向渐变光晕 + 弧线段</div>
|
|
||||||
<div class="grid" id="gridGeometric"></div>
|
|
||||||
|
|
||||||
<div class="section-title">Breezy — 径向渐变光晕 + 波浪线 + 弧线段</div>
|
|
||||||
<div class="grid" id="gridBreezy"></div>
|
|
||||||
|
|
||||||
<div class="section-title">Lemon — 径向渐变光晕 + 天气场景装饰</div>
|
|
||||||
<div class="grid" id="gridLemon"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function hexToRgb(hex) {
|
|
||||||
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
|
|
||||||
return {r,g,b};
|
|
||||||
}
|
|
||||||
function rgba(hex, a) { const c = hexToRgb(hex); return `rgba(${c.r},${c.g},${c.b},${a})`; }
|
|
||||||
|
|
||||||
const P = {
|
|
||||||
google: {
|
|
||||||
clear: {
|
|
||||||
day: { top:'#4FC3F7', bottom:'#B3E5FC', text:'#0D47A1', textSec:'#1565C0', overlay:'rgba(255,255,255,0.08)', shapes:[] },
|
|
||||||
night: { top:'#0D47A1', bottom:'#1A237E', text:'#E8EAF6', textSec:'#9FA8DA', overlay:'rgba(0,0,0,0.12)', shapes:[] },
|
|
||||||
},
|
|
||||||
rain: { day: { top:'#78909C', bottom:'#B0BEC5', text:'#263238', textSec:'#37474F', overlay:'rgba(255,255,255,0.06)', shapes:[] }, night: { top:'#263238', bottom:'#37474F', text:'#CFD8DC', textSec:'#90A4AE', overlay:'rgba(0,0,0,0.15)', shapes:[] } },
|
|
||||||
cloudy: { day: { top:'#90A4AE', bottom:'#CFD8DC', text:'#263238', textSec:'#455A64', overlay:'rgba(255,255,255,0.06)', shapes:[] }, night: { top:'#37474F', bottom:'#455A64', text:'#CFD8DC', textSec:'#90A4AE', overlay:'rgba(0,0,0,0.12)', shapes:[] } },
|
|
||||||
},
|
|
||||||
geometric: {
|
|
||||||
clear: {
|
|
||||||
day: { top:'#1A237E', bottom:'#3949AB', text:'#E8EAF6', textSec:'#9FA8DA', overlay:'rgba(255,255,255,0.04)',
|
|
||||||
shapes:[{x:0.78,y:0.20,r:0.55,c:'#5C6BC0',a:0.22},{x:0.12,y:0.68,r:0.42,c:'#3F51B5',a:0.18},{x:0.52,y:0.82,r:0.32,c:'#7986CB',a:0.14},{x:0.35,y:0.12,r:0.28,c:'#7986CB',a:0.08},{x:0.88,y:0.55,r:0.22,c:'#5C6BC0',a:0.10}] },
|
|
||||||
night: { top:'#0A0E27', bottom:'#1A1A3E', text:'#C5CAE9', textSec:'#7986CB', overlay:'rgba(255,255,255,0.03)',
|
|
||||||
shapes:[{x:0.78,y:0.20,r:0.55,c:'#1A237E',a:0.25},{x:0.12,y:0.68,r:0.42,c:'#283593',a:0.20},{x:0.52,y:0.82,r:0.32,c:'#3F51B5',a:0.16}] },
|
|
||||||
},
|
|
||||||
rain: {
|
|
||||||
day: { top:'#1A237E', bottom:'#3F51B5', text:'#E8EAF6', textSec:'#9FA8DA', overlay:'rgba(255,255,255,0.04)',
|
|
||||||
shapes:[{x:0.72,y:0.22,r:0.50,c:'#5C6BC0',a:0.20},{x:0.18,y:0.65,r:0.38,c:'#3F51B5',a:0.16},{x:0.55,y:0.80,r:0.28,c:'#7986CB',a:0.12}] },
|
|
||||||
night: { top:'#0A0E27', bottom:'#1A1A3E', text:'#C5CAE9', textSec:'#7986CB', overlay:'rgba(255,255,255,0.03)',
|
|
||||||
shapes:[{x:0.75,y:0.18,r:0.48,c:'#1A237E',a:0.22},{x:0.15,y:0.62,r:0.36,c:'#283593',a:0.18}] },
|
|
||||||
},
|
|
||||||
cloudy: {
|
|
||||||
day: { top:'#37474F', bottom:'#607D8B', text:'#ECEFF1', textSec:'#B0BEC5', overlay:'rgba(255,255,255,0.04)',
|
|
||||||
shapes:[{x:0.70,y:0.25,r:0.48,c:'#78909C',a:0.18},{x:0.20,y:0.60,r:0.36,c:'#607D8B',a:0.14},{x:0.50,y:0.78,r:0.26,c:'#90A4AE',a:0.10}] },
|
|
||||||
night: { top:'#1A1A2E', bottom:'#2D2D44', text:'#CFD8DC', textSec:'#90A4AE', overlay:'rgba(255,255,255,0.03)',
|
|
||||||
shapes:[{x:0.72,y:0.22,r:0.45,c:'#37474F',a:0.20},{x:0.18,y:0.58,r:0.34,c:'#455A64',a:0.16}] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
breezy: {
|
|
||||||
clear: {
|
|
||||||
day: { top:'#4DD0E1', bottom:'#80DEEA', text:'#004D40', textSec:'#00695C', overlay:'rgba(255,255,255,0.08)',
|
|
||||||
shapes:[{x:0.72,y:0.25,r:0.48,c:'#26C6DA',a:0.20},{x:0.20,y:0.60,r:0.36,c:'#00BCD4',a:0.16},{x:0.50,y:0.80,r:0.28,c:'#B2EBF2',a:0.12}] },
|
|
||||||
night: { top:'#006064', bottom:'#00838F', text:'#E0F7FA', textSec:'#80DEEA', overlay:'rgba(0,0,0,0.12)',
|
|
||||||
shapes:[{x:0.72,y:0.25,r:0.48,c:'#4DD0E1',a:0.18},{x:0.20,y:0.60,r:0.36,c:'#00ACC1',a:0.14}] },
|
|
||||||
},
|
|
||||||
rain: {
|
|
||||||
day: { top:'#4DB6AC', bottom:'#80CBC4', text:'#004D40', textSec:'#00695C', overlay:'rgba(255,255,255,0.06)',
|
|
||||||
shapes:[{x:0.68,y:0.28,r:0.44,c:'#66BB6A',a:0.16},{x:0.22,y:0.58,r:0.32,c:'#4DB6AC',a:0.12}] },
|
|
||||||
night: { top:'#004D40', bottom:'#00695C', text:'#E0F2F1', textSec:'#80CBC4', overlay:'rgba(0,0,0,0.15)',
|
|
||||||
shapes:[{x:0.70,y:0.22,r:0.42,c:'#4DB6AC',a:0.14},{x:0.18,y:0.55,r:0.30,c:'#00897B',a:0.10}] },
|
|
||||||
},
|
|
||||||
cloudy: {
|
|
||||||
day: { top:'#80CBC4', bottom:'#B2DFDB', text:'#004D40', textSec:'#00695C', overlay:'rgba(255,255,255,0.06)',
|
|
||||||
shapes:[{x:0.70,y:0.28,r:0.44,c:'#A7D9D2',a:0.16},{x:0.22,y:0.55,r:0.32,c:'#80CBC4',a:0.12}] },
|
|
||||||
night: { top:'#37474F', bottom:'#546E7A', text:'#ECEFF1', textSec:'#B0BEC5', overlay:'rgba(0,0,0,0.12)',
|
|
||||||
shapes:[{x:0.72,y:0.22,r:0.42,c:'#78909C',a:0.14},{x:0.18,y:0.58,r:0.30,c:'#607D8B',a:0.10}] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lemon: {
|
|
||||||
clear: {
|
|
||||||
day: { top:'#FFB74D', bottom:'#FFF176', text:'#4E342E', textSec:'#6D4C41', overlay:'rgba(255,255,255,0.06)',
|
|
||||||
shapes:[{x:0.70,y:0.25,r:0.35,c:'#FF9800',a:0.28},{x:0.70,y:0.25,r:0.18,c:'#FFC107',a:0.45},{x:0.15,y:0.70,r:0.30,c:'#FF8A65',a:0.10},{x:0.85,y:0.55,r:0.22,c:'#FFE082',a:0.08}] },
|
|
||||||
night: { top:'#1A237E', bottom:'#311B92', text:'#E8EAF6', textSec:'#B39DDB', overlay:'rgba(0,0,0,0.12)',
|
|
||||||
shapes:[{x:0.72,y:0.22,r:0.40,c:'#FFD54F',a:0.15},{x:0.15,y:0.68,r:0.30,c:'#7C4DFF',a:0.10},{x:0.85,y:0.55,r:0.22,c:'#B388FF',a:0.08}] },
|
|
||||||
},
|
|
||||||
rain: {
|
|
||||||
day: { top:'#90A4AE', bottom:'#B0BEC5', text:'#263238', textSec:'#37474F', overlay:'rgba(255,255,255,0.06)',
|
|
||||||
shapes:[{x:0.65,y:0.25,r:0.38,c:'#78909C',a:0.14},{x:0.30,y:0.50,r:0.30,c:'#607D8B',a:0.10},{x:0.15,y:0.70,r:0.30,c:'#B0BEC5',a:0.10}] },
|
|
||||||
night: { top:'#1A1A2E', bottom:'#311B92', text:'#D1C4E9', textSec:'#9575CD', overlay:'rgba(0,0,0,0.15)',
|
|
||||||
shapes:[{x:0.68,y:0.22,r:0.38,c:'#7C4DFF',a:0.12},{x:0.25,y:0.55,r:0.28,c:'#5C6BC0',a:0.08}] },
|
|
||||||
},
|
|
||||||
cloudy: {
|
|
||||||
day: { top:'#BCAAA4', bottom:'#D7CCC8', text:'#3E2723', textSec:'#5D4037', overlay:'rgba(255,255,255,0.06)',
|
|
||||||
shapes:[{x:0.60,y:0.30,r:0.40,c:'#A1887F',a:0.16},{x:0.35,y:0.55,r:0.32,c:'#8D6E63',a:0.12},{x:0.15,y:0.70,r:0.30,c:'#BCAAA4',a:0.10}] },
|
|
||||||
night: { top:'#37474F', bottom:'#4E342E', text:'#EFEBE9', textSec:'#BCAAA4', overlay:'rgba(0,0,0,0.12)',
|
|
||||||
shapes:[{x:0.65,y:0.28,r:0.38,c:'#8D6E63',a:0.12},{x:0.25,y:0.55,r:0.28,c:'#6D4C41',a:0.08}] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICONS = { clear:'☀️', rain:'🌧️', cloudy:'☁️' };
|
|
||||||
const NAMES = { clear:'晴', rain:'小雨', cloudy:'多云' };
|
|
||||||
const RANGES = { clear:'18° / 32°', rain:'14° / 22°', cloudy:'16° / 26°' };
|
|
||||||
const TEMPS = { clear:'28°', rain:'18°', cloudy:'22°' };
|
|
||||||
|
|
||||||
let curTime='day', curWeather='clear';
|
|
||||||
|
|
||||||
function createWidget(style, time, weather) {
|
|
||||||
const p = P[style][weather][time];
|
|
||||||
const icon = ICONS[weather];
|
|
||||||
let shapesHTML = '';
|
|
||||||
if (p.shapes && p.shapes.length > 0) {
|
|
||||||
shapesHTML = p.shapes.map(s => {
|
|
||||||
const size = s.r * 100;
|
|
||||||
return `<div style="position:absolute;left:${s.x*100}%;top:${s.y*100}%;width:${size}vmin;height:${size}vmin;transform:translate(-50%,-50%);border-radius:50%;background:radial-gradient(circle,${rgba(s.c,s.a)} 0%,${rgba(s.c,s.a*0.6)} 40%,${rgba(s.c,0)} 100%);z-index:0;pointer-events:none"></div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="widget">
|
|
||||||
<div class="widget-bg" style="background:linear-gradient(135deg,${p.top},${p.bottom})"></div>
|
|
||||||
${shapesHTML}
|
|
||||||
<div class="widget-overlay" style="background:${p.overlay}"></div>
|
|
||||||
<div class="widget-content">
|
|
||||||
<div class="widget-top">
|
|
||||||
<div>
|
|
||||||
<div class="temp" style="color:${p.text}">${TEMPS[weather]}</div>
|
|
||||||
<div class="condition" style="color:${p.text}">${NAMES[weather]}</div>
|
|
||||||
</div>
|
|
||||||
<div class="icon-block">
|
|
||||||
<div class="weather-icon">${icon}</div>
|
|
||||||
<div class="range" style="color:${p.textSec}">${RANGES[weather]}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="widget-bottom">
|
|
||||||
<div class="location" style="color:${p.textSec}">📍 北京</div>
|
|
||||||
<div class="metrics">
|
|
||||||
<span class="metric-tag" style="color:${p.text}">💧 58%</span>
|
|
||||||
<span class="metric-tag" style="color:${p.text}">🌬️ 12km/h</span>
|
|
||||||
<span class="metric-tag" style="color:${p.text}">🌫️ AQI 42</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAll() {
|
|
||||||
['google','geometric','breezy','lemon'].forEach(style => {
|
|
||||||
document.getElementById('grid'+style.charAt(0).toUpperCase()+style.slice(1)).innerHTML = createWidget(style, curTime, curWeather);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTime(t) {
|
|
||||||
curTime=t;
|
|
||||||
document.getElementById('btnDay').classList.toggle('active',t==='day');
|
|
||||||
document.getElementById('btnNight').classList.toggle('active',t==='night');
|
|
||||||
renderAll();
|
|
||||||
}
|
|
||||||
function setWeather(w) {
|
|
||||||
curWeather=w;
|
|
||||||
['Clear','Rain','Cloudy'].forEach(n => document.getElementById('btn'+n).classList.toggle('active',w===n.toLowerCase()));
|
|
||||||
renderAll();
|
|
||||||
}
|
|
||||||
renderAll();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
using System; class Program { static void Main() { foreach (var name in Enum.GetNames(typeof(FluentIcons.Common.Symbol))) Console.WriteLine(name); } }
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
Reference in New Issue
Block a user