chore: 更新 .gitignore,忽略 AI 工具配置、临时调试脚本和杂乱文件

This commit is contained in:
lincube
2026-06-16 15:21:57 +08:00
parent 2793be68d4
commit 2ead9d8619
39 changed files with 757 additions and 7734 deletions

View File

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

View File

@@ -1,13 +0,0 @@
{
"permissions": {
"allow": [
"Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")",
"Read(//d/github/**)",
"Bash(dotnet build *)",
"Bash(dotnet test *)",
"Bash(python -)",
"Bash(py -3 -c \"from pathlib import Path; p=Path\\(r'd:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs'\\); t=p.read_text\\(encoding='utf-8'\\); s=t.find\\('public sealed partial class UpdateSettingsPageViewModel : ViewModelBase'\\); e=t.find\\('public sealed partial class StudySettingsPageViewModel : ViewModelBase', s\\); assert s!=-1 and e!=-1; p.write_text\\(t[:s]+t[e:], encoding='utf-8'\\); print\\('ok'\\)\")",
"Bash(perl -0777 -i -pe \"s/public sealed partial class UpdateSettingsPageViewModel : ViewModelBase\\\\R\\\\{.*?\\\\R\\\\}\\\\R\\\\Rpublic sealed partial class StudySettingsPageViewModel : ViewModelBase/public sealed partial class StudySettingsPageViewModel : ViewModelBase/s\" \"d:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs\")"
]
}
}

View File

@@ -1,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"

View File

@@ -1,291 +0,0 @@
# StandBy Digital Clock - iPhone 待机风格大数字时钟组件
## 1. 需求场景与处理逻辑
### 1.1 需求描述
新增一个 4×2 尺寸的数字时钟桌面组件,视觉风格参考 iPhone 横屏充电时的 StandBy 待机显示——大面积、粗体、圆润的数字显示当前时间HH:MM数字采用不规则的自由排版有微妙的垂直偏移不在一条直线上颜色使用 Monet 主题色而非纯黑/白,伴随数字切换时的流畅垂直滚动/翻转动画,下方显示日期信息。
### 1.2 用户体验目标
- 大字号、圆润粗体的数字时间,远距离一目了然
- 数字采用不规则自由排版(微妙垂直偏移),营造 iPhone StandBy 那种有机、散漫的视觉节奏
- 数字使用 Monet 主题色(跟随壁纸/用户选色的强调色),而非死板的纯黑/白
- 数字变化时执行垂直滑动动画(旧数字向上滑出,新数字从下方滑入),类似翻页时钟效果
- 冒号(:)有呼吸闪烁效果
- 支持夜间/日间模式自动切换
- 点击组件可打开世界时钟 AirApp
- 支持时区配置(与现有桌面时钟共享设置体系)
### 1.3 处理逻辑
1. 组件加载时读取时区设置和秒针模式设置
2. `DispatcherTimer` 每秒触发一次更新
3. 当检测到分钟数变化时,触发分钟数字的垂直滑动动画
4. 当检测到小时数变化时,触发小时数字的垂直滑动动画
5. 冒号以 1 秒周期做透明度脉冲动画
6. 每 tick 检查是否需要切换日间/夜间视觉模式
## 2. 架构与技术方案
### 2.1 组件架构
遵循现有桌面组件架构模式:
- 继承 `UserControl`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware`
- AXAML 定义根布局结构,代码后置处理动画逻辑
- 通过 `DesktopComponentDefinition` 注册到组件系统
### 2.2 数字滚动动画技术方案
采用 Avalonia `RenderTransform` + `DoubleTransition` 实现数字滚动:
**核心思路**:每个数位(共 4 位H1, H2, M1, M2使用 `ClipToBounds` 的容器,内含一个垂直排列的 `StackPanel`,包含当前数字和下一个数字。切换时通过 `TranslateTransform.Y``DoubleTransition` 实现平滑滚动。
```
每位数字的结构:
┌─ DigitClip (ClipToBounds=true) ──────────┐
│ ┌─ DigitStack (TranslateTransform.Y) ──┐ │
│ │ [当前数字 TextBlock] │ │
│ │ [新数字 TextBlock] │ │
│ └───────────────────────────────────────┘ │
└───────────────────────────────────────────┘
```
当数字变化时:
1. 在 StackPanel 底部添加新数字的 TextBlock
2.`TranslateTransform.Y` 从 0 动画过渡到 `-digitHeight`
3. 动画完成后移除旧数字,重置 Y 为 0
### 2.3 动画参数
- 使用项目 `FluttermotionToken` 体系:滚动动画时长 `FluttermotionToken.Standard`200ms
- 缓动函数:`CubicEaseOut`(与项目现有动画风格一致)
- 冒号呼吸动画:透明度 1.0 → 0.3 → 1.0,周期 2 秒,使用 `DoubleTransition`
### 2.4 尺寸与布局
- 组件定义:`MinWidthCells = 4, MinHeightCells = 2`
- 缩放规则2:1 比例(与 WorldClock 一致)
- 内部布局采用 `Viewbox` 包裹,确保在不同 cellSize 下自适应缩放
- 数字字体大小:基准设计为 130px在 Viewbox 内),实际显示由 Viewbox 缩放
### 2.5 布局风格——不规则自由排版iPhone StandBy 风格)
iPhone StandBy 的数字不是规矩地排成一条直线,而是有微妙的垂直偏移和大小差异,营造出自由散漫、有机的视觉节奏:
```
H1 H2 : M1 M2
↗↘ ↘↗ ↗↘ ↘↗
↕+6 ↕+2 : ↕+4 ↕+2
↖ -3° ↗ +4° : ↖ -1° ↗ +5°
←+6,↑-10 ←-2,↓+10 →+4,↑-3 ←-2,↓+12
```
每个数字有三个自由度:
- **垂直偏移 (Y)**H1=-10, H2=+10, 冒号=+8, M1=-3, M2=+12
- **水平偏移 (X)**H1=+6, H2=-2, 冒号=0, M1=+4, M2=-2
- **旋转角度 (Z)**H1=-4°, H2=+3°, 冒号=-1°, M1=-2°, M2=+5°
### 2.6 视觉风格——圆润粗体 + Monet 主题色
- **字体**`FontWeight.Bold`,配合较大的字号,视觉上圆润饱满
- **颜色**:使用项目 Monet 主题色系统,数字颜色跟随 `AdaptiveAccentBrush` / `SystemAccentColor`,而非纯黑/白
- 数字颜色通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
- 跟随系统:使用 `AdaptiveAccentBrush`Monet 提取的强调色)
- 原生模式:使用组件自带的特色色彩
- 夜间模式:深色渐变背景 + 主题色数字(亮色调)
- 日间模式:浅色渐变背景 + 主题色数字(深色调)
- 夜间暗光环境:数字过渡到柔和的红色调(`#FF6B4A`),模拟 iPhone StandBy 夜间红色调
- **冒号颜色**:与数字同色,但有呼吸动画
- **日期行**:使用 `AdaptiveTextMutedBrush`(跟随主题的弱化文字色),字号约 14-16px 基准
- **根容器圆角**`DesignCornerRadiusComponent`(遵循圆角规范)
## 3. 受影响文件
### 3.1 新增文件
| 文件 | 类型 | 说明 |
|------|------|------|
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | 新增 | 组件 AXAML 布局 |
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 新增 | 组件代码后置(动画逻辑、时间更新、模式切换) |
### 3.2 修改文件
| 文件 | 修改类型 | 受影响函数/区域 |
|------|----------|-----------------|
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增常量 | 新增 `DesktopStandbyDigitalClock` 常量 |
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 新增定义 | `CreateDefault()` 中新增组件定义 |
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增运行时注册 | `GetDefaultRegistrations()` 中新增运行时注册项 |
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | 新增缩放规则 | `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 |
## 4. 实现细节
### 4.1 BuiltInComponentIds 新增常量
```csharp
public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
```
### 4.2 ComponentRegistry 新增定义
```csharp
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStandbyDigitalClock,
"StandBy Clock",
"Clock",
"Clock",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
```
### 4.3 DesktopComponentRuntimeRegistry 新增注册
```csharp
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStandbyDigitalClock,
"component.standby_digital_clock",
() => new StandbyDigitalClockWidget()),
```
### 4.4 NormalizeAspectRatioForComponent 缩放规则
`case BuiltInComponentIds.DesktopWorldClock:` 的同一分支中添加 `BuiltInComponentIds.DesktopStandbyDigitalClock`,使用 2:1 比例规则。
### 4.5 AXAML 布局结构
```xml
<UserControl x:Class="LanMountainDesktop.Views.Components.StandbyDigitalClockWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="14">
<!-- 背景在代码后置中设置渐变与AnalogClockWidget一致 -->
<Viewbox Stretch="Uniform">
<Grid Width="400" Height="200">
<StackPanel VerticalAlignment="Center"
HorizontalAlignment="Center"
Orientation="Horizontal">
<!-- H1 数位 -->
<Border x:Name="H1Clip" ClipToBounds="True" ...>
<Panel x:Name="H1Stack" ...>
<TextBlock x:Name="H1Text" Text="0" ... />
</Panel>
</Border>
<!-- H2 数位 -->
<Border x:Name="H2Clip" ClipToBounds="True" ...>
<Panel x:Name="H2Stack" ...>
<TextBlock x:Name="H2Text" Text="0" ... />
</Panel>
</Border>
<!-- 冒号 -->
<TextBlock x:Name="ColonText" Text=":" ... />
<!-- M1 数位 -->
<Border x:Name="M1Clip" ClipToBounds="True" ...>
<Panel x:Name="M1Stack" ...>
<TextBlock x:Name="M1Text" Text="0" ... />
</Panel>
</Border>
<!-- M2 数位 -->
<Border x:Name="M2Clip" ClipToBounds="True" ...>
<Panel x:Name="M2Stack" ...>
<TextBlock x:Name="M2Text" Text="0" ... />
</Panel>
</Border>
</StackPanel>
<!-- 日期行 -->
<TextBlock x:Name="DateTextBlock"
VerticalAlignment="Bottom"
HorizontalAlignment="Center" ... />
</Grid>
</Viewbox>
</Border>
</UserControl>
```
### 4.6 数字滚动动画核心代码(伪代码)
```csharp
private void AnimateDigit(Border clip, Panel stack, TextBlock currentText, char newDigit, double digitHeight)
{
var oldText = currentText;
var newTextBlock = new TextBlock
{
Text = newDigit.ToString(),
FontSize = oldText.FontSize,
FontWeight = oldText.FontWeight,
Foreground = oldText.Foreground,
Width = oldText.Width,
Height = digitHeight,
// 复制旧文本的所有样式属性
};
stack.Children.Add(newTextBlock);
// 应用 TranslateTransform 过渡动画
var transform = new TranslateTransform { Y = 0 };
stack.RenderTransform = transform;
stack.Transitions = new Transitions
{
new DoubleTransition(TranslateTransform.YProperty, FluttermotionToken.Standard, new CubicEaseOut())
};
// 触发动画:从当前位置滑到 -digitHeight
transform.Y = -digitHeight;
// 动画完成后清理
_ = DispatcherTimer.RunOnce(() =>
{
stack.Children.Remove(oldText);
transform.Y = 0;
stack.Transitions = null; // 移除过渡,避免重置时再次动画
// 更新引用
UpdateCurrentTextReference(newTextBlock);
}, FluttermotionToken.Standard);
}
```
### 4.7 冒号呼吸动画
使用 `DispatcherTimer` 每秒切换冒号透明度:
```csharp
private void ToggleColonOpacity()
{
_colonVisible = !_colonVisible;
ColonText.Opacity = _colonVisible ? 1.0 : 0.3;
}
```
配合 `DoubleTransition` 使透明度变化平滑过渡。
### 4.8 日间/夜间模式
`AnalogClockWidget` 使用完全相同的判断逻辑:
- 检查 `ActualThemeVariant`
- 回退到 `AdaptiveSurfaceBaseBrush` 亮度计算
- 夜间模式:深色渐变背景 + 浅色数字
- 日间模式:浅色渐变背景 + 深色数字
### 4.9 时区与设置
- 复用 `AnalogClockWidget` 的时区解析和设置加载逻辑
- 使用 `ComponentSettingsSnapshot.DesktopClockTimeZoneId` 读取时区配置
- 点击打开世界时钟 AirApp
## 5. 边界条件与异常处理
| 场景 | 处理方式 |
|------|----------|
| 组件首次加载时数字尚未初始化 | 在构造函数中初始化所有数字为当前时间,不触发动画 |
| 快速连续触发数字变化(如时间同步导致跳变) | 在动画完成前忽略新的变化请求,或中断当前动画立即跳转到目标值 |
| cellSize 极小或极大 | `ApplyCellSize` 中 clamp 缩放因子0.58-1.95,与 AnalogClockWidget 一致) |
| 时区切换 | 重新加载设置并更新所有数字(无动画,直接设置) |
| 主题切换 | 通过 `ApplyModeVisualIfNeeded()` 在下一个 tick 自动检测并切换 |
| 组件被销毁 | `DetachedFromVisualTree` 停止 timer清理资源 |
| 冒号动画在组件不可见时 | timer 仍在运行但 Opacity 变化无性能开销;若需要可结合 `IDesktopPageVisibilityAwareComponentWidget` |
## 6. 数据流路径
```
DispatcherTimer (1s interval)
→ OnTimerTick
→ 计算当前时间 (TimeZoneInfo.ConvertTimeFromUtc)
→ 比较新旧时间数字
→ 若有变化: AnimateDigit() 执行滚动动画
→ ToggleColonOpacity() 切换冒号
→ ApplyModeVisualIfNeeded() 检查日/夜间切换
→ UpdateDateText() 更新日期文本
用户点击 → OnPointerReleased → AirAppLauncherServiceProvider.OpenWorldClock()
时区变更 → TimeZoneChanged event → RefreshFromSettings() → 无动画更新所有数字
```
## 7. 预期成果
- 在桌面组件选择器中新增 "StandBy Clock" 组件,位于 Clock 分类
- 拖放到桌面后显示 4×2 大数字时钟
- 数字切换时有流畅的垂直滑动动画
- 冒号有呼吸闪烁效果
- 支持日间/夜间自动切换
- 支持时区配置
- 支持组件缩放2:1 比例规则)

View File

@@ -1,52 +0,0 @@
# StandBy Digital Clock 实现总结
## 完成状态
全部任务已完成构建通过0 错误)。
## 变更清单
### 新增文件
| 文件 | 说明 |
|------|------|
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期Monet 主题色绑定 |
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 |
### 修改文件
| 文件 | 改动 |
|------|------|
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 |
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 |
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 |
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 |
## 核心设计要点
### 不规则自由排版iPhone StandBy 风格)
- 每个数字有独立的垂直 Margin 偏移H1 上移10, H2 下移8, M1 上移5, M2 下移10
- 冒号比数字中心略低下移6
- 数字间距不等,营造自由散漫的视觉节奏
### Monet 主题色
- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色
- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
- 跟随系统:使用 Monet 提取的强调色
- 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy
- 日期文本使用 `AdaptiveTextMutedBrush`
### 数字滚动动画
- `TranslateTransform.Y` + `DoubleTransition`200ms CubicEaseOut
- 动画完成后清理旧 TextBlock 并重置 transform
### 冒号呼吸
- 每秒切换 Opacity1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡
### 日/夜模式
- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算
- 夜间:深色渐变背景 + 亮调强调色数字
- 日间:浅色渐变背景 + 深调强调色数字
### 组件规格
- 尺寸4×2 (MinWidthCells=4, MinHeightCells=2)
- 分类Clock
- 缩放2:1 比例 (Proportional)
- 字体FontWeight.Bold, 120px 基准

View File

@@ -1,25 +0,0 @@
# StandBy Digital Clock 任务计划
- [x] Task 1: 注册组件定义与运行时
- 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量
- 1.2: 在 `ComponentRegistry.cs``CreateDefault()` 中新增 `DesktopComponentDefinition`4×2, Clock 分类, Proportional
- 1.3: 在 `DesktopComponentRuntimeRegistry.cs``GetDefaultRegistrations()` 中新增运行时注册项
- 1.4: 在 `MainWindow.ComponentSystem.cs``NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则
- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局
- 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 RootBorderDesignCornerRadiusComponent、Viewbox、时间数字区域4 个 ClipToBounds 数位容器 + 冒号)、日期文本
- 2.2: 确保 Viewbox 内基准设计尺寸为 400×200数字使用 FontWeight.Bold冒号和日期布局合理
- [x] Task 3: 实现组件代码后置(核心逻辑与动画)
- 3.1: 创建 `StandbyDigitalClockWidget.axaml.cs`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware` 接口
- 3.2: 实现 DispatcherTimer 每秒更新逻辑,比较新旧时间数字,触发数位滚动动画
- 3.3: 实现数字垂直滚动动画:每位数字使用 TranslateTransform.Y + DoubleTransition旧数字上滑出新数字滑入动画完成后清理
- 3.4: 实现冒号呼吸动画:每秒切换透明度,配合 DoubleTransition 平滑过渡
- 3.5: 实现日间/夜间模式切换:检测 ActualThemeVariant 和亮度,切换背景渐变和数字颜色;夜间暗光环境过渡到红色调
- 3.6: 实现 ApplyCellSize 缩放逻辑clamp 缩放因子,更新圆角和间距
- 3.7: 实现时区设置加载(复用 AnalogClockWidget 逻辑),点击打开世界时钟 AirApp
- 3.8: 实现日期文本更新逻辑,显示完整日期和星期
- [x] Task 4: 构建验证与调试
- 4.1: 执行 `dotnet build` 确保编译通过,修复所有错误
- 4.2: 检查圆角规范合规性(根容器使用 DesignCornerRadiusComponent

View File

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

View File

@@ -1 +0,0 @@

59
.gitignore vendored
View File

@@ -519,3 +519,62 @@ nul
/velopack-output-local
/test-aot-publish
/.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
View File

@@ -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"
}
}
}
}

View File

@@ -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`

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -91,10 +91,9 @@ public sealed record PluginManifest(
if (requestedVersion.Major != currentVersion.Major)
{
throw new InvalidOperationException(
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
$"This host only supports v{currentVersion.Major}.x plugins and rejects v{requestedVersion.Major}.x packages by default. " +
$"Migrate the plugin manifest and code to API {PluginSdkInfo.ApiVersion}, then rebuild and republish the package.");
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', " +
$"but the host provides '{PluginSdkInfo.ApiVersion}'. " +
$"This host only supports API {PluginSdkInfo.ApiVersion} plugins.");
}
return normalized;

View File

@@ -1830,7 +1830,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
entry.Author,
entry.Version,
entry.ApiVersion,
string.Empty,
entry.EntranceAssembly,
entry.SharedContracts
.Select(contract => new PluginCatalogSharedContractInfo(
contract.Id,
@@ -1858,7 +1858,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
entry.UpdatedAt,
entry.PackageSizeBytes,
entry.Sha256,
null);
entry.Md5);
var sources = BuildPackageSources(entry);
@@ -1873,21 +1873,16 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
private static IReadOnlyList<PluginCapabilityInfo> BuildCapabilities(AirAppMarketPluginEntry entry)
{
if (entry.Capabilities is null)
{
return [];
}
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)));
capabilities.AddRange(entry.Capabilities.DesktopComponents.Select(id =>
capabilities.AddRange(entry.DesktopComponents.Select(id =>
new PluginCapabilityInfo(id, null, null)));
capabilities.AddRange(entry.Capabilities.SettingsSections.Select(id =>
capabilities.AddRange(entry.SettingsSections.Select(id =>
new PluginCapabilityInfo(id, null, null)));
capabilities.AddRange(entry.Capabilities.Exports.Select(id =>
capabilities.AddRange(entry.Exports.Select(id =>
new PluginCapabilityInfo(id, null, null)));
capabilities.AddRange(entry.Capabilities.MessageTypes.Select(id =>
capabilities.AddRange(entry.MessageTypes.Select(id =>
new PluginCapabilityInfo(id, null, null)));
return capabilities

View File

@@ -184,8 +184,6 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
services.AddSingleton(_localizationService);
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
services.AddSingleton<WeatherLocationRefreshService>();
services.AddSingleton<AirAppMarketIconService>();
services.AddSingleton<AirAppMarketReadmeService>();
var pluginRuntime = _pluginRuntimeAccessor();
if (pluginRuntime is not null)

View File

@@ -24,7 +24,7 @@ public enum PluginCatalogPrimaryActionState
Incompatible
}
public sealed partial class PluginCatalogItemViewModel : ViewModelBase
public sealed partial class PluginCatalogItemViewModel : ViewModelBase, IDisposable
{
private readonly LocalizationService _localizationService;
private readonly string _languageCode;
@@ -111,6 +111,11 @@ public sealed partial class PluginCatalogItemViewModel : ViewModelBase
OnPropertyChanged(nameof(HasIcon));
}
public void Dispose()
{
IconBitmap = null;
}
public async Task EnsureIconLoadedAsync(AirAppMarketIconService iconService)
{
if (_isLoadingIcon || IconBitmap is not null)
@@ -376,7 +381,7 @@ public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase, IDisposable
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly IPluginCatalogSettingsService _pluginCatalog;
@@ -456,6 +461,19 @@ public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
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)
{
return new PluginCatalogDetailViewModel(

View File

@@ -44,26 +44,39 @@ public partial class PluginCatalogSettingsPage : SettingsPageBase
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()
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var localizationService = new LocalizationService();
var assetCache = new PluginMarketAssetCacheService(AppDataPathProvider.GetPluginMarketDirectory());
return new PluginCatalogSettingsPageViewModel(
settingsFacade,
localizationService,
new AirAppMarketIconService(),
new AirAppMarketReadmeService());
new AirAppMarketIconService(assetCache),
new AirAppMarketReadmeService(assetCache));
}
private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var localizationService = new LocalizationService();
var assetCache = new PluginMarketAssetCacheService(AppDataPathProvider.GetPluginMarketDirectory());
var viewModel = new PluginCatalogSettingsPageViewModel(
settingsFacade,
localizationService,
new AirAppMarketIconService(),
new AirAppMarketReadmeService());
new AirAppMarketIconService(assetCache),
new AirAppMarketReadmeService(assetCache));
var previewHostVersion = new Version(1, 2, 0);
var items = new[]

View File

@@ -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);
}

View 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

View File

@@ -4,15 +4,27 @@ using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using LanMountainDesktop.Services.Settings;
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
{
private readonly PluginMarketAssetCacheService? _cache;
private readonly HttpClient _httpClient;
public AirAppMarketIconService()
: this(cache: null)
{
}
public AirAppMarketIconService(PluginMarketAssetCacheService? cache)
{
_cache = cache;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
@@ -20,28 +32,8 @@ public sealed class AirAppMarketIconService : IDisposable
_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(
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
PluginCatalogItemInfo plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
@@ -51,11 +43,37 @@ public sealed class AirAppMarketIconService : IDisposable
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);
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();
await stream.CopyToAsync(memory, cancellationToken);
await networkStream.CopyToAsync(memory, cancellationToken);
memory.Position = 0;
return new Bitmap(memory);
}

View File

@@ -10,7 +10,6 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketIndexService : IDisposable
{
private readonly AirAppMarketCacheService _cacheService;
private readonly AirAppMarketMetadataResolverService _metadataResolver;
private readonly HttpClient _httpClient;
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
@@ -23,20 +22,19 @@ internal sealed class AirAppMarketIndexService : IDisposable
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
_metadataResolver = new AirAppMarketMetadataResolverService(_httpClient);
}
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
{
Exception? networkError = null;
// The index is self-contained, so there is no per-plugin enrichment step anymore.
if (AirAppMarketDefaults.TryGetWorkspaceIndexPath() is { } localIndexPath)
{
try
{
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken).ConfigureAwait(false);
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(
true,
@@ -69,7 +67,6 @@ internal sealed class AirAppMarketIndexService : IDisposable
response.EnsureSuccessStatusCode();
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(
true,
@@ -97,7 +94,6 @@ internal sealed class AirAppMarketIndexService : IDisposable
try
{
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
cachedDocument = await _metadataResolver.EnrichAsync(cachedDocument, cancellationToken).ConfigureAwait(false);
return new AirAppMarketLoadResult(
true,
cachedDocument,
@@ -129,7 +125,6 @@ internal sealed class AirAppMarketIndexService : IDisposable
public void Dispose()
{
_metadataResolver.Dispose();
_httpClient.Dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,27 @@ using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services.Settings;
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
{
private readonly PluginMarketAssetCacheService? _cache;
private readonly HttpClient _httpClient;
public AirAppMarketReadmeService()
: this(cache: null)
{
}
public AirAppMarketReadmeService(PluginMarketAssetCacheService? cache)
{
_cache = cache;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
@@ -19,24 +31,8 @@ public sealed class AirAppMarketReadmeService : IDisposable
_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(
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
PluginCatalogItemInfo plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
@@ -46,9 +42,38 @@ public sealed class AirAppMarketReadmeService : IDisposable
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);
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()

View File

@@ -22,14 +22,15 @@ internal sealed class PluginSharedContractManager : IDisposable
private readonly Dictionary<string, LoadedSharedContract> _loadedContracts =
new(StringComparer.OrdinalIgnoreCase);
public PluginSharedContractManager(string cacheDirectory)
public PluginSharedContractManager(string dataDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
_contractsDirectory = Path.Combine(
GetSharedContractRootDirectory(),
"SharedContracts");
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(cacheDirectory));
// Shared contracts live alongside the rest of the plugin market data so that a single
// storage location (driven by AppDataPathProvider.GetDataRoot() / the OOBE-chosen path)
// owns every plugin asset: index cache, downloads, and shared contracts.
_contractsDirectory = Path.Combine(dataDirectory, "SharedContracts");
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(2)
@@ -255,11 +256,6 @@ internal sealed class PluginSharedContractManager : IDisposable
reference.AssemblyName);
}
private static string GetSharedContractRootDirectory()
{
return AppDataPathProvider.GetDataRoot();
}
private static string Sanitize(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();

View File

@@ -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. **安全清单**: 建立安全相关的持续集成检查
---
*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。*

View File

@@ -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. **代码审计**: 建议进行定期安全审计
---
*报告生成工具: 自动安全审计系统*
*审计方法: 静态代码分析 + 架构审查 + 攻击面映射*

View File

@@ -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结论一致代码库在安全性方面保持良好状态未发现新增的中等及以上漏洞。
---
*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。*

View File

@@ -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")}");
}
}
}

View File

@@ -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>

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -1 +0,0 @@
using System; class Program { static void Main() { foreach (var name in Enum.GetNames(typeof(FluentIcons.Common.Symbol))) Console.WriteLine(name); } }

View File

@@ -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>