From 2ead9d86192f9065ca39716af205a909bba246f7 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 16 Jun 2026 15:21:57 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20.gitignore?= =?UTF-8?q?=EF=BC=8C=E5=BF=BD=E7=95=A5=20AI=20=E5=B7=A5=E5=85=B7=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E3=80=81=E4=B8=B4=E6=97=B6=E8=B0=83=E8=AF=95=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=92=8C=E6=9D=82=E4=B9=B1=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .arts/settings.json | 5 - .claude/settings.local.json | 13 - .codex/environments/environment.toml | 16 - .comate/specs/standby-digital-clock/doc.md | 291 --- .../specs/standby-digital-clock/summary.md | 52 - .comate/specs/standby-digital-clock/tasks.md | 25 - .../launcher_单项目解耦_302f1ec6.plan.md | 432 ----- .cursor/skills/.gitkeep | 1 - .gitignore | 59 + .kilo/package-lock.json | 376 ---- .kilo/plans/1776989126427-witty-island.md | 171 -- CODE_WIKI.md | 1577 ----------------- CheckIpcAot/CheckIpcAot.csproj | 14 - CheckIpcAot/Program.cs | 10 - .../PluginManifest.cs | 7 +- .../Settings/SettingsDomainServices.cs | 19 +- .../Services/Settings/SettingsPageRegistry.cs | 2 - .../PluginCatalogSettingsPageViewModels.cs | 22 +- .../PluginCatalogSettingsPage.axaml.cs | 21 +- .../AirAppMarketMetadataResolverService.cs | 403 ----- .../plugins/PluginMarketAssetCacheService.cs | 336 ++++ .../plugins/PluginMarketEmbeddedView.cs | 1323 -------------- .../plugins/PluginMarketIconService.cs | 64 +- .../plugins/PluginMarketIndexService.cs | 7 +- .../plugins/PluginMarketModels.cs | 890 +++------- .../plugins/PluginMarketReadmeService.cs | 61 +- .../plugins/PluginSharedContractManager.cs | 18 +- SECURITY_AUDIT_REPORT.md | 255 --- SECURITY_AUDIT_REPORT_2026-05-24.md | 253 --- SECURITY_AUDIT_REPORT_2026-06-01.md | 329 ---- TestFluentIcons/Program.cs | 29 - TestFluentIcons/TestFluentIcons.csproj | 15 - ago --name-only --oneline | 433 ----- ago --name-only --stat | 173 -- ago --stat --format=short | 109 -- mocks/class-schedule-mock.html | 459 ----- mocks/weather-widget-mock.html | 209 --- testicon/Program.cs | 1 - testicon/testicon.csproj | 11 - 39 files changed, 757 insertions(+), 7734 deletions(-) delete mode 100644 .arts/settings.json delete mode 100644 .claude/settings.local.json delete mode 100644 .codex/environments/environment.toml delete mode 100644 .comate/specs/standby-digital-clock/doc.md delete mode 100644 .comate/specs/standby-digital-clock/summary.md delete mode 100644 .comate/specs/standby-digital-clock/tasks.md delete mode 100644 .cursor/plans/launcher_单项目解耦_302f1ec6.plan.md delete mode 100644 .cursor/skills/.gitkeep delete mode 100644 .kilo/package-lock.json delete mode 100644 .kilo/plans/1776989126427-witty-island.md delete mode 100644 CODE_WIKI.md delete mode 100644 CheckIpcAot/CheckIpcAot.csproj delete mode 100644 CheckIpcAot/Program.cs delete mode 100644 LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs create mode 100644 LanMountainDesktop/plugins/PluginMarketAssetCacheService.cs delete mode 100644 LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs delete mode 100644 SECURITY_AUDIT_REPORT.md delete mode 100644 SECURITY_AUDIT_REPORT_2026-05-24.md delete mode 100644 SECURITY_AUDIT_REPORT_2026-06-01.md delete mode 100644 TestFluentIcons/Program.cs delete mode 100644 TestFluentIcons/TestFluentIcons.csproj delete mode 100644 ago --name-only --oneline delete mode 100644 ago --name-only --stat delete mode 100644 ago --stat --format=short delete mode 100644 mocks/class-schedule-mock.html delete mode 100644 mocks/weather-widget-mock.html delete mode 100644 testicon/Program.cs delete mode 100644 testicon/testicon.csproj diff --git a/.arts/settings.json b/.arts/settings.json deleted file mode 100644 index e249426..0000000 --- a/.arts/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "diffEditor.renderSideBySide": false, - "clawMode.mode": "editor", - "workbench.activityBar.location": "default" -} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b378090..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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\")" - ] - } -} diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml deleted file mode 100644 index 40afd11..0000000 --- a/.codex/environments/environment.toml +++ /dev/null @@ -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" diff --git a/.comate/specs/standby-digital-clock/doc.md b/.comate/specs/standby-digital-clock/doc.md deleted file mode 100644 index d2bc6fe..0000000 --- a/.comate/specs/standby-digital-clock/doc.md +++ /dev/null @@ -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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -### 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 比例规则) diff --git a/.comate/specs/standby-digital-clock/summary.md b/.comate/specs/standby-digital-clock/summary.md deleted file mode 100644 index a502339..0000000 --- a/.comate/specs/standby-digital-clock/summary.md +++ /dev/null @@ -1,52 +0,0 @@ -# StandBy Digital Clock 实现总结 - -## 完成状态 -全部任务已完成,构建通过(0 错误)。 - -## 变更清单 - -### 新增文件 -| 文件 | 说明 | -|------|------| -| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期,Monet 主题色绑定 | -| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置:数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 | - -### 修改文件 -| 文件 | 改动 | -|------|------| -| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 | -| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 | -| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 | -| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 | - -## 核心设计要点 - -### 不规则自由排版(iPhone StandBy 风格) -- 每个数字有独立的垂直 Margin 偏移(H1 上移10, H2 下移8, M1 上移5, M2 下移10) -- 冒号比数字中心略低(下移6) -- 数字间距不等,营造自由散漫的视觉节奏 - -### Monet 主题色 -- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色 -- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断: - - 跟随系统:使用 Monet 提取的强调色 - - 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy -- 日期文本使用 `AdaptiveTextMutedBrush` - -### 数字滚动动画 -- `TranslateTransform.Y` + `DoubleTransition`(200ms CubicEaseOut) -- 动画完成后清理旧 TextBlock 并重置 transform - -### 冒号呼吸 -- 每秒切换 Opacity(1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡 - -### 日/夜模式 -- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算 -- 夜间:深色渐变背景 + 亮调强调色数字 -- 日间:浅色渐变背景 + 深调强调色数字 - -### 组件规格 -- 尺寸:4×2 (MinWidthCells=4, MinHeightCells=2) -- 分类:Clock -- 缩放:2:1 比例 (Proportional) -- 字体:FontWeight.Bold, 120px 基准 diff --git a/.comate/specs/standby-digital-clock/tasks.md b/.comate/specs/standby-digital-clock/tasks.md deleted file mode 100644 index 6cb7dab..0000000 --- a/.comate/specs/standby-digital-clock/tasks.md +++ /dev/null @@ -1,25 +0,0 @@ -# StandBy Digital Clock 任务计划 - -- [x] Task 1: 注册组件定义与运行时 - - 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量 - - 1.2: 在 `ComponentRegistry.cs` 的 `CreateDefault()` 中新增 `DesktopComponentDefinition`(4×2, Clock 分类, Proportional) - - 1.3: 在 `DesktopComponentRuntimeRegistry.cs` 的 `GetDefaultRegistrations()` 中新增运行时注册项 - - 1.4: 在 `MainWindow.ComponentSystem.cs` 的 `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 - -- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局 - - 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 RootBorder(DesignCornerRadiusComponent)、Viewbox、时间数字区域(4 个 ClipToBounds 数位容器 + 冒号)、日期文本 - - 2.2: 确保 Viewbox 内基准设计尺寸为 400×200,数字使用 FontWeight.Bold,冒号和日期布局合理 - -- [x] Task 3: 实现组件代码后置(核心逻辑与动画) - - 3.1: 创建 `StandbyDigitalClockWidget.axaml.cs`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware` 接口 - - 3.2: 实现 DispatcherTimer 每秒更新逻辑,比较新旧时间数字,触发数位滚动动画 - - 3.3: 实现数字垂直滚动动画:每位数字使用 TranslateTransform.Y + DoubleTransition,旧数字上滑出新数字滑入,动画完成后清理 - - 3.4: 实现冒号呼吸动画:每秒切换透明度,配合 DoubleTransition 平滑过渡 - - 3.5: 实现日间/夜间模式切换:检测 ActualThemeVariant 和亮度,切换背景渐变和数字颜色;夜间暗光环境过渡到红色调 - - 3.6: 实现 ApplyCellSize 缩放逻辑,clamp 缩放因子,更新圆角和间距 - - 3.7: 实现时区设置加载(复用 AnalogClockWidget 逻辑),点击打开世界时钟 AirApp - - 3.8: 实现日期文本更新逻辑,显示完整日期和星期 - -- [x] Task 4: 构建验证与调试 - - 4.1: 执行 `dotnet build` 确保编译通过,修复所有错误 - - 4.2: 检查圆角规范合规性(根容器使用 DesignCornerRadiusComponent) diff --git a/.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md b/.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md deleted file mode 100644 index ee3afbe..0000000 --- a/.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md +++ /dev/null @@ -1,432 +0,0 @@ ---- -name: Launcher 单项目解耦 -overview: 在保持单一 LanMountainDesktop.Launcher 项目、单一 exe、零部署风险的前提下,按职责域增量重构:目录分层、RunAsync→Pipeline+Phase、UpdateEngine→策略类、App→纯 Avalonia+LauncherOrchestrator;执行过程中由 Agent 自主 Git 提交,每域可编译可测。 -todos: - - id: phase-a-diagnostics - content: Phase A:Startup 诊断 + HostStartupMonitor 独立类 + AOT 启动检测竞态修复 + 测试 - status: completed - - id: phase-b-directory - content: Phase B1:职责域目录迁移(Deployment/Update/Startup/Oobe/Plugins/Infrastructure),零逻辑变更,提交 - status: completed - - id: phase-b-pipeline - content: Phase B2:RunAsync→LaunchPipeline+ILaunchPhase,引入 LauncherOrchestrator,删除 LauncherFlowCoordinator,提交 - status: completed - - id: phase-b-app-slim - content: Phase B3:App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator,提交 - status: completed - - id: phase-c-di - content: Phase C:LauncherServiceRegistration + 轻量 MS DI,统一 CLI/GUI 装配,提交 - status: completed - - id: phase-d-update-split - content: Phase D:UpdateEngineService→门面+策略类(Verifier/Activator/Rollback 等),提交 - status: completed - - id: phase-e-guardrails - content: Phase E:LauncherArchitectureTests + 文档 + AOT 回归,提交 - status: completed -isProject: false ---- - -# Launcher 单项目内部解耦改造计划(执行版) - -## 0. 硬性约束 - - -| 约束 | 说明 | -| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **单项目** | 仅 `[LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj](LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj)`,不新建 Launcher.* 独立程序集 | -| **单 exe** | 仍只发布 `LanMountainDesktop.Launcher.exe`(AOT 单文件) | -| **零部署风险** | 不改变安装包目录结构、不引入新进程、不改变 Public IPC / Coordinator IPC 拓扑与契约 | -| **增量重构** | 一个职责域一域推进,每步 `dotnet build` + 相关 `dotnet test` 通过后再进下一步 | -| **单进程性能** | 模块间仅 in-process 接口调用,不为解耦新增 IPC | -| **未来可拆** | 各域暴露 `I`* 接口,将来若需多进程可直接复用契约 | -| **Git 自主提交** | Agent 在每个职责域完成且验证通过后 **自动 commit**,无需用户手动提交(见 §8) | - - -外部共享库 `[LanMountainDesktop.PluginPackaging](LanMountainDesktop.PluginPackaging/)` 保留(Host + Launcher CLI 共用),不属于 Launcher 拆分。 - ---- - -## 1. 验收标准(必须全部满足) - -### 1.1 零部署风险 - -- Inno Setup / CI 产物仍只有:`LanMountainDesktop.Launcher.exe` + `app-{version}/` + `.launcher/` -- Host 调用 Launcher 的 CLI 参数、`launch-source`、`apply-update` 路径不变 -- Public IPC routes(`lanmountain.launcher.startup-progress`、`loading-state`)与 Coordinator pipe 不变 -- VeloPack / 更新 apply 状态机(`.current/.partial/.destroy`)行为不变 - -### 1.2 增量可验证 - -- 每个 Phase 结束:编译绿 + 该域新增/既有测试绿 -- 允许「纯移动文件」的 PR 单独提交,行为 diff 为零 - -### 1.3 测试友好 - -- `Startup/`、`Update/`、`Deployment/` 内类型 **无 Avalonia 依赖**,可独立单元测试 -- 每个 `ILaunchPhase`、每个 Update 策略类各有对应测试类 -- 保留并扩展现有 `[LauncherStartupTimeoutPolicyTests](LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs)`、`[LauncherMultiInstancePolicyTests](LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs)` - -### 1.4 启动性能 - -- Pipeline 阶段为同步/异步方法调用链,不引入额外进程或网络 -- DI 容器仅在进程入口构建一次;Stage/Phase 实例可复用 Singleton - -### 1.5 代码结构目标 - - -| 对象 | 当前(实测) | 目标 | -| ----------------------------------- | -------------------------------------------- | --------------------------------------------------- | -| `LauncherFlowCoordinator` 全 partial | ~1880 行(859+568+279+…) | **删除**;逻辑迁入 Pipeline + Phases | -| `RunAsync()` 等价逻辑 | 跨 partial ~800+ 行 while/阶段混杂 | **≤80 行** 编排入口,细节在各 Phase | -| `UpdateEngineService` | ~1622 行 | 门面 **≤200 行** + 6 个策略类各 **≤300 行** | -| `App.axaml.cs` | ~258 行(已部分瘦身) | **≤120 行**:纯 Avalonia + 一行委托 `LauncherOrchestrator` | -| `LauncherOrchestrator` | 不存在(逻辑在 Coordinator + CompositionRoot 546 行) | **≤250 行**:GUI 入口编排 | -| `LauncherCompositionRoot` | ~546 行 | **≤150 行**:仅 DI 构建 + 入口分发 | - - ---- - -## 2. 目标架构 - -### 2.1 核心类型关系 - -```mermaid -flowchart TB - Program --> EntryRouter - App --> LauncherOrchestrator - EntryRouter --> LauncherOrchestrator - LauncherOrchestrator --> LaunchPipeline - LaunchPipeline --> Phase1[CleanupPhase] - LaunchPipeline --> Phase2[OobeGatePhase] - LaunchPipeline --> Phase3[ApplyUpdatePhase] - LaunchPipeline --> Phase4[LaunchHostPhase] - LaunchPipeline --> Phase5[MonitorStartupPhase] - Phase3 --> IUpdateEngine - Phase4 --> IDeploymentLocator - Phase5 --> IHostStartupMonitor - LauncherCompositionRoot --> ServiceProvider - ServiceProvider --> LaunchPipeline -``` - - - -**命名约定:** - -- `**LauncherOrchestrator`**:GUI 生命周期内的唯一编排入口(取代 `LauncherFlowCoordinator` 对外角色) -- `**LaunchPipeline**`:按序执行 `ILaunchPhase` 列表 -- `**ILaunchPhase**`:原 `ILaunchPipelineStage`;每个 Phase 对应原 `RunAsync` 中一个职责段 - -### 2.2 职责域目录(单项目内) - -``` -LanMountainDesktop.Launcher/ -├── Program.cs # CLI / GUI 路由 -├── App.axaml.cs # 纯 Avalonia(≤120 行) -├── Shell/ -│ ├── LauncherOrchestrator.cs # GUI 编排入口 -│ ├── LauncherCompositionRoot.cs # DI + Entry 分发 -│ ├── LaunchPipeline.cs -│ ├── Phases/ # ILaunchPhase 实现 -│ │ ├── CleanupDeploymentsPhase.cs -│ │ ├── OobeGatePhase.cs -│ │ ├── ApplyPendingUpdatePhase.cs -│ │ ├── LaunchHostPhase.cs -│ │ └── MonitorStartupPhase.cs -│ └── EntryHandlers/ # apply-update / air-app-broker / attach -├── Deployment/ -├── Update/ -│ ├── IUpdateEngine.cs -│ ├── UpdateEngineFacade.cs # 原 UpdateEngineService 门面 -│ └── Strategies/ -│ ├── PendingUpdateDetector.cs -│ ├── UpdatePackageVerifier.cs -│ ├── DeploymentActivator.cs -│ ├── UpdateSnapshotStore.cs -│ ├── RollbackStrategy.cs -│ └── IncomingArtifactsCleaner.cs -├── Startup/ -├── Oobe/ -├── Ipc/ -├── AirApp/ -├── Plugins/ -├── Infrastructure/ -├── Models/ -└── Views/ -``` - -### 2.3 模块依赖规则 - -- `Deployment/`、`Update/`、`Startup/`:**禁止** `using Avalonia` -- `Views/`:**禁止** 引用具体 `UpdateEngineFacade` / `DeploymentLocator`(仅接口或 Orchestrator) -- 跨域:**仅通过 `I`* 接口**;Orchestrator/Pipeline 负责装配 - -### 2.4 与 Host 边界(不变) - - -| 能力 | Owner | -| -------------------------- | ------------------------------ | -| OOBE / Splash / 多实例 / 启动检测 | Launcher `Startup/` + `Shell/` | -| 更新 apply / rollback | Launcher `Update/` | -| 插件市场 / pending | Host + PluginPackaging | -| 更新 download | Host → spawn Launcher apply | - - ---- - -## 3. 三大核心拆分(用户指定) - -### 3.1 拆分 `LauncherFlowCoordinator`:`RunAsync` → Pipeline + Phase - -**现状:** 逻辑分散在 4 个 partial,等效一个 1800+ 行 God Class;`RunAsync` 内含清理、OOBE、更新、启动、IPC 监听、超时 while-loop、多实例分支。 - -**目标 API(单项目 `Shell/` 内):** - -```csharp -internal interface ILaunchPhase -{ - string PhaseId { get; } - /// null = 继续下一阶段;非 null = 管道终止并返回结果 - Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken); -} - -internal sealed class LaunchPipeline -{ - public LaunchPipeline(IEnumerable phases) { ... } - public Task RunAsync(LaunchContext context, CancellationToken ct); -} -``` - -**Phase 映射(与原 RunAsync 步骤一一对应):** - - -| Phase | 原 RunAsync 段 | 产出 | -| ------------------------- | --------------------------------------- | ----------------------------- | -| `CleanupDeploymentsPhase` | `CleanupOldDeployments` | 无 UI | -| `ExistingHostProbePhase` | 多实例 / Public IPC 探测 | 可短路成功 | -| `ApplyPendingUpdatePhase` | `_updateEngine.ApplyPendingUpdateAsync` | 失败仍继续 | -| `OobeGatePhase` | migration + OOBE steps | UI via `ILauncherUiPresenter` | -| `LaunchHostPhase` | `LaunchHostWithIpcAsync` | Process + plan | -| `MonitorStartupPhase` | while-loop + IPC + timeout | 调用 `IHostStartupMonitor` | - - -`**LauncherOrchestrator` 职责:** - -- 接收 `SplashWindow`、构建 `LaunchContext`(含 reporter、attempt registry、coordinator server) -- 调用 `LaunchPipeline.RunAsync` -- 管理 Splash/Error 窗口生命周期(委托 `ILauncherUiPresenter`) -- **不含** 更新/部署/IPC 细节 - -**删除清单:** `LauncherFlowCoordinator.cs` 及全部 partial 文件。 - ---- - -### 3.2 拆分 `UpdateEngineService` → 门面 + 策略类 - -**现状:** ~1622 行单文件,混合检测、验签、解压、激活、快照、回滚、清理。 - -**目标结构:** - -``` -Update/ -├── IUpdateEngine.cs # 对外契约(未来多进程可原样抽出) -├── UpdateEngineFacade.cs # 门面,编排策略,≤200 行 -└── Strategies/ - ├── IUpdateStrategy.cs # 可选:各策略统一接口 - ├── PendingUpdateDetector.cs # CheckPendingUpdate - ├── UpdatePackageVerifier.cs # manifest + RSA 签名 - ├── UpdatePackageExtractor.cs # 解压 / 增量复用 - ├── DeploymentActivator.cs # .current / .partial / .destroy - ├── UpdateSnapshotStore.cs # snapshots 读写 - ├── RollbackStrategy.cs # rollback CLI/GUI - └── IncomingArtifactsCleaner.cs # CleanupIncomingArtifacts -``` - -**门面方法映射:** - - -| 原 `UpdateEngineService` 公开方法 | 委托策略 | -| ---------------------------- | ------------------------------------------------------ | -| `CheckPendingUpdate()` | `PendingUpdateDetector` | -| `ApplyPendingUpdateAsync()` | Detector → Verifier → Extractor → Activator → Snapshot | -| `RollbackLatest()` | `RollbackStrategy` | -| `CleanupIncomingArtifacts()` | `IncomingArtifactsCleaner` | -| `DownloadAsync()`(若有) | 保持或拆 `UpdateDownloader` | - - -**测试:** 每个 Strategy 独立 mock `IDeploymentLocator` / 文件系统,不启 Avalonia。 - ---- - -### 3.3 精简 `App.axaml.cs` → 纯 Avalonia + `LauncherOrchestrator` - -**现状:** ~258 行,仍含 apply-update、air-app-broker、preview、coordinator attach 等分支。 - -**目标结构:** - -```csharp -// App.axaml.cs 目标形态(概念) -public override void OnFrameworkInitializationCompleted() -{ - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var context = LauncherRuntimeContext.Current; - var mode = LauncherEntryModeResolver.Resolve(context); - _ = LauncherOrchestrator.RunAsync(desktop, context, mode); - } - base.OnFrameworkInitializationCompleted(); -} -``` - -**从 App 迁出的逻辑 → `Shell/EntryHandlers/`:** - - -| 现 App 分支 | 新 Handler | -| ----------------- | -------------------------------------- | -| `launch` + splash | `GuiLaunchEntryHandler` → Orchestrator | -| `apply-update` | `ApplyUpdateEntryHandler` | -| `air-app-broker` | `AirAppBrokerEntryHandler` | -| debug preview | `PreviewEntryHandler` | - - -**验收:** `App.axaml.cs` ≤120 行;不含 `new UpdateEngineService` / `new DeploymentLocator` / while-loop。 - ---- - -## 4. 分阶段执行顺序与 Git 提交点 - -```mermaid -flowchart LR - A[Phase A Startup] --> B1[Phase B1 目录迁移] - B1 --> B2[Phase B2 Pipeline+Orchestrator] - B2 --> B3[Phase B3 App 精简] - B3 --> C[Phase C DI] - B1 --> D[Phase D Update 策略拆分] - C --> E[Phase E 守卫+文档+AOT回归] - D --> E -``` - - - -### Phase A:Startup 子系统 + AOT 生产 bug(优先) - -- 抽出 `Startup/HostStartupMonitor.cs`(从 partial 独立) -- 修复 IPC 连接退避、成功判定统一走 `StartupSuccessTracker` -- Host 侧 `DesktopVisible` 上报对齐(仅日志/时序,不改 IPC 契约) -- 测试 + `**git commit**`: `fix(launcher): extract HostStartupMonitor and harden startup detection` - -### Phase B1:目录迁移(零逻辑变更) - -- 物理移动文件到 `Deployment/`、`Update/`、`Startup/` 等,更新 namespace -- `dotnet build` + test -- `**git commit**`: `refactor(launcher): reorganize into responsibility folders` - -### Phase B2:Pipeline + Phase + LauncherOrchestrator - -- 实现 `ILaunchPhase`、`LaunchPipeline`、`LauncherOrchestrator` -- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试) -- 删除 `LauncherFlowCoordinator*` -- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline` - -### Phase B3:App.axaml.cs 精简 - -- EntryHandlers 提取;App 仅 Avalonia + Orchestrator 委托 -- `**git commit**`: `refactor(launcher): slim App.axaml.cs to Avalonia shell only` - -### Phase C:轻量 DI - -- `LauncherServiceRegistration.cs` + `Microsoft.Extensions.DependencyInjection` -- Program / CliHost / CompositionRoot 统一 `ServiceProvider` -- `**git commit**`: `refactor(launcher): add composition-root DI wiring` - -### Phase D:UpdateEngine 策略拆分(可与 B2 并行,依赖 B1) - -- 策略类提取 + `UpdateEngineFacade` -- 删除原巨型 `UpdateEngineService.cs` -- 每策略测试 -- `**git commit**`: `refactor(launcher): split UpdateEngine into strategy classes` - -### Phase E:守卫 + 文档 + AOT 回归 - -- `LauncherArchitectureTests`(命名空间依赖规则) -- 更新 `[docs/LAUNCHER.md](docs/LAUNCHER.md)`、`[.trae/specs/launcher-shell-hardening/spec.md](.trae/specs/launcher-shell-hardening/spec.md)` -- AOT publish 本地 smoke:launch / apply-update / 多实例 / 启动检测 -- `**git commit**`: `docs(launcher): document module boundaries and add architecture tests` - ---- - -## 5. Phase / Service 测试矩阵 - - -| 组件 | 测试文件 | 覆盖点 | -| ----------------------- | ---------------------------- | --------------------------------- | -| `StartupSuccessTracker` | `StartupSuccessTrackerTests` | Foreground/Tray/Background policy | -| `HostStartupMonitor` | `HostStartupMonitorTests` | 超时、IPC 延迟、ShellStatus 轮询 | -| `LaunchPipeline` | `LaunchPipelineTests` | Phase 短路、失败传播 | -| 各 `ILaunchPhase` | `*PhaseTests` | 单阶段 mock | -| `PendingUpdateDetector` | `PendingUpdateDetectorTests` | 无 pending / corrupt | -| `DeploymentActivator` | `DeploymentActivatorTests` | 标记文件状态机 | -| `RollbackStrategy` | `RollbackStrategyTests` | 快照回退 | -| 命名空间规则 | `LauncherArchitectureTests` | 无 Avalonia 泄漏 | - - ---- - -## 6. 明确不做 - -- 不新建 csproj(Launcher.Deployment 等) -- 不新建 exe / Windows Service -- 不改变 Public IPC / Coordinator IPC 协议 -- 不把插件市场安装迁回 Launcher -- 不为模块间通信引入新 IPC(仅保留现有 Host↔Launcher 契约) - ---- - -## 7. 风险与缓解 - - -| 风险 | 缓解 | -| --------------- | ------------------------------------------------------------------ | -| 大规模移动 merge 冲突 | B1 独立 commit,零逻辑变更 | -| Pipeline 迁移行为回归 | 先写 Phase 级测试再迁代码;保留 `LMD_LAUNCHER_LEGACY_COORDINATOR=1` 开关一个版本(可选) | -| AOT + DI | 显式注册,禁止反射扫描;`PublishAot` CI 步骤验证 | -| Update 拆分遗漏路径 | CLI `update *` 与 GUI apply-update 同一 `IUpdateEngine` 门面 | - - ---- - -## 8. Git 工作流(Agent 自主提交) - -**原则:** 每个 Phase 验证通过后立即提交;不累积巨型 uncommitted diff。 - -**Commit 前检查(每个 commit 必做):** - -```bash -dotnet build LanMountainDesktop.slnx -c Debug -dotnet test LanMountainDesktop.slnx -c Debug --filter "FullyQualifiedName~Launcher" -``` - -**Commit message 风格(与仓库一致):** - -``` -refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline - -Pipeline + Phase pattern; LauncherOrchestrator becomes GUI entry. -No deployment or IPC contract changes. -``` - -**禁止:** `git push --force`、修改 git config、跳过 hooks(除非 hook 失败需修复后新 commit)。 - -**建议分支:** `refactor/launcher-internal-modularization`(单 long-lived 分支,按 Phase 连续 commit;或每 Phase 一个 PR 由用户决定 merge 时机)。 - ---- - -## 9. 整体完成定义(Definition of Done) - -- 无 `LauncherFlowCoordinator` 源文件 -- `App.axaml.cs` ≤120 行,仅 Avalonia + Orchestrator 委托 -- `UpdateEngineService` 巨型文件已替换为 Facade + Strategies -- 职责域目录就位,架构测试通过 -- 全量 Launcher 相关测试 + AOT publish smoke 通过 -- 安装包结构与 IPC 拓扑与重构前一致 -- 每个 Phase 有对应 Git commit,工作区 clean - diff --git a/.cursor/skills/.gitkeep b/.cursor/skills/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/.cursor/skills/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.gitignore b/.gitignore index 8f095ce..45c35ff 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json deleted file mode 100644 index ae2321a..0000000 --- a/.kilo/package-lock.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/.kilo/plans/1776989126427-witty-island.md b/.kilo/plans/1776989126427-witty-island.md deleted file mode 100644 index 477cdf0..0000000 --- a/.kilo/plans/1776989126427-witty-island.md +++ /dev/null @@ -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` diff --git a/CODE_WIKI.md b/CODE_WIKI.md deleted file mode 100644 index 3178583..0000000 --- a/CODE_WIKI.md +++ /dev/null @@ -1,1577 +0,0 @@ -# LanMountainDesktop Code Wiki - -> 本文档是 LanMountainDesktop(阑山桌面)项目的结构化 Code Wiki,涵盖项目整体架构、主要模块职责、关键类与函数说明、依赖关系以及项目运行方式等关键信息。 -> -> 生成日期:2026-06-02 -> 技术基线:Avalonia 12.0.3 + .NET 10 -> Plugin SDK API 基线:5.0.0 - ---- - -## 目录 - -1. [项目概述](#1-项目概述) -2. [整体架构](#2-整体架构) -3. [项目结构与模块职责](#3-项目结构与模块职责) -4. [关键类与函数说明](#4-关键类与函数说明) -5. [依赖关系](#5-依赖关系) -6. [项目运行方式](#6-项目运行方式) -7. [启动流程详解](#7-启动流程详解) -8. [插件系统架构](#8-插件系统架构) -9. [AirApp 系统架构](#9-airapp-系统架构) -10. [更新与分发系统(Plonds)](#10-更新与分发系统plonds) -11. [数据流与交互模型](#11-数据流与交互模型) -12. [测试体系](#12-测试体系) -13. [附录](#附录) - ---- - -## 1. 项目概述 - -### 1.1 产品定位 - -**阑山桌面(LanMountainDesktop)** 是一款跨平台桌面环境增强工具,基于 Avalonia UI 和 .NET 10 构建。 - -- **产品口号**:你的桌面,不止一面 -- **技术基线**:Avalonia UI 12.0.3 + .NET 10 (net10.0) -- **支持平台**:Windows、Linux、macOS -- **仓库角色**:桌面宿主、插件运行时、Plugin SDK 与共享契约的权威来源 - -### 1.2 目标用户 - -- **学生用户**:课程表、自习监测、计时、天气和日常信息聚合 -- **办公用户**:日历、资讯、最近文档、常用工具入口 -- **效率和美化爱好者**:自由布局、主题切换、插件扩展 -- **中文用户**:本地化界面、农历和节假日等本地语境支持 - -### 1.3 核心能力 - -- **桌面组件系统**:内置组件与扩展组件统一注册、统一放置约束 -- **插件系统**:宿主加载插件、整合设置页、组件与市场安装流 -- **外观系统**:主题、玻璃层级、圆角与颜色资源统一管理 -- **设置系统**:独立设置窗口、设置页注册与分域持久化 -- **AirApp 系统**:独立进程运行的轻量应用(时钟、白板等),通过 IPC 与宿主通信 -- **更新与分发**:Plonds 分发系统,支持增量更新、签名验证与回滚 -- **跨平台运行**:基于 Avalonia 的桌面宿主运行在 Windows、Linux、macOS - -### 1.4 生态边界 - -| 仓库 | 职责 | -|------|------| -| `LanMountainDesktop`(本仓库) | 宿主代码、插件运行时、SDK、共享契约、主题与设置基础设施 | -| `LanAirApp`(兄弟仓库) | 插件市场元数据、开发者生态材料 | -| `LanMountainDesktop.SamplePlugin` | 官方示例插件实现 | - ---- - -## 2. 整体架构 - -### 2.1 架构分层 - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ 用户界面层 (UI Layer) │ -│ Views/ │ ViewModels/ │ Theme/ │ Styles/ │ Localization/ │ -├─────────────────────────────────────────────────────────────────────┤ -│ 业务服务层 (Service Layer) │ -│ Services/ │ ComponentSystem/ │ DesktopEditing/ │ plugins/ │ -├─────────────────────────────────────────────────────────────────────┤ -│ 基础设施层 (Infrastructure) │ -│ DesktopHost/ │ Appearance/ │ Settings.Core/ │ Shared.IPC/ │ -├─────────────────────────────────────────────────────────────────────┤ -│ 抽象与契约层 (Abstractions) │ -│ Host.Abstractions/ │ Shared.Contracts/ │ PluginSdk/ │ -├─────────────────────────────────────────────────────────────────────┤ -│ 启动与更新层 (Launcher & Update) │ -│ LanMountainDesktop.Launcher/ │ Plonds (分发系统) │ -├─────────────────────────────────────────────────────────────────────┤ -│ AirApp 层 (独立进程应用) │ -│ AirAppHost/ │ AirAppRuntime/ │ PluginIsolation/ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 核心设计原则 - -1. **插件优先**:核心功能通过插件扩展,宿主提供运行时和基础设施 -2. **组件化桌面**:所有桌面元素都是组件,统一注册、统一放置 -3. **设置分域**:App / Launcher / ComponentInstance / Plugin 四级设置作用域 -4. **主题动态化**:支持 Material Design 3 动态配色、系统主题跟随 -5. **进程隔离预留**:当前为进程内加载,预留了隔离进程架构(PluginIsolation) -6. **AirApp 独立进程**:轻量应用在独立进程中运行,通过 IPC 与宿主通信 -7. **圆角统一**:桌面组件根容器必须使用 `DesignCornerRadiusComponent` 动态资源 - -### 2.3 进程模型 - -``` -┌──────────────────────────────────────────────────────┐ -│ Launcher 进程 │ -│ OOBE → Splash → 更新检查 → 启动主程序 │ -└──────────────────┬───────────────────────────────────┘ - │ IPC -┌──────────────────▼───────────────────────────────────┐ -│ 主宿主进程 (Desktop) │ -│ ┌────────────────────────────────────────────────┐ │ -│ │ UI 线程: MainWindow / Settings / Components │ │ -│ ├────────────────────────────────────────────────┤ │ -│ │ 插件运行时: PluginLoadContext (进程内) │ │ -│ ├────────────────────────────────────────────────┤ │ -│ │ IPC 服务端: PublicIpcHostService │ │ -│ └────────────────────────────────────────────────┘ │ -└──────────────────┬───────────────────────────────────┘ - │ IPC -┌──────────────────▼───────────────────────────────────┐ -│ AirAppRuntime 进程 │ -│ 管理多个 AirApp 实例的生命周期 │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ ClockAirApp │ │ WhiteboardApp│ ... │ -│ └──────────────┘ └──────────────┘ │ -└──────────────────────────────────────────────────────┘ -``` - ---- - -## 3. 项目结构与模块职责 - -### 3.1 解决方案项目列表 - -| 项目路径 | 输出类型 | 主要职责 | -|---------|---------|---------| -| `LanMountainDesktop/` | WinExe | 主桌面宿主应用,包含 UI、服务、组件系统、插件运行时接入 | -| `LanMountainDesktop.Launcher/` | WinExe | 启动器 — 负责 OOBE、Splash、版本管理、增量更新、插件安装 | -| `LanMountainDesktop.PluginSdk/` | Library (NuGet) | 官方插件 SDK v5,定义插件可依赖的公开接口与打包行为 | -| `LanMountainDesktop.Shared.Contracts/` | Library | 宿主与插件共享的稳定契约类型(版本信息、更新协议、IPC 常量等) | -| `LanMountainDesktop.Shared.IPC/` | Library | 统一 IPC 基础,用于 Host 公共服务、Launcher/OOBE 启动通知、插件贡献的公共服务 | -| `LanMountainDesktop.Appearance/` | Library | 主题、圆角、外观资源相关基础设施 | -| `LanMountainDesktop.Settings.Core/` | Library | 设置域、持久化和设置基础抽象 | -| `LanMountainDesktop.DesktopHost/` | Library | 桌面宿主流程与生命周期相关逻辑(引导、壳层、关机协调) | -| `LanMountainDesktop.DesktopComponents.Runtime/` | Library | 组件运行时支撑能力 | -| `LanMountainDesktop.Host.Abstractions/` | Library | 宿主侧抽象接口 | -| `LanMountainDesktop.PluginIsolation.Contracts/` | Library | 插件隔离机制的传输无关 DTO、路由常量、错误码 | -| `LanMountainDesktop.PluginIsolation.Ipc/` | Library | 插件隔离 IPC 外观,基于 dotnetCampus.Ipc | -| `LanMountainDesktop.PluginPackaging/` | Library | 插件打包与安装工具 | -| `LanMountainDesktop.PluginTemplate/` | Template | `dotnet new lmd-plugin` 官方模板 | -| `LanMountainDesktop.AirAppHost/` | WinExe | AirApp 独立宿主进程,承载 AirApp 窗口 | -| `LanMountainDesktop.AirAppRuntime/` | Library | AirApp 运行时 IPC 宿主,管理 AirApp 实例生命周期 | -| `ThirdParty/DotNetCampus.InkCanvas/` | Library | 第三方墨迹画布控件(白板功能) | -| `LanMountainDesktop.Tests/` | Test | 宿主与 SDK 的测试项目 | -| `PenguinLogisticsOnlineNetworkDistributionSystem/` | Tool | Plonds 分发系统(更新源管理、包验证) | - -### 3.2 主宿主工程内部结构 - -``` -LanMountainDesktop/ -├── Program.cs # 进程启动主线 -├── App.axaml.cs # 应用初始化、主题、语言、托盘、插件运行时 -├── ViewLocator.cs # 视图定位器 -├── Views/ # 界面视图 -│ ├── MainWindow.axaml(.cs) # 主窗口(含多个 partial class) -│ ├── MainWindow.ComponentSystem.cs # 主窗口-组件系统集成 -│ ├── MainWindow.DesktopEditing.cs # 主窗口-桌面编辑 -│ ├── MainWindow.DesktopPaging.cs # 主窗口-桌面分页 -│ ├── MainWindow.RenderBackend.cs # 主窗口-渲染后端 -│ ├── SettingsWindow.axaml(.cs) # 设置窗口 -│ ├── ComponentEditorWindow.axaml(.cs) # 组件编辑器窗口 -│ ├── ComponentLibraryWindow.axaml # 组件库窗口 -│ ├── DesktopWidgetWindow.axaml(.cs) # 桌面小部件窗口 -│ ├── NotificationWindow.axaml(.cs) # 通知窗口 -│ ├── NotificationDialogWindow.axaml # 对话框通知窗口 -│ ├── UpdateProgressDialog.axaml(.cs) # 更新进度对话框 -│ ├── StudySessionReportWindow.axaml # 自习报告窗口 -│ └── Components/ # 桌面组件视图 -│ ├── ClockWidget.axaml(.cs) # 时钟组件 -│ ├── DateWidget.axaml(.cs) # 日期组件 -│ ├── TimerWidget.axaml(.cs) # 计时器组件 -│ ├── WeatherWidget.axaml # 天气组件 -│ ├── ShortcutWidget.axaml # 快捷方式组件 -│ ├── DailyNewsView.axaml # 每日新闻组件 -│ ├── JuyaNewsWidget.axaml # 聚雅新闻组件 -│ ├── BrowserWidget.axaml # 浏览器组件 -│ └── WeatherIconView.cs # 天气图标视图 -├── ViewModels/ # 视图模型 -│ ├── ViewModelBase.cs # MVVM 基类 -│ ├── MainWindowViewModel.cs # 主窗口 VM -│ ├── SettingsViewModels.cs # 设置 VM -│ ├── MusicControlViewModel.cs # 音乐控制 VM -│ ├── NotificationViewModel.cs # 通知 VM -│ ├── PrivacyPolicyViewModel.cs # 隐私策略 VM -│ ├── ShortcutEditorViewModel.cs # 快捷键编辑 VM -│ ├── UpdateProgressViewModel.cs # 更新进度 VM -│ └── UpdateSettingsViewModel.cs # 更新设置 VM -├── Services/ # 业务服务层 -│ ├── Settings/ # 设置相关服务 -│ │ └── SettingsService.cs # 设置核心服务 -│ ├── Plonds/ # Plonds 分发服务 -│ │ ├── IPlondsService.cs # Plonds 服务接口 -│ │ ├── PlondsService.cs # Plonds 服务实现 -│ │ ├── PlondsPackageStore.cs # 包存储 -│ │ ├── PlondsSourceStore.cs # 源存储 -│ │ └── PlondsVerifier.cs # 签名验证 -│ ├── Update/ # 更新服务 -│ │ ├── UpdateOrchestrator.cs # 更新编排器 -│ │ ├── UpdateStateStore.cs # 更新状态存储 -│ │ ├── UpdatePathGuard.cs # 更新路径守卫 -│ │ ├── RollbackStrategy.cs # 回滚策略 -│ │ └── ResumableDownloadService.cs # 断点续传下载 -│ ├── AirAppLauncherService.cs # AirApp 启动服务 -│ ├── AppDataPathProvider.cs # 应用数据路径 -│ ├── AppDatabaseService.cs # 数据库服务 -│ ├── AppLogger.cs # 日志服务 -│ ├── AppRestartService.cs # 应用重启服务 -│ ├── AppSettingsService.cs # 应用设置服务 -│ ├── AppearanceThemeService.cs # 外观主题服务 -│ ├── AttendanceDataStore.cs # 考勤数据存储 -│ ├── CalculatorDataService.cs # 计算器服务 -│ ├── ComponentLibraryServices.cs # 组件库服务 -│ ├── ComponentSettingsService.cs # 组件设置服务 -│ ├── CurrentUserProfileService.cs # 用户档案服务 -│ ├── DataStorageService.cs # 数据存储服务 -│ ├── DesktopGridLayoutService.cs # 桌面网格布局服务 -│ ├── DesktopTrayService.cs # 桌面托盘服务 -│ ├── FontFamilyService.cs # 字体服务 -│ ├── FusedDesktopLayoutService.cs # 融合桌面布局服务 -│ ├── GlassEffectService.cs # 毛玻璃效果服务 -│ ├── HolidayCalendarService.cs # 节假日日历服务 -│ ├── HostShutdownGate.cs # 宿主关闭门 -│ ├── LauncherSettingsService.cs # 启动器设置服务 -│ ├── LocalizationService.cs # 本地化服务 -│ ├── LocationService.cs # 定位服务 -│ ├── LunarCalendarService.cs # 农历服务 -│ ├── MaterialColorService.cs # Material 颜色服务 -│ ├── MaterialSurfaceService.cs # Material 表面服务 -│ ├── MonetColorService.cs # Monet 配色服务 -│ ├── NotificationService.cs # 通知服务 -│ ├── PowerManagementService.cs # 电源管理服务 -│ ├── RecommendationDataService.cs # 推荐数据服务 -│ ├── SettingsSearchService.cs # 设置搜索服务 -│ ├── ShortcutHelper.cs # 快捷方式辅助 -│ ├── StudyAnalyticsService.cs # 学习分析服务 -│ ├── SystemWallpaperProvider.cs # 系统壁纸提供者 -│ ├── TelemetryServices.cs # 遥测服务 -│ ├── ThemeColorSystemService.cs # 主题颜色系统服务 -│ ├── TimeZoneService.cs # 时区服务 -│ ├── UiExceptionGuard.cs # UI 异常保护 -│ ├── WallpaperColorPipeline.cs # 壁纸颜色管线 -│ ├── WeatherIconAssetResolver.cs # 天气图标资源解析 -│ ├── WebView2RuntimeProbe.cs # WebView2 运行时探测 -│ ├── WindowMaterialService.cs # 窗口材质服务 -│ ├── WindowPassthroughService.cs # 窗口穿透服务 -│ ├── WindowsStartMenuService.cs # Windows 开始菜单服务 -│ ├── WindowsStartupService.cs # Windows 开机启动服务 -│ └── XiaomiWeatherService.cs # 小米天气服务 -├── ComponentSystem/ # 组件系统 -│ └── ComponentRegistry.cs # 组件注册表 -├── DesktopEditing/ # 桌面布局编辑 -│ └── DesktopEditSession.cs # 桌面编辑会话 -├── plugins/ # 插件运行时 -│ ├── LoadedPlugin.cs # 已加载插件 -│ ├── PluginLoadContext.cs # 插件程序集加载上下文 -│ ├── PluginExportRegistry.cs # 插件导出注册表 -│ ├── PluginContributions.cs # 插件贡献点定义 -│ ├── PluginCatalogEntry.cs # 插件目录条目 -│ └── DevPluginOptions.cs # 开发插件选项 -├── Controls/ # 自定义控件 -│ ├── GridPreviewControl.cs # 网格预览控件 -│ ├── IconText.axaml(.cs) # 图标文本控件 -│ ├── SettingsOptionCard.axaml(.cs) # 设置选项卡片 -│ ├── SettingsSectionCard.axaml # 设置节卡片 -│ └── SmoothBorder.cs # 平滑边框 -├── Converters/ # 值转换器 -│ ├── HexToBrushConverter.cs # 十六进制转画刷 -│ └── HexToColorConverter.cs # 十六进制转颜色 -├── Models/ # 数据模型 -│ ├── AppSettingsSnapshot.cs # 应用设置快照 -│ ├── LauncherSettingsSnapshot.cs # 启动器设置快照 -│ ├── ComponentSettingsSnapshot.cs # 组件设置快照 -│ ├── FusedDesktopLayoutSnapshot.cs # 融合桌面布局快照 -│ ├── NotificationItem.cs # 通知项 -│ ├── TaskbarActionItem.cs # 任务栏操作项 -│ ├── WeatherDataModels.cs # 天气数据模型 -│ ├── MaterialColorModels.cs # Material 颜色模型 -│ ├── MonetPalette.cs # Monet 调色板 -│ ├── StudyAnalyticsModels.cs # 学习分析模型 -│ ├── AttendanceModels.cs # 考勤模型 -│ ├── WhiteboardNoteSnapshot.cs # 白板笔记快照 -│ └── ... # 其他模型 -├── Platform/ # 平台特定代码 -│ └── Windows/ -│ ├── ChromePatchState.cs # 窗口边框补丁状态 -│ └── PatcherEntrance.cs # 补丁入口 -├── Theme/ # 主题资源 -│ ├── AppThemePalette.cs # 应用主题调色板 -│ ├── ColorMath.cs # 颜色数学工具 -│ ├── FluttermotionToken.cs # Flutter Motion Token -│ └── ThemeColorContext.cs # 主题颜色上下文 -├── Styles/ # 样式规则 -│ ├── FluttermotionToken.axaml # Flutter Motion Token 样式 -│ ├── GlassModule.axaml # 毛玻璃模块样式 -│ ├── NavigationStyles.axaml # 导航样式 -│ ├── SettingsAnimations.axaml # 设置动画 -│ └── SettingsCardStyles.axaml # 设置卡片样式 -├── Localization/ # 本地化资源 -│ ├── zh-CN.json # 简体中文 -│ ├── en-US.json # 英文 -│ ├── ja-JP.json # 日文 -│ └── ko-KR.json # 韩文 -└── Assets/ # 静态资源 - ├── Documents/ # 文档资源 - ├── Fonts/ # 字体文件 (MiSans-VF) - ├── MaterialWeatherIcons/ # Material 天气图标 - └── endfiled/ # 表情图片资源 -``` - -### 3.3 Launcher 工程结构 - -``` -LanMountainDesktop.Launcher/ -├── Program.cs # 启动器入口(CLI 命令解析 + GUI 启动) -├── App.axaml.cs # 启动器应用初始化 -├── CommandContext.cs # 命令上下文解析 -├── LauncherRuntimeContext.cs # 启动器运行时上下文 -├── AppJsonContext.cs # JSON 序列化上下文 -├── GlobalUsings.cs # 全局 using -├── Deployment/ # 部署相关 -│ └── HostLaunchPlan.cs # 宿主启动计划 -├── Infrastructure/ # 基础设施 -│ ├── Commands.cs # 命令处理 -│ └── Logger.cs # 日志 -├── Models/ # 数据模型 -│ ├── DataLocationModels.cs # 数据位置模型 -│ ├── LauncherResult.cs # 启动结果 -│ ├── OobeStateModels.cs # OOBE 状态模型 -│ ├── PrivacyConfig.cs # 隐私配置 -│ ├── ReleaseInfo.cs # 发布信息 -│ └── UpdateModels.cs # 更新模型 -├── Oobe/ # 首次体验引导 -│ ├── IOobeStep.cs # OOBE 步骤接口 -│ ├── OobeStateService.cs # OOBE 状态服务 -│ ├── WelcomeOobeStep.cs # 欢迎步骤 -│ └── DataLocationOobeStep.cs # 数据位置步骤 -├── Shell/ # 壳层服务 -│ ├── AirAppRuntimeBridge.cs # AirApp 运行时桥接 -│ ├── LaunchUiPresenter.cs # 启动 UI 展示 -│ └── ThemeService.cs # 主题服务 -├── Startup/ # 启动流程 -│ ├── ExistingHostProbe.cs # 已有宿主探测 -│ ├── HostLaunchModels.cs # 宿主启动模型 -│ ├── HostLaunchService.cs # 宿主启动服务 -│ └── LaunchPipeline.cs # 启动管线 -├── ViewModels/ -│ └── RelayCommand.cs # 命令绑定 -├── Views/ # 视图 -│ ├── OobeWindow.axaml(.cs) # OOBE 窗口 -│ ├── SplashWindow.axaml(.cs) # 启动动画窗口 -│ ├── UpdateWindow.axaml(.cs) # 更新窗口 -│ ├── ErrorWindow.axaml(.cs) # 错误窗口 -│ ├── ErrorDebugWindow.axaml # 错误调试窗口 -│ └── DevDebugWindow.axaml # 开发调试窗口 -└── Resources/ # 资源 - ├── Strings.cs # 本地化字符串 - ├── Strings.resx # 默认语言 - ├── Strings.en-US.resx # 英文 - ├── Strings.ja-JP.resx # 日文 - └── Strings.ko-KR.resx # 韩文 -``` - -### 3.4 AirApp 工程结构 - -``` -LanMountainDesktop.AirAppHost/ -├── Program.cs # AirApp 宿主进程入口 -├── AirApp.axaml(.cs) # AirApp 应用定义 -├── AirAppWindow.axaml(.cs) # AirApp 窗口(IPC 通信 + 窗口管理) -├── AirAppLaunchOptions.cs # 启动选项 -├── AirAppWindowChromeMode.cs # 窗口边框模式 -├── AirAppWindowDescriptor.cs # 窗口描述符 -├── ClockAirAppView.axaml(.cs) # 时钟 AirApp 视图 -└── WorldClockAirAppView.axaml # 世界时钟 AirApp 视图 - -LanMountainDesktop.AirAppRuntime/ -├── Program.cs # AirApp 运行时入口 -├── AirAppRuntimeIpcHost.cs # IPC 宿主服务 -├── AirAppHostLocator.cs # AirApp 宿主定位 -├── AirAppInstanceKey.cs # 实例标识 -├── AirAppRuntimeLogger.cs # 运行时日志 -└── AirAppRuntimeOptions.cs # 运行时选项 -``` - ---- - -## 4. 关键类与函数说明 - -### 4.1 应用程序入口与生命周期 - -#### `Program`(LanMountainDesktop/Program.cs) - -**职责**:应用程序入口点,负责启动初始化、渲染模式配置、全局异常日志、遥测初始化。 - -**关键属性**: - -```csharp -internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default; -``` - -**关键方法**: - -| 方法 | 签名 | 说明 | -|------|------|------| -| `Main` | `public static void Main(string[] args)` | 应用入口,初始化日志、数据路径、开发插件选项、遥测,构建 Avalonia AppBuilder | -| `BuildAvaloniaApp` | `public static AppBuilder BuildAvaloniaApp(string renderMode)` | 构建 Avalonia 应用,配置 Win32 渲染模式 | -| `LoadConfiguredRenderMode` | `private static string LoadConfiguredRenderMode()` | 从设置加载配置的渲染模式 | -| `LoadChromePatchState` | `private static void LoadChromePatchState()` | 加载窗口边框补丁状态 | -| `InstallChromePatchersIfNeeded` | `private static void InstallChromePatchersIfNeeded()` | 安装窗口边框补丁(仅 Windows x64/x86) | -| `RegisterGlobalExceptionLogging` | `private static void RegisterGlobalExceptionLogging()` | 注册全局未处理异常日志和遥测 | -| `InitializeTelemetryIdentity` | `private static void InitializeTelemetryIdentity()` | 初始化遥测身份 | -| `InitializeCrashTelemetry` | `private static void InitializeCrashTelemetry()` | 初始化 Sentry 崩溃遥测 | -| `InitializeUsageTelemetry` | `private static void InitializeUsageTelemetry()` | 初始化 PostHog 使用遥测 | - -#### `App`(LanMountainDesktop/App.axaml.cs) - -**职责**:应用启动和生命周期管理,包含应用初始化、主窗口管理、插件运行时初始化、主题设置、设置系统初始化。 - -**关键方法**: - -| 方法 | 签名 | 说明 | -|------|------|------| -| `Initialize` | `public override void Initialize()` | 初始化应用资源、主题、语言、设置服务 | -| `OnFrameworkInitializationCompleted` | `public override void OnFrameworkInitializationCompleted()` | 框架初始化完成后调用,初始化 IPC、桌面壳层 | -| `InitializeDesktopShell` | `private void InitializeDesktopShell()` | 初始化桌面壳层,包括插件运行时、托盘、主窗口 | -| `OpenIndependentSettingsModule` | `internal void OpenIndependentSettingsModule(string source, string? pageTag)` | 打开独立设置窗口 | -| `ActivateMainWindow` | `internal void ActivateMainWindow()` | 激活主窗口 | - -#### `DesktopBootstrap`(LanMountainDesktop.DesktopHost/DesktopBootstrap.cs) - -**职责**:桌面启动引导,协调启动服务初始化和应用初始化。 - -```csharp -public static class DesktopBootstrap -{ - public static void InitializeStartupServices( - Action initializeTelemetryIdentity, - Action initializeCrashTelemetry, - Action initializeUsageTelemetry, - Action scheduleStartupCleanup); - - public static void InitializeApplication(Application application, Action initializeShell); -} -``` - -#### `DesktopShellHost`(LanMountainDesktop.DesktopHost/DesktopShellHost.cs) - -**职责**:桌面壳层宿主,管理桌面壳层的初始化和生命周期。 - -#### `ShutdownCoordinator`(LanMountainDesktop.DesktopHost/ShutdownCoordinator.cs) - -**职责**:关机协调器,协调各模块的关闭顺序。 - -### 4.2 插件系统 - -#### `LoadedPlugin`(LanMountainDesktop/plugins/LoadedPlugin.cs) - -**职责**:表示一个已加载的插件,包含其元数据、程序集和托管生命周期(含释放逻辑)。 - -#### `PluginLoadContext`(LanMountainDesktop/plugins/PluginLoadContext.cs) - -**职责**:自定义程序集加载上下文,负责解析和加载插件程序集及其依赖项,提供程序集隔离。 - -#### `PluginExportRegistry`(LanMountainDesktop/plugins/PluginExportRegistry.cs) - -**职责**:维护插件服务导出注册表,提供查询和管理插件间服务集成的方法。 - -#### `PluginContributions`(LanMountainDesktop/plugins/PluginContributions.cs) - -**职责**:定义插件向系统贡献的内容记录,包括设置页、组件和编辑器贡献。 - -#### `PluginCatalogEntry`(LanMountainDesktop/plugins/PluginCatalogEntry.cs) - -**职责**:表示插件目录中的条目,包含清单数据、加载状态和插件能力信息。 - -#### `DevPluginOptions`(LanMountainDesktop/plugins/DevPluginOptions.cs) - -**职责**:解析和管理开发模式下的插件设置和命令行参数。 - -### 4.3 Plugin SDK 接口 - -#### `IPlugin`(LanMountainDesktop.PluginSdk/IPlugin.cs) - -**职责**:插件核心接口,定义插件的初始化方法。 - -```csharp -public interface IPlugin -{ - void Initialize(HostBuilderContext context, IServiceCollection services); -} -``` - -#### `PluginBase`(LanMountainDesktop.PluginSdk/PluginBase.cs) - -**职责**:插件抽象基类,提供默认实现。 - -```csharp -public abstract class PluginBase : IPlugin -{ - public virtual void Initialize(HostBuilderContext context, IServiceCollection services) { } -} -``` - -#### `IPluginWorker` / `PluginWorkerBase` - -**职责**:定义插件后台工作线程的生命周期。 - -```csharp -public interface IPluginWorker -{ - void ConfigureServices(IServiceCollection services); - Task StartAsync(IPluginWorkerContext context, CancellationToken cancellationToken); - Task StopAsync(CancellationToken cancellationToken); -} -``` - -#### `IPluginRuntimeContext`(LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs) - -**职责**:插件运行时上下文,提供访问插件清单、目录、服务提供者和外观上下文。 - -```csharp -public interface IPluginRuntimeContext -{ - PluginManifest Manifest { get; } - string PluginDirectory { get; } - IServiceProvider Services { get; } - IPluginAppearanceContext Appearance { get; } -} -``` - -#### `IPluginExportRegistry`(LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs) - -**职责**:插件服务导出注册表接口。 - -```csharp -public interface IPluginExportRegistry -{ - T? GetExport(); - IEnumerable GetExports(); -} -``` - -#### `IPluginMessageBus`(LanMountainDesktop.PluginSdk/IPluginMessageBus.cs) - -**职责**:插件间通信的消息总线机制。 - -```csharp -public interface IPluginMessageBus -{ - void Publish(T message) where T : class; - IObservable Subscribe() where T : class; -} -``` - -#### `IPluginPackageManager`(LanMountainDesktop.PluginSdk/IPluginPackageManager.cs) - -**职责**:插件包管理器,处理插件的安装、卸载和更新。 - -#### `IPluginPublicIpcBuilder`(LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs) - -**职责**:插件公共 IPC 构建器,用于构建插件间的 IPC 通信。 - -#### `IPluginSettingsService`(LanMountainDesktop.PluginSdk/IPluginSettingsService.cs) - -**职责**:插件设置服务,提供设置项的获取和更新。 - -#### `IPluginAppearanceContext`(LanMountainDesktop.PluginSdk/IPluginAppearanceContext.cs) - -**职责**:插件外观上下文,用于插件与应用程序主题和外观相关的操作。 - -#### `PluginManifest`(LanMountainDesktop.PluginSdk/PluginManifest.cs) - -**职责**:插件清单信息类,包含插件的元数据。 - -```csharp -public sealed record PluginManifest( - string Id, // 插件唯一标识 - string Name, // 插件名称 - string EntranceAssembly, // 入口程序集 - string? Description = null, // 描述 - string? Author = null, // 作者 - string? Version = null, // 版本 - string? ApiVersion = null, // API 版本 - IReadOnlyList? SharedContracts = null, - PluginRuntimeConfiguration? Runtime = null) -``` - -**关键方法**: - -| 方法 | 签名 | 说明 | -|------|------|------| -| `Load` | `public static PluginManifest Load(string manifestPath)` | 从文件加载插件清单 | -| `ResolveEntranceAssemblyPath` | `public string ResolveEntranceAssemblyPath(string manifestPath)` | 解析入口程序集路径 | - -#### `PluginRuntimeMode`(LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs) - -**职责**:定义插件的运行时模式。 - -| 模式 | 说明 | -|------|------| -| `InProc` | 进程内加载(当前默认) | -| `IsolatedBackground` | 后台逻辑移至独立工作进程(预留) | -| `IsolatedWindow` | 插件 UI 离屏渲染(预留) | - -#### `PluginSdkInfo`(LanMountainDesktop.PluginSdk/PluginSdkInfo.cs) - -**职责**:提供插件 SDK 的版本信息和 API 版本信息。 - -#### `ISettingsService` / `ISettingsCatalog` / `SettingsPageBase` - -**职责**:插件设置系统接口,提供设置页注册、设置项读写和分类管理。 - -### 4.4 设置系统 - -#### `SettingsService`(LanMountainDesktop/Services/Settings/SettingsService.cs) - -**职责**:设置系统的核心服务,管理应用和插件的设置数据持久化、读取和保存、设置变更监听。 - -**关键事件**: - -```csharp -public event EventHandler? Changed; -``` - -**关键方法**: - -| 方法 | 签名 | 说明 | -|------|------|------| -| `LoadSnapshot` | `public T LoadSnapshot(SettingsScope scope, string? subjectId = null, string? placementId = null)` | 加载设置快照 | -| `SaveSnapshot` | `public void SaveSnapshot(SettingsScope scope, T snapshot, ...)` | 保存设置快照 | -| `LoadSection` | `public T LoadSection(SettingsScope scope, string subjectId, string sectionId, ...)` | 加载设置节 | -| `SaveSection` | `public void SaveSection(SettingsScope scope, string subjectId, string sectionId, T section, ...)` | 保存设置节 | -| `GetValue` | `public T? GetValue(SettingsScope scope, string key, ...)` | 获取单个值 | -| `SetValue` | `public void SetValue(SettingsScope scope, string key, T value, ...)` | 设置单个值 | -| `GetComponentAccessor` | `public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)` | 获取组件设置访问器 | - -**设置作用域(SettingsScope)**: - -| 作用域 | 说明 | -|--------|------| -| `App` | 应用级设置 | -| `Launcher` | 启动器设置 | -| `ComponentInstance` | 组件实例设置 | -| `Plugin` | 插件设置 | - -#### `AppSettingsService`(LanMountainDesktop/Services/AppSettingsService.cs) - -**职责**:应用全局设置的加载和保存,带缓存机制。 - -#### `LauncherSettingsService`(LanMountainDesktop/Services/LauncherSettingsService.cs) - -**职责**:启动器设置的加载和保存,处理旧设置文件的迁移。 - -### 4.5 外观主题系统 - -#### `IMaterialColorService`(LanMountainDesktop/Services/IMaterialColorService.cs) - -**职责**:材料颜色服务的核心公开接口。 - -```csharp -public interface IMaterialColorService -{ - MaterialColorSnapshot GetColorSnapshot(); - void ApplyThemeResources(IResourceDictionary resources); - AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role); - void ApplyWindowMaterial(Window window, MaterialSurfaceRole role); -} -``` - -#### `MaterialColorService`(LanMountainDesktop/Services/MaterialColorService.cs) - -**职责**:Material Design 3 颜色和材料主题的构建与应用,包括颜色获取、表面材质构建、窗体材质应用。 - -#### `AppearanceThemeService`(LanMountainDesktop/Services/AppearanceThemeService.cs) - -**职责**:外观主题服务的主要实现,委托给 `MaterialColorService` 处理具体逻辑。 - -**关键方法**: - -| 方法 | 签名 | 说明 | -|------|------|------| -| `GetCurrent` | `public AppearanceThemeSnapshot GetCurrent()` | 获取当前主题快照 | -| `BuildPreview` | `public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState)` | 构建主题预览 | -| `ApplyThemeResources` | `public void ApplyThemeResources(IResourceDictionary resources)` | 应用主题资源到资源字典 | -| `GetMaterialSurface` | `public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)` | 获取材质表面配置 | -| `ApplyWindowMaterial` | `public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)` | 应用窗口材质效果 | - -**材质表面角色(MaterialSurfaceRole)**: - -| 角色 | 说明 | -|------|------| -| `WindowBackground` | 窗口背景 | -| `SettingsWindowBackground` | 设置窗口背景 | -| `DockBackground` | 停靠栏背景 | -| `StatusBarBackground` | 状态栏背景 | -| `DesktopComponentHost` | 桌面组件宿主 | -| `StatusBarComponentHost` | 状态栏组件宿主 | -| `OverlayPanel` | 覆盖层面板 | - -#### `ThemeColorSystemService`(LanMountainDesktop/Services/ThemeColorSystemService.cs) - -**职责**:主题颜色的构建和应用,定义资源键并构建不同颜色上下文下的应用主题调色板。 - -#### `GlassEffectService`(LanMountainDesktop/Services/GlassEffectService.cs) - -**职责**:毛玻璃效果的资源应用,将毛玻璃材质应用于 UI 资源。 - -#### `WindowMaterialService`(LanMountainDesktop/Services/WindowMaterialService.cs) - -**职责**:窗口材料的应用,定义可用的系统材料模式并实现对指定窗口应用特定材料类型。 - -### 4.6 桌面组件系统 - -#### `ComponentRegistry`(LanMountainDesktop/ComponentSystem/ComponentRegistry.cs) - -**职责**:组件注册中心,负责组件的注册和管理,包括加载组件插件、注册插件组件以及搜索插件组件。 - -#### `DesktopEditSession`(LanMountainDesktop/DesktopEditing/DesktopEditSession.cs) - -**职责**:桌面编辑会话,支持编辑桌面组件、管理组件状态和完成编辑操作。 - -#### `DesktopGridLayoutService`(LanMountainDesktop/Services/DesktopGridLayoutService.cs) - -**职责**:桌面网格布局的设置与应用,支持获取和设置桌面网格的行数、列数以及自动网格等参数。 - -#### `FusedDesktopLayoutService`(LanMountainDesktop/Services/FusedDesktopLayoutService.cs) - -**职责**:融合桌面布局,通过初始化和应用桌面布局策略,融合不同的桌面网格布局。 - -### 4.7 通知系统 - -#### `NotificationService`(LanMountainDesktop/Services/NotificationService.cs) - -**职责**:通知显示服务,支持普通通知和对话框通知,可根据通知内容展示信息、成功、警告或错误类型的通知,支持自定义图标和按钮。 - -### 4.8 AirApp 系统 - -#### `AirAppLauncherService`(LanMountainDesktop/Services/AirAppLauncherService.cs) - -**职责**:启动 Air 应用的服务,通过 IPC 通信与 AirApp Runtime 交互,支持启动世界时钟和白板等应用。 - -#### `AirAppRuntimeIpcHost`(LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs) - -**职责**:管理 AirApp 的 IPC 通信,处理注册、生命周期和 IPC 服务。 - -#### `AirAppWindow`(LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs) - -**职责**:AirApp 窗口的创建和行为管理,支持不同窗口类型和进程间通信。 - -### 4.9 更新系统 - -#### `UpdateOrchestrator`(LanMountainDesktop/Services/Update/UpdateOrchestrator.cs) - -**职责**:更新编排器,管理软件更新流程,包括检查更新、下载、安装、回滚等阶段。 - -**关键事件**: - -```csharp -public event EventHandler? StateChanged; -public event EventHandler? ProgressChanged; -``` - -**关键方法**: - -| 方法 | 说明 | -|------|------| -| `CheckForUpdateAsync` | 检查更新 | -| `DownloadUpdateAsync` | 下载更新 | -| `ApplyUpdateAsync` | 应用更新 | -| `RollbackAsync` | 回滚更新 | - -### 4.10 Plonds 分发系统 - -#### `IPlondsService` / `PlondsService`(LanMountainDesktop/Services/Plonds/) - -**职责**:Plonds 分发服务接口和实现,管理更新源和包的分发。 - -#### `PlondsPackageStore` / `PlondsSourceStore` - -**职责**:包存储和源存储,管理 Plonds 包和源的持久化。 - -#### `PlondsVerifier` - -**职责**:签名验证,确保分发包的完整性和来源可信。 - -### 4.11 遥测系统 - -#### `TelemetryServices`(LanMountainDesktop/Services/TelemetryServices.cs) - -**职责**:遥测数据收集,负责将日志信息发送到远程服务器并支持客户端标识和上报频率设置。 - -**子服务**: -- `SentryCrashTelemetryService` — 崩溃遥测(基于 Sentry) -- `PostHogUsageTelemetryService` — 使用遥测(基于 PostHog) -- `TelemetryIdentityService` — 遥测身份管理 - -### 4.12 其他关键服务 - -| 服务 | 职责 | -|------|------| -| `LocalizationService` | 本地化和语言设置管理 | -| `IWeatherDataService` / `XiaomiWeatherService` | 天气数据获取(小米天气 API) | -| `IStudyAnalyticsService` / `StudyAnalyticsService` | 学习分析和行为数据收集 | -| `IMusicControlService` | 音乐播放控制 | -| `ICalculatorDataService` / `CalculatorDataService` | 计算器服务 | -| `HolidayCalendarService` | 节假日日历 | -| `LunarCalendarService` | 农历计算 | -| `LocationService` | 地理定位 | -| `PowerManagementService` | 电源管理 | -| `WindowsStartMenuService` | Windows 开始菜单应用枚举 | -| `WindowsStartupService` | Windows 开机启动项管理 | -| `HostShutdownGate` | 宿主关闭门,协调关闭流程 | -| `UiExceptionGuard` | UI 异常保护 | -| `WebView2RuntimeProbe` | WebView2 运行时可用性探测 | -| `AppDataPathProvider` | 应用数据路径提供 | -| `AppDatabaseService` | SQLite 数据库服务 | -| `ResumableDownloadService` | 断点续传下载服务 | -| `WallpaperColorPipeline` | 壁纸颜色提取管线 | -| `MonetColorService` | Monet 动态配色服务 | - -### 4.13 共享契约(Shared.Contracts) - -#### `AppVersionProvider`(LanMountainDesktop.Shared.Contracts/Launcher/AppVersionProvider.cs) - -**职责**:提供多种方法来解析和获取应用程序版本信息,包括从文件、可执行文件、部署目录等。 - -#### `AppVersionInfo` - -```csharp -public record AppVersionInfo(Version Version, string Codename, string FullVersionText); -``` - -#### `LauncherRuntimeMetadata` - -**职责**:从命令行参数、环境变量等中提取运行时元数据,包括包根路径、版本、代号等。 - -#### `HostExitCodes` - -**职责**:定义标准的主机进程退出码。 - -#### `LauncherIpc` - -**职责**:定义启动器 IPC 相关的常量(环境变量、选项名称等)。 - -#### `LoadingState` - -**职责**:定义加载项类型、加载状态以及相关数据结构,用于跟踪启动过程中的加载进度。 - -#### `UpdateManifest` / `UpdateState` / `UpdatePaths` / `UpdateMessages` - -**职责**:更新协议相关类型,定义更新清单、状态、路径和消息格式。 - -#### `AppearanceCornerRadiusTokens` - -**职责**:外观圆角样式标记,提供统一的 UI 圆角设计 Token。 - -### 4.14 共享 IPC(Shared.IPC) - -#### `IAirAppLifecycleService`(LanMountainDesktop.Shared.IPC/Abstractions/Services/) - -**职责**:AirApp 生命周期管理服务契约。 - -```csharp -public interface IAirAppLifecycleService -{ - Task OpenAsync(AirAppInstanceKey key, AirAppWindowDescriptor descriptor); - Task ActivateAsync(AirAppInstanceKey key); - Task RegisterAsync(AirAppInstanceKey key); - Task UnregisterAsync(AirAppInstanceKey key); - Task CloseAsync(AirAppInstanceKey key); -} -``` - -#### `PublicIpcHostService` - -**职责**:公共 IPC 宿主服务,管理 IPC 会话和路由通知。 - -#### `IpcConstants` / `IpcRoutedNotifyIds` - -**职责**:IPC 常量和路由通知 ID 定义。 - -#### `PublicAppInfoSnapshot` / `PublicPluginDescriptor` - -**职责**:公共应用信息快照和插件描述符。 - ---- - -## 5. 依赖关系 - -### 5.1 项目间依赖图 - -``` -LanMountainDesktop (主程序) -├── LanMountainDesktop.Host.Abstractions -├── LanMountainDesktop.Shared.Contracts -├── LanMountainDesktop.Shared.IPC -├── LanMountainDesktop.Settings.Core -├── LanMountainDesktop.Appearance -├── LanMountainDesktop.DesktopComponents.Runtime -├── LanMountainDesktop.DesktopHost -├── LanMountainDesktop.PluginPackaging -├── LanMountainDesktop.PluginSdk -└── ThirdParty/DotNetCampus.InkCanvas - -LanMountainDesktop.Launcher (启动器) -├── LanMountainDesktop.Shared.Contracts -├── LanMountainDesktop.Shared.IPC -└── LanMountainDesktop.PluginPackaging - -LanMountainDesktop.PluginSdk (插件 SDK) -├── LanMountainDesktop.PluginIsolation.Contracts -├── LanMountainDesktop.Shared.Contracts -├── LanMountainDesktop.Shared.IPC -└── NuGet: Avalonia, FluentAvaloniaUI, FluentIcons, DI Abstractions, dotnetCampus.Ipc - -LanMountainDesktop.DesktopHost -├── LanMountainDesktop.Host.Abstractions -└── LanMountainDesktop.Shared.Contracts - -LanMountainDesktop.Appearance -├── LanMountainDesktop.Settings.Core -└── LanMountainDesktop.Shared.Contracts - -LanMountainDesktop.DesktopComponents.Runtime -├── LanMountainDesktop.Host.Abstractions -└── LanMountainDesktop.Shared.Contracts - -LanMountainDesktop.Settings.Core -└── LanMountainDesktop.Shared.Contracts - -LanMountainDesktop.Host.Abstractions -└── LanMountainDesktop.Shared.Contracts - -LanMountainDesktop.Shared.IPC -└── LanMountainDesktop.Shared.Contracts - -LanMountainDesktop.PluginIsolation.Ipc -├── LanMountainDesktop.PluginIsolation.Contracts -└── LanMountainDesktop.Shared.IPC - -LanMountainDesktop.PluginIsolation.Contracts -└── (无项目引用) - -LanMountainDesktop.AirAppHost -└── LanMountainDesktop.Shared.Contracts - -LanMountainDesktop.AirAppRuntime -└── LanMountainDesktop.Shared.Contracts -``` - -### 5.2 主要 NuGet 依赖 - -| 包名 | 版本 | 用途 | -|------|------|------| -| Avalonia | 12.0.3 | 跨平台 UI 框架 | -| Avalonia.Controls.WebView | 12.0.1 | WebView 控件 | -| Avalonia.Desktop | 12.0.3 | 桌面平台支持 | -| Avalonia.Themes.Fluent | 12.0.3 | Fluent 主题 | -| Avalonia.Fonts.Inter | 12.0.3 | Inter 字体 | -| FluentAvaloniaUI | 3.0.0-preview4 | Fluent UI 控件库 | -| FluentIcons.Avalonia | 2.1.325 | Fluent 图标 | -| Material.Avalonia | 3.17.0 | Material Design 控件 | -| Material.Icons.Avalonia | 3.0.3-nightly.0.2 | Material 图标 | -| MaterialColorUtilities | 0.3.0 | Material Design 3 动态配色 | -| CommunityToolkit.Mvvm | 8.4.2 | MVVM 工具包 | -| Microsoft.Extensions.DependencyInjection | 11.0.0-preview | 依赖注入 | -| Microsoft.Extensions.Hosting.Abstractions | 11.0.0-preview | 宿主抽象 | -| Microsoft.Data.Sqlite | 11.0.0-preview | SQLite 数据库 | -| PostHog | 2.7.1 | 使用遥测 | -| Sentry | 6.5.0 | 崩溃遥测 | -| Downloader | 5.4.0 | 文件下载 | -| Lib.Harmony.Thin | 2.4.2 | 运行时方法拦截 | -| dotnetCampus.Ipc | 2.0.0-alpha436 | 进程间通信 | -| DotNetCampus.AvaloniaInkCanvas | 1.0.1 | 墨迹画布(白板) | -| ClassIsland.Markdown.Avalonia | 12.0.0 | Markdown 渲染 | -| PortAudioSharp2 | 1.0.6 | 音频录制 | -| System.Drawing.Common | 11.0.0-preview | 图像处理 | -| System.Runtime.WindowsRuntime | 5.0.0-preview | WinRT 互操作 | -| Tmds.DBus.Protocol | 0.92.0 | Linux DBus 通信 | -| MudTools.OfficeInterop | 2.0.9 | Office 互操作 | -| YamlDotNet | 17.1.0 | YAML 解析 | -| log4net | 3.3.1 | 日志记录 | - -### 5.3 全局构建配置 - -**Directory.Build.props**: - -```xml -0.0.0-dev -net10.0 -enable -enable -true -``` - -**Directory.Packages.props**:使用中央包版本管理(`ManagePackageVersionsCentrally`),所有 NuGet 包版本在此统一声明。 - ---- - -## 6. 项目运行方式 - -### 6.1 环境准备 - -- 安装 **.NET SDK 10** -- 桌面端建议优先在 Windows 上开发和验证 -- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx` - -### 6.2 常用命令 - -#### 还原与构建 - -```bash -dotnet restore -dotnet build LanMountainDesktop.slnx -c Debug -``` - -#### 运行桌面宿主(开发模式) - -```bash -# 直接运行主程序,跳过 Launcher -dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj -``` - -#### 运行桌面宿主(生产模式) - -```bash -# 先构建 Launcher -dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug - -# 通过 Launcher 启动主程序 -dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch -``` - -#### Launcher 其他命令 - -```bash -# 检查更新 -dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check - -# 安装插件 -dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- plugin install - -# 版本回退 -dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback -``` - -#### 运行测试 - -```bash -dotnet test LanMountainDesktop.slnx -c Debug -``` - -#### 插件本地包生成 - -```powershell -./scripts/Pack-PluginPackages.ps1 -``` - -### 6.3 Linux 录音依赖 - -如果在 Linux 上使用录音机或自习监测相关能力,需要安装音频库: - -```bash -# Debian/Ubuntu -sudo apt install libportaudio2 libasound2 - -# Fedora/RHEL -sudo dnf install portaudio-libs alsa-lib - -# Arch Linux -sudo pacman -S portaudio alsa-lib - -# Alpine Linux -sudo apk add portaudio alsa-lib -``` - ---- - -## 7. 启动流程详解 - -### 7.1 生产环境启动流程(通过 Launcher) - -``` -用户启动 LanMountainDesktop.Launcher.exe - │ - ▼ -Launcher Program.Main() 解析命令上下文 - │ - ├── 旧版插件安装?→ PluginInstallerService → 退出 - ├── 非 GUI 命令?→ RunCliCommandAsync → 退出 - └── GUI 命令?→ 继续 - │ - ▼ -LauncherRuntimeContext 初始化 -LauncherServiceRegistration 注册服务 - │ - ▼ -LaunchPipeline 启动管线 - │ - ├── ExistingHostProbe 探测已有宿主实例 - ├── HostLaunchService 准备宿主启动 - │ - ▼ -首次启动?→ 显示 OOBE 引导(OobeWindow) - │ - ▼ -显示 Splash 启动动画(SplashWindow) - │ - ▼ -检查并应用待处理的更新 - │ - ▼ -启动主程序 app-{version}/LanMountainDesktop.exe - │ - ▼ -清理标记为 .destroy 的旧版本 -``` - -### 7.2 主程序启动流程(LanMountainDesktop.exe) - -``` -Program.cs Main() - │ - ├── 初始化日志(AppLogger.Initialize) - ├── 初始化应用数据路径(AppDataPathProvider.Initialize) - ├── 解析开发插件选项(DevPluginOptions.Parse) - └── 注册全局异常日志 - │ - ▼ -DesktopBootstrap.InitializeStartupServices - │ - ├── 初始化遥测身份(TelemetryIdentityService) - ├── 初始化崩溃遥测(SentryCrashTelemetryService) - ├── 初始化使用遥测(PostHogUsageTelemetryService) - └── 调度白板笔记启动清理 - │ - ▼ -运行启动诊断(StartupDiagnosticsService.Run) - │ - ▼ -加载配置的渲染模式(LoadConfiguredRenderMode) - │ - ▼ -加载窗口边框补丁状态(LoadChromePatchState) - │ - ▼ -安装窗口边框补丁(InstallChromePatchersIfNeeded,仅 Windows x64/x86) - │ - ▼ -构建 Avalonia AppBuilder(BuildAvaloniaApp) - │ - ▼ -进入 App.axaml.cs - │ - ├── 初始化主题(ApplyThemeFromSettings) - ├── 初始化语言(ApplyCurrentCultureFromSettings) - ├── 初始化设置窗口服务 - ├── 初始化天气定位刷新 - └── 初始化通知服务 - │ - ▼ -框架初始化完成(OnFrameworkInitializationCompleted) - │ - ├── 初始化公共 IPC(InitializePublicIpc) - ├── 启动单实例激活监听 - ├── 初始化 Launcher IPC(InitializeLauncherIpcAsync) - └── 初始化桌面壳层(InitializeDesktopShell) - │ - ▼ -桌面壳层初始化 - │ - ├── 初始化插件运行时(InitializePluginRuntime) - ├── 初始化托盘图标(InitializeTrayIcon) - ├── 创建主窗口(CreateAndAssignMainWindow) - └── 启动天气定位刷新 -``` - -### 7.3 版本目录结构 - -``` -安装根目录/ -├── LanMountainDesktop.Launcher.exe ← 唯一入口 -├── app-1.0.0/ ← 版本目录 -│ ├── .current ← 当前版本标记 -│ ├── LanMountainDesktop.exe -│ ├── AirAppHost/ ← AirApp 宿主 -│ └── ... -├── app-1.0.1/ ← 新版本 -│ ├── .partial ← 下载中标记 -│ └── ... -└── .launcher/ ← Launcher 数据 - ├── state/ ← OOBE 状态 - ├── update/incoming/ ← 更新缓存 - └── snapshots/ ← 更新快照 -``` - -**版本标记文件**: -- `.current` — 标记当前使用的版本 -- `.partial` — 标记下载未完成的版本(更新失败时自动清理) -- `.destroy` — 标记待删除的旧版本(下次启动时清理) - ---- - -## 8. 插件系统架构 - -### 8.1 插件生命周期 - -``` -插件包(.laapp) - │ - ▼ -发现阶段(DiscoverCandidates) - │ - ├── 扫描 PluginsDirectory - ├── 解析 plugin.json 清单 - └── 验证 API 版本兼容性 - │ - ▼ -加载阶段(PluginLoadContext 加载程序集) - │ - ├── 注册共享契约 - ├── 加载入口程序集 - ├── 调用 IPlugin.Initialize - └── 收集贡献点(设置页、组件、编辑器) - │ - ▼ -激活阶段 - │ - ├── 注册设置页到设置窗口 - ├── 注册组件到组件系统 - └── 注册编辑器到编辑器系统 - │ - ▼ -运行阶段 - │ - ├── 插件服务通过 DI 容器解析 - ├── 插件通过 IPluginRuntimeContext 访问宿主功能 - └── 插件通过 IPC 与宿主通信 - │ - ▼ -卸载阶段 - │ - ├── 卸载插件程序集 - ├── 清理贡献点 - └── 释放资源 -``` - -### 8.2 插件运行时模式 - -| 模式 | 状态 | 说明 | -|------|------|------| -| `InProc` | 当前默认 | 进程内加载,`PluginLoadContext` 提供程序集隔离 | -| `IsolatedBackground` | 预留 | 后台逻辑移至独立工作进程,Host UI 变为薄 IPC 驱动壳 | -| `IsolatedWindow` | 预留 | 插件 UI 离屏渲染,Host 嵌入平台窗口句柄 | - -### 8.3 插件贡献点 - -插件可以向宿主贡献以下内容: - -1. **设置页(Settings Sections)**:通过 `IPluginSettingsService` 注册自定义设置页 -2. **桌面组件(Desktop Components)**:通过组件贡献点注册可放置的桌面组件 -3. **组件编辑器(Component Editors)**:为组件提供自定义编辑器界面 -4. **公共服务(Public Services)**:通过 `IPluginPublicIpcBuilder` 向外部提供 IPC 服务 -5. **消息订阅(Message Bus)**:通过 `IPluginMessageBus` 进行插件间通信 - -### 8.4 插件目录结构 - -``` -PluginsDirectory/ -├── PluginA/ -│ ├── plugin.json # 插件清单 -│ ├── PluginA.dll # 入口程序集 -│ └── ... # 其他资源 -├── PluginB.laapp # 打包的插件包 -└── ... -``` - -### 8.5 插件清单格式(plugin.json) - -```json -{ - "id": "com.example.my-plugin", - "name": "My Plugin", - "entranceAssembly": "MyPlugin.dll", - "description": "A sample plugin", - "author": "Author Name", - "version": "1.0.0", - "apiVersion": "5.0.0", - "sharedContracts": [], - "runtime": { - "mode": "in-proc" - } -} -``` - ---- - -## 9. AirApp 系统架构 - -### 9.1 概述 - -AirApp 是阑山桌面的轻量独立应用机制。每个 AirApp 在独立进程中运行,通过 IPC 与主宿主通信。 - -### 9.2 架构组件 - -| 组件 | 职责 | -|------|------| -| `AirAppLauncherService` | 宿主侧服务,负责启动 AirApp Runtime 进程 | -| `AirAppRuntimeIpcHost` | Runtime 侧 IPC 宿主,管理 AirApp 实例的注册和生命周期 | -| `AirAppHost` | 独立进程,承载 AirApp 窗口 | -| `AirAppWindow` | AirApp 窗口,支持不同窗口类型和 IPC 通信 | -| `IAirAppLifecycleService` | AirApp 生命周期管理服务契约 | - -### 9.3 AirApp 启动流程 - -``` -宿主 AirAppLauncherService.LaunchAsync() - │ - ▼ -启动 AirAppRuntime 进程 - │ - ▼ -AirAppRuntimeIpcHost 初始化 IPC - │ - ▼ -通过 IPC 请求打开 AirApp 实例 - │ - ▼ -AirAppHost 进程启动,创建 AirAppWindow - │ - ▼ -AirAppWindow 通过 IPC 与宿主通信 -``` - -### 9.4 内置 AirApp - -| AirApp | 视图 | 说明 | -|--------|------|------| -| 时钟 | `ClockAirAppView` | 桌面时钟 | -| 世界时钟 | `WorldClockAirAppView` | 多时区时钟 | -| 白板 | (使用 InkCanvas) | 桌面白板笔记 | - ---- - -## 10. 更新与分发系统(Plonds) - -### 10.1 概述 - -Plonds(Penguin Logistics Online Network Distribution System)是阑山桌面的分发系统,负责更新源管理、包签名验证和增量更新。 - -### 10.2 架构组件 - -| 组件 | 职责 | -|------|------| -| `IPlondsService` / `PlondsService` | 分发服务接口和实现 | -| `PlondsPackageStore` | 包存储管理 | -| `PlondsSourceStore` | 更新源存储 | -| `PlondsVerifier` | 包签名验证 | -| `UpdateOrchestrator` | 更新编排器(检查、下载、安装、回滚) | -| `ResumableDownloadService` | 断点续传下载 | -| `RollbackStrategy` | 回滚策略 | -| `UpdatePathGuard` | 更新路径守卫 | - -### 10.3 更新流程 - -``` -UpdateOrchestrator.CheckForUpdateAsync() - │ - ▼ -PlondsService 获取更新清单 - │ - ▼ -PlondsVerifier 验证签名 - │ - ▼ -UpdateOrchestrator.DownloadUpdateAsync() - │ - ├── ResumableDownloadService 断点续传下载 - └── 进度通知 - │ - ▼ -UpdateOrchestrator.ApplyUpdateAsync() - │ - ├── UpdatePathGuard 保护路径 - ├── 创建版本目录 - ├── 标记 .partial - └── 标记 .current / .destroy - │ - ▼ -如失败 → RollbackStrategy 回滚 -``` - ---- - -## 11. 数据流与交互模型 - -### 11.1 设置流 - -``` -Settings.Core(基础设置能力) - │ - ├── 宿主通过 SettingsFacade 读取和监听设置变化 - ├── 插件通过 IPluginSettingsService 访问设置 - └── 组件通过 IComponentSettingsAccessor 访问设置 -``` - -### 11.2 外观流 - -``` -Appearance(主题和圆角资源) - │ - ├── 宿主在 App.axaml.cs 中应用到资源字典 - ├── MaterialColorService 处理动态配色 - ├── MonetColorService 处理壁纸取色 - ├── WallpaperColorPipeline 从系统壁纸提取颜色 - └── 主题变更通过事件通知所有订阅者 -``` - -### 11.3 组件流 - -``` -ComponentSystem(组件定义、注册、扩展接入) - │ - ├── 内置组件在 Views/Components/ 中定义 - ├── 插件通过贡献点注册扩展组件 - ├── ComponentRegistry 统一管理组件注册 - ├── DesktopEditSession 处理组件放置和布局编辑 - └── DesktopGridLayoutService / FusedDesktopLayoutService 管理网格布局 -``` - -### 11.4 插件流 - -``` -plugins/(宿主侧插件运行时) - │ - ├── .laapp 插件包的发现、安装、替换 - ├── PluginLoadContext 提供程序集隔离 - ├── PluginExportRegistry 管理服务导出 - ├── PluginContributions 收集贡献点 - └── 插件设置页注册到宿主设置窗口 -``` - -### 11.5 IPC 流 - -``` -Shared.IPC(统一 IPC 基础) - │ - ├── Host 公共服务(PublicIpcHostService) - ├── Launcher/OOBE 启动通知 - ├── AirApp 生命周期管理(IAirAppLifecycleService) - ├── 插件贡献的公共服务 - └── 外部集成(External IPC Public API) -``` - -### 11.6 更新流 - -``` -Plonds(分发系统) - │ - ├── PlondsService 管理更新源 - ├── PlondsVerifier 验证包签名 - ├── UpdateOrchestrator 编排更新流程 - └── ResumableDownloadService 断点续传下载 -``` - ---- - -## 12. 测试体系 - -### 12.1 测试项目 - -测试项目 `LanMountainDesktop.Tests/` 覆盖以下方面: - -| 测试类 | 覆盖内容 | -|--------|---------| -| `CornerRadiusStyleTests` | 圆角和外观缩放 | -| `DesktopPlacementMathTests` | 桌面布局数学计算 | -| `DesktopEditCommitMathTests` | 桌面编辑提交计算 | -| `ComponentSettingsServiceTests` | 组件设置服务 | -| `SettingsCatalogServiceTests` | 设置目录服务 | -| `SettingsSearchServiceTests` | 设置搜索服务 | -| `UiExceptionGuardTests` | UI 异常保护 | -| `OobeStateServiceTests` | OOBE 状态服务 | -| `PluginInstallerServiceTests` | 插件安装服务 | -| `PluginManifestRuntimeTests` | 插件清单运行时验证 | -| `PluginRuntimeDataPathTests` | 插件运行时数据路径 | -| `HostShutdownGateTests` | 主机关闭门 | -| `HostStartupMonitorTests` | 宿主启动监控 | -| `HostActivationPolicyTests` | 宿主激活策略 | -| `HostLaunchPlanBuilderTests` | 宿主启动计划构建 | -| `LauncherArchitectureTests` | 启动器架构测试 | -| `LauncherUpdateCommandTests` | 启动器更新命令 | -| `AirAppLauncherServiceTests` | AirApp 启动服务 | -| `ClockAirAppMvpTests` | 时钟 AirApp MVP | -| `MusicControlServiceTests` | 音乐控制服务 | -| `MusicControlViewModelTests` | 音乐控制 VM | -| `StudyAnalyticsServiceTests` | 学习分析服务 | -| `WeatherPreviewDataTests` | 天气预览数据 | -| `ThemeAppearanceValuesTests` | 主题外观值 | -| `SystemChromeModeTests` | 系统边框模式 | -| `PlondsClientServiceTests` | Plonds 客户端服务 | -| `PackagingRuntimePolicyTests` | 打包运行时策略 | -| `ExternalIpcPublicApiTests` | 外部 IPC 公共 API | -| `WindowLayerIsolationTests` | 窗口层隔离 | -| `DotNetRuntimeProbeTests` | .NET 运行时探测 | -| `DeploymentLocatorTests` | 部署定位器 | -| `DataLocationResolverTests` | 数据位置解析器 | -| `AppVersionProviderTests` | 应用版本提供者 | -| `CommandContextTests` | 命令上下文 | -| `StartupSuccessTrackerTests` | 启动成功追踪器 | - -### 12.2 测试原则 - -- 涉及宿主行为、SDK 契约、布局计算或设置持久化的改动,应优先补对应测试 -- 优先扩展已有测试而不是新建无关测试入口 - ---- - -## 附录 A:快速参考 - -### A.1 关键文件速查 - -| 需求 | 优先查看文件 | -|------|-------------| -| 启动问题 | `LanMountainDesktop/Program.cs`, `LanMountainDesktop/App.axaml.cs` | -| Launcher 启动问题 | `LanMountainDesktop.Launcher/Program.cs`, `Startup/LaunchPipeline.cs` | -| 版本管理问题 | `LanMountainDesktop.Shared.Contracts/Launcher/AppVersionProvider.cs` | -| 更新系统问题 | `LanMountainDesktop/Services/Update/UpdateOrchestrator.cs`, `Services/Plonds/PlondsService.cs` | -| 设置窗口和设置页 | `LanMountainDesktop/Views/`, `ViewModels/`, `Services/Settings/` | -| 插件加载与安装 | `LanMountainDesktop/plugins/`, `LanMountainDesktop.PluginSdk/` | -| 组件元数据或放置规则 | `LanMountainDesktop/ComponentSystem/`, `DesktopEditing/` | -| 主题、颜色、圆角 | `LanMountainDesktop/Theme/`, `Styles/`, `LanMountainDesktop.Appearance/` | -| 设置持久化 | `LanMountainDesktop.Settings.Core/`, `LanMountainDesktop/Services/Settings/SettingsService.cs` | -| SDK 接口调整 | `LanMountainDesktop.PluginSdk/`, `LanMountainDesktop.Shared.Contracts/` | -| 桌面壳层或生命周期 | `Program.cs`, `App.axaml.cs`, `LanMountainDesktop.DesktopHost/` | -| AirApp 相关 | `LanMountainDesktop.AirAppHost/`, `LanMountainDesktop.AirAppRuntime/`, `Services/AirAppLauncherService.cs` | -| IPC 通信 | `LanMountainDesktop.Shared.IPC/`, `LanMountainDesktop.PluginIsolation.Ipc/` | -| 圆角规范 | `docs/CORNER_RADIUS_SPEC.md`, `LanMountainDesktop.Appearance/` | - -### A.2 文档权威来源 - -| 主题 | 权威文档 | -|------|---------| -| 产品定位 | `docs/PRODUCT.md` | -| 架构与模块职责 | `docs/ARCHITECTURE.md` | -| 运行、构建、测试、打包 | `docs/DEVELOPMENT.md` | -| 视觉规范 | `docs/VISUAL_SPEC.md` | -| 圆角规范 | `docs/CORNER_RADIUS_SPEC.md` | -| 生态边界 | `docs/ECOSYSTEM_BOUNDARIES.md` | -| SDK v5 迁移 | `docs/PLUGIN_SDK_V5_MIGRATION.md` | -| 代码地图 | `docs/ai/CODEBASE_MAP.md` | -| 文档权威来源 | `docs/ai/DOC_SOURCES.md` | -| AI 协作入口 | `AGENTS.md` | -| Feature 规格 | `.trae/specs/` | - -### A.3 圆角开发准则(AI 强制建议) - -- **桌面组件根容器**:必须且仅能使用 `{DynamicResource DesignCornerRadiusComponent}` -- **内部元素**:必须根据嵌套层级使用 `DesignCornerRadiusSm/Md/Lg` 等 Token -- **禁止硬编码**:严禁硬编码像素值 -- **禁止缩放**:严禁在圆角资源上乘以任何 `scale` 变量 - ---- - -*本文档基于 LanMountainDesktop 仓库代码和文档自动生成,如有更新请以仓库最新代码为准。* diff --git a/CheckIpcAot/CheckIpcAot.csproj b/CheckIpcAot/CheckIpcAot.csproj deleted file mode 100644 index 7e57883..0000000 --- a/CheckIpcAot/CheckIpcAot.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - - - diff --git a/CheckIpcAot/Program.cs b/CheckIpcAot/Program.cs deleted file mode 100644 index 2b7b2c5..0000000 --- a/CheckIpcAot/Program.cs +++ /dev/null @@ -1,10 +0,0 @@ -using dotnetCampus.Ipc.CompilerServices.Attributes; -using System.Threading.Tasks; - -[IpcPublic] -public interface IMyService { - Task DoWork(MyRequest req); -} - -public class MyResult { public string Msg {get;set;} } -public class MyRequest { public string Data {get;set;} } diff --git a/LanMountainDesktop.PluginSdk/PluginManifest.cs b/LanMountainDesktop.PluginSdk/PluginManifest.cs index e90f64c..332f49e 100644 --- a/LanMountainDesktop.PluginSdk/PluginManifest.cs +++ b/LanMountainDesktop.PluginSdk/PluginManifest.cs @@ -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; diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 3d11ea5..c328842 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -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 BuildCapabilities(AirAppMarketPluginEntry entry) { - if (entry.Capabilities is null) - { - return []; - } - var capabilities = new List(); - 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 diff --git a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs index 4abff3b..30d01e6 100644 --- a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs +++ b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs @@ -184,8 +184,6 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable services.AddSingleton(_localizationService); services.AddSingleton(_ => HostLocationServiceProvider.GetOrCreate()); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); var pluginRuntime = _pluginRuntimeAccessor(); if (pluginRuntime is not null) diff --git a/LanMountainDesktop/ViewModels/PluginCatalogSettingsPageViewModels.cs b/LanMountainDesktop/ViewModels/PluginCatalogSettingsPageViewModels.cs index a4701cb..951a6b1 100644 --- a/LanMountainDesktop/ViewModels/PluginCatalogSettingsPageViewModels.cs +++ b/LanMountainDesktop/ViewModels/PluginCatalogSettingsPageViewModels.cs @@ -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( diff --git a/LanMountainDesktop/Views/SettingsPages/PluginCatalogSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/PluginCatalogSettingsPage.axaml.cs index 90fdab6..519aa98 100644 --- a/LanMountainDesktop/Views/SettingsPages/PluginCatalogSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/PluginCatalogSettingsPage.axaml.cs @@ -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[] diff --git a/LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs b/LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs deleted file mode 100644 index abd7ca5..0000000 --- a/LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs +++ /dev/null @@ -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 _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 EnrichAsync( - AirAppMarketIndexDocument document, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(document); - - if (document.Plugins.Count == 0) - { - return document; - } - - var enrichedPlugins = new List(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 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 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 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(text, JsonOptions); - } - catch - { - return null; - } - } - - private async Task> GetBranchCandidatesAsync( - string owner, - string repositoryName, - CancellationToken cancellationToken) - { - var candidates = new List(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 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 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 FirstNonEmptyList(params IReadOnlyList?[] 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? Tags, - string? ReleaseNotes); -} diff --git a/LanMountainDesktop/plugins/PluginMarketAssetCacheService.cs b/LanMountainDesktop/plugins/PluginMarketAssetCacheService.cs new file mode 100644 index 0000000..a0080f2 --- /dev/null +++ b/LanMountainDesktop/plugins/PluginMarketAssetCacheService.cs @@ -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; + +/// +/// 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. +/// +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(); + } + + /// + /// 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 . + /// + 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); + } + + /// + /// Returns the cached icon path for the plugin when the cache is fresh, or null when it + /// must be (re)fetched. + /// + 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); + } + + /// + /// Removes the cached assets for a plugin (for example after an uninstall). + /// + 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(); + } + + /// + /// Clears every cached asset and the manifest. + /// + 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(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 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; } + } +} diff --git a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs deleted file mode 100644 index 7f041fc..0000000 --- a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs +++ /dev/null @@ -1,1323 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Interactivity; -using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Media.Imaging; -using LanMountainDesktop.PluginSdk; -using LanMountainDesktop.Services; - -namespace LanMountainDesktop.Services.PluginMarket; - -internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable -{ - private static readonly IBrush SurfaceBrush = new SolidColorBrush(Color.Parse("#14000000")); - private static readonly IBrush SelectedSurfaceBrush = new SolidColorBrush(Color.Parse("#1A0EA5E9")); - private static readonly IBrush CardBorderBrush = new SolidColorBrush(Color.Parse("#24FFFFFF")); - private static readonly IBrush SelectedBorderBrush = new SolidColorBrush(Color.Parse("#7C0EA5E9")); - private static readonly IBrush IconSurfaceBrush = new SolidColorBrush(Color.Parse("#221E3A8A")); - private static readonly IBrush ChipBrush = new SolidColorBrush(Color.Parse("#22000000")); - private static readonly IBrush MutedBrush = new SolidColorBrush(Color.Parse("#CC94A3B8")); - private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E")); - private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FF9A6700")); - private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C")); - - private readonly AppSettingsService _appSettingsService = new(); - private readonly LocalizationService _localizationService = new(); - private readonly PluginRuntimeService _runtime; - private readonly AirAppMarketIndexService _indexService; - private readonly AirAppMarketInstallService _installService; - private readonly AirAppMarketReadmeService _readmeService; - private readonly AirAppMarketIconService _iconService; - private readonly Version? _hostVersion; - private readonly CancellationTokenSource _lifetimeCts = new(); - - private readonly TextBox _searchTextBox; - private readonly Button _refreshButton; - private readonly TextBlock _statusTextBlock; - private readonly StackPanel _pluginListHost; - private readonly Border _detailBorder; - - private AirAppMarketIndexDocument? _document; - private AirAppMarketPluginEntry? _selectedPlugin; - private Dictionary _installedPlugins = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _pendingRestartPluginIds = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _readmeContents = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _readmeErrors = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _iconBitmaps = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _loadingIconPluginIds = new(StringComparer.OrdinalIgnoreCase); - private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl; - private string? _loadingReadmePluginId; - private string? _installingPluginId; - private bool _isRefreshing; - private bool _isInstalling; - private bool _hasLoadedOnce; - private bool _isDisposed; - private bool _isAttachedToVisualTree; - - public PluginMarketEmbeddedView(PluginRuntimeService runtime) - { - _runtime = runtime; - var dataDirectory = AppDataPathProvider.GetPluginMarketDirectory(); - _indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory)); - _installService = new AirAppMarketInstallService(runtime, dataDirectory); - _readmeService = new AirAppMarketReadmeService(); - _iconService = new AirAppMarketIconService(); - _hostVersion = typeof(App).Assembly.GetName().Version; - - _searchTextBox = new TextBox - { - MinWidth = 260, - Watermark = T("market.toolbar.search_placeholder", "Search plugins") - }; - _searchTextBox.PropertyChanged += (_, e) => - { - if (e.Property == TextBox.TextProperty) - { - RebuildSurface(); - } - }; - - _refreshButton = new Button - { - Content = T("market.toolbar.refresh", "Refresh"), - HorizontalAlignment = HorizontalAlignment.Right - }; - _refreshButton.Click += OnRefreshClick; - - _statusTextBlock = new TextBlock - { - Text = T("market.status.loading", "Loading the official plugin market..."), - TextWrapping = TextWrapping.Wrap, - Foreground = WarningBrush - }; - - _pluginListHost = new StackPanel - { - Spacing = 10 - }; - - _detailBorder = CreatePanelShell(18); - - Content = BuildLayout(); - AttachedToVisualTree += OnAttachedToVisualTree; - DetachedFromVisualTree += OnDetachedFromVisualTree; - } - - public void RefreshInstalledSnapshot() - { - _installedPlugins = _runtime.Catalog - .ToDictionary(entry => entry.Manifest.Id, StringComparer.OrdinalIgnoreCase); - RebuildSurface(); - } - - public void RefreshLocalization() - { - _searchTextBox.Watermark = T("market.toolbar.search_placeholder", "Search plugins"); - _refreshButton.Content = T("market.toolbar.refresh", "Refresh"); - RebuildSurface(); - } - - public void Dispose() - { - if (_isDisposed) - { - return; - } - - _isDisposed = true; - _lifetimeCts.Cancel(); - - foreach (var bitmap in _iconBitmaps.Values) - { - bitmap?.Dispose(); - } - - _iconBitmaps.Clear(); - _lifetimeCts.Dispose(); - _iconService.Dispose(); - _readmeService.Dispose(); - _installService.Dispose(); - _indexService.Dispose(); - } - - private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) - { - _isAttachedToVisualTree = true; - if (_hasLoadedOnce) - { - return; - } - - _hasLoadedOnce = true; - UiExceptionGuard.FireAndForgetGuarded( - RefreshAsync, - "PluginMarket.InitialLoad", - BuildMarketContext()); - } - - private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) - { - _isAttachedToVisualTree = false; - } - - private Control BuildLayout() - { - var root = new Grid - { - RowDefinitions = new RowDefinitions("Auto,*"), - RowSpacing = 16 - }; - - var toolbar = new Grid - { - RowDefinitions = new RowDefinitions("Auto,Auto"), - RowSpacing = 8 - }; - - var actionRow = new Grid - { - ColumnDefinitions = new ColumnDefinitions("*,Auto"), - ColumnSpacing = 12 - }; - actionRow.Children.Add(_searchTextBox); - actionRow.Children.Add(_refreshButton); - Grid.SetColumn(_refreshButton, 1); - - toolbar.Children.Add(actionRow); - toolbar.Children.Add(_statusTextBlock); - Grid.SetRow(_statusTextBlock, 1); - - var contentGrid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("430,*"), - ColumnSpacing = 16 - }; - - var listShell = CreatePanelShell(14); - listShell.Child = new ScrollViewer - { - Content = _pluginListHost, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled - }; - - contentGrid.Children.Add(listShell); - contentGrid.Children.Add(_detailBorder); - Grid.SetColumn(_detailBorder, 1); - - root.Children.Add(toolbar); - root.Children.Add(contentGrid); - Grid.SetRow(contentGrid, 1); - - return root; - } - - private void OnRefreshClick(object? sender, RoutedEventArgs e) - { - UiExceptionGuard.FireAndForgetGuarded( - RefreshAsync, - "PluginMarket.Refresh", - BuildMarketContext(), - ex => HandleTopLevelUiActionExceptionAsync( - ex, - F( - "market.status.load_failed_format", - "Failed to load the plugin market: {0}", - DescribeException(ex)))); - } - - private async Task RefreshAsync() - { - if (_isRefreshing || _isDisposed || _lifetimeCts.IsCancellationRequested) - { - return; - } - - _isRefreshing = true; - _refreshButton.IsEnabled = false; - SetStatus(T("market.status.loading", "Loading the official plugin market..."), WarningBrush); - - try - { - RefreshInstalledSnapshot(); - - var result = await _indexService.LoadAsync(_lifetimeCts.Token); - if (!CanUpdateUi()) - { - return; - } - - if (!result.Success || result.Document is null) - { - _document = null; - _selectedPlugin = null; - AppLogger.Warn( - "PluginMarket", - $"Refresh failed. Source=None; Warning={result.WarningMessage ?? string.Empty}; Error={result.ErrorMessage ?? string.Empty}; Context={BuildMarketContext()}"); - SetStatus( - F( - "market.status.load_failed_format", - "Failed to load the plugin market: {0}", - result.ErrorMessage ?? T("market.detail.unknown", "Unknown")), - ErrorBrush); - RebuildSurface(); - return; - } - - _document = result.Document; - _marketSourceDisplay = result.SourceLocation ?? AirAppMarketDefaults.DefaultIndexUrl; - _selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins); - AppLogger.Info( - "PluginMarket", - $"Refresh completed. Source={result.Source}; PluginCount={result.Document.Plugins.Count}; SourceLocation={result.SourceLocation ?? string.Empty}; Warning={result.WarningMessage ?? string.Empty}; Context={BuildMarketContext()}"); - - var statusMessage = result.Source == AirAppMarketLoadSource.Cache - ? F( - "market.status.loaded_cache_format", - "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}", - result.Document.Plugins.Count, - result.WarningMessage ?? T("market.detail.unknown", "Unknown")) - : F( - "market.status.loaded_network_format", - "Loaded {0} plugin(s) from the official source.", - result.Document.Plugins.Count); - - SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush); - RebuildSurface(); - await EnsureReadmeLoadedAsync(_selectedPlugin); - } - catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) - { - AppLogger.Info("PluginMarket", $"Refresh canceled because the view is being disposed. Context={BuildMarketContext()}"); - } - catch (Exception ex) - { - AppLogger.Warn( - "PluginMarket", - $"Refresh threw unexpectedly. ExceptionType={ex.GetType().FullName}; Classification={ClassifyException(ex)}; Context={BuildMarketContext()}", - ex); - if (CanUpdateUi()) - { - SetStatus( - F( - "market.status.load_failed_format", - "Failed to load the plugin market: {0}", - DescribeException(ex)), - ErrorBrush); - _document = null; - _selectedPlugin = null; - RebuildSurface(); - } - } - finally - { - _isRefreshing = false; - _refreshButton.IsEnabled = !_isDisposed; - } - } - - private void RebuildSurface() - { - if (_isDisposed) - { - return; - } - - var filteredPlugins = GetFilteredPlugins(); - _selectedPlugin = filteredPlugins.Count > 0 - ? ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins) - : null; - - BuildPluginList(filteredPlugins); - BuildDetailPanel(); - - _ = EnsureReadmeLoadedAsync(_selectedPlugin); - foreach (var plugin in filteredPlugins) - { - _ = EnsureIconLoadedAsync(plugin); - } - } - - private List GetFilteredPlugins() - { - if (_document is null) - { - return []; - } - - var query = (_searchTextBox.Text ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(query)) - { - return _document.Plugins.ToList(); - } - - return _document.Plugins - .Where(plugin => - plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Author.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase))) - .ToList(); - } - - private void BuildPluginList(IReadOnlyList plugins) - { - _pluginListHost.Children.Clear(); - - if (_document is null) - { - _pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "The plugin market has not been loaded yet."))); - return; - } - - if (plugins.Count == 0) - { - _pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "No plugins match the current search."))); - return; - } - - foreach (var plugin in plugins) - { - _pluginListHost.Children.Add(CreatePluginListItem(plugin)); - } - } - - private Control CreatePluginListItem(AirAppMarketPluginEntry plugin) - { - var installState = ResolveInstallState(plugin, out var installedPlugin); - var isCompatible = IsCompatibleWithHost(plugin); - var isSelected = string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase); - - var titleBlock = new TextBlock - { - Text = plugin.Name, - FontSize = 16, - FontWeight = FontWeight.SemiBold, - TextWrapping = TextWrapping.Wrap - }; - - var subtitleBlock = new TextBlock - { - Text = F("market.card.subtitle_format", "{0} | v{1}", plugin.Author, plugin.Version), - Foreground = MutedBrush, - TextWrapping = TextWrapping.Wrap - }; - - var descriptionBlock = new TextBlock - { - Text = plugin.Description, - TextWrapping = TextWrapping.Wrap, - MaxHeight = 40 - }; - - var chips = CreateChipWrapPanel( - CreateStateChip(T(StateKey(installState), StateFallback(installState)))); - - if (installedPlugin is not null) - { - chips.Children.Add(CreateStateChip(installedPlugin.IsLoaded - ? T("market.card.loaded", "Loaded") - : T("market.card.pending_restart", "Restart required"))); - } - - foreach (var tag in plugin.Tags.Take(3)) - { - chips.Children.Add(CreateStateChip(tag)); - } - - var summaryStack = new StackPanel - { - Spacing = 6, - VerticalAlignment = VerticalAlignment.Center, - Children = - { - titleBlock, - subtitleBlock, - descriptionBlock, - chips - } - }; - - var selectGrid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - ColumnSpacing = 14, - Children = - { - CreatePluginIcon(plugin, 56), - summaryStack - } - }; - Grid.SetColumn(summaryStack, 1); - - var selectButton = new Button - { - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(0), - HorizontalContentAlignment = HorizontalAlignment.Stretch, - Content = selectGrid - }; - selectButton.Click += (_, _) => UiExceptionGuard.FireAndForgetGuarded( - () => SelectPluginAsync(plugin), - "PluginMarket.SelectPlugin", - BuildMarketContext(plugin)); - - var rightPanel = new StackPanel - { - Spacing = 8, - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Top, - Children = - { - CreateInstallButton(plugin, installState, isCompatible, 96), - new TextBlock - { - Text = installedPlugin is null - ? string.Empty - : installedPlugin.IsLoaded - ? T("market.card.loaded", "Loaded") - : T("market.card.pending_restart", "Restart required"), - FontSize = 12, - Foreground = MutedBrush, - HorizontalAlignment = HorizontalAlignment.Right, - IsVisible = installedPlugin is not null - } - } - }; - - var layoutGrid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("*,Auto"), - ColumnSpacing = 14, - Children = - { - selectButton, - rightPanel - } - }; - Grid.SetColumn(rightPanel, 1); - - return new Border - { - Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush, - BorderBrush = isSelected ? SelectedBorderBrush : CardBorderBrush, - BorderThickness = new Thickness(1), - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusComponent", 18), - Padding = new Thickness(14), - Child = layoutGrid - }; - } - - private Control CreateDetailInfoRow(Control icon, Control infoStack) - { - return new Border - { - Background = SurfaceBrush, - BorderBrush = CardBorderBrush, - BorderThickness = new Thickness(1), - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusComponent", 16), - Padding = new Thickness(14, 12), - Child = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - ColumnSpacing = 12, - Children = - { - icon, - infoStack - } - } - }; - } - - private void BuildDetailPanel() - { - if (_selectedPlugin is null) - { - _detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "Select a plugin on the left to inspect details.")); - return; - } - - var plugin = _selectedPlugin; - var installState = ResolveInstallState(plugin, out var installedPlugin); - var isCompatible = IsCompatibleWithHost(plugin); - - var headerSummary = new StackPanel - { - Spacing = 6, - Children = - { - new TextBlock - { - Text = plugin.Name, - FontSize = 26, - FontWeight = FontWeight.SemiBold, - TextWrapping = TextWrapping.Wrap - }, - new TextBlock - { - Text = F("market.detail.author_subtitle_format", "By {0}", plugin.Author), - Foreground = MutedBrush, - TextWrapping = TextWrapping.Wrap - }, - new TextBlock - { - Text = plugin.Description, - TextWrapping = TextWrapping.Wrap - } - } - }; - - var headerChips = CreateChipWrapPanel( - CreateStateChip(T(StateKey(installState), StateFallback(installState))), - CreateStateChip(plugin.GetVersionSummary())); - foreach (var tag in plugin.Tags) - { - headerChips.Children.Add(CreateStateChip(tag)); - } - - headerSummary.Children.Add(headerChips); - - var headerGrid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), - ColumnSpacing = 16, - Children = - { - CreatePluginIcon(plugin, 76), - headerSummary, - CreateInstallButton(plugin, installState, isCompatible, 120) - } - }; - Grid.SetColumn(headerSummary, 1); - Grid.SetColumn(headerGrid.Children[2], 2); - - var detailPanel = new StackPanel - { - Spacing = 18, - Children = - { - headerGrid - } - }; - - if (!isCompatible) - { - detailPanel.Children.Add(new Border - { - Background = new SolidColorBrush(Color.Parse("#24FFC42B1C")), - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusSm", 14), - Padding = new Thickness(12), - Child = new TextBlock - { - Text = F( - "market.status.host_incompatible_format", - "This host is too old. Version {0} or newer is required.", - plugin.MinHostVersion), - Foreground = ErrorBrush, - TextWrapping = TextWrapping.Wrap - } - }); - } - - detailPanel.Children.Add(CreateSectionTitle(T("market.detail.readme", "README"))); - detailPanel.Children.Add(new Border - { - Background = SurfaceBrush, - BorderBrush = CardBorderBrush, - BorderThickness = new Thickness(1), - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusComponent", 16), - Padding = new Thickness(16), - Child = new TextBlock - { - Text = GetReadmeContent(plugin), - TextWrapping = TextWrapping.Wrap - } - }); - - detailPanel.Children.Add(CreateSectionTitle(T("market.detail.plugin_information", "Plugin Information"))); - detailPanel.Children.Add(CreatePluginInfoSection(plugin, installedPlugin, installState)); - - _detailBorder.Child = new ScrollViewer - { - Content = detailPanel, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled - }; - } - - private Control CreatePluginInfoSection( - AirAppMarketPluginEntry plugin, - PluginCatalogEntry? installedPlugin, - AirAppMarketInstallState installState) - { - var infoPanel = new StackPanel - { - Spacing = 14 - }; - - var cardWrap = new WrapPanel - { - Orientation = Orientation.Horizontal - }; - - foreach (var card in new[] - { - CreateInfoCard(T("market.detail.version", "Version"), $"v{plugin.Version}"), - CreateInfoCard(T("market.detail.installed_version", "Installed Version"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "Not installed")), - CreateInfoCard(T("market.detail.api_version", "API Version"), plugin.ApiVersion), - CreateInfoCard(T("market.detail.min_host_version", "Minimum Host Version"), plugin.MinHostVersion), - CreateInfoCard(T("market.detail.package_size", "Package Size"), FormatPackageSize(plugin.PackageSizeBytes)), - CreateInfoCard(T("market.detail.published_at", "Published At"), FormatTimestamp(plugin.PublishedAt)), - CreateInfoCard(T("market.detail.updated_at", "Updated At"), FormatTimestamp(plugin.UpdatedAt)), - CreateInfoCard(T("market.detail.state", "Install State"), T(StateKey(installState), StateFallback(installState))) - }) - { - cardWrap.Children.Add(card); - } - - infoPanel.Children.Add(cardWrap); - infoPanel.Children.Add(CreateInfoRow(T("market.detail.tags", "Tags"), plugin.Tags.Count == 0 ? T("market.detail.unknown", "Unknown") : string.Join(", ", plugin.Tags))); - infoPanel.Children.Add(CreateInfoRow(T("market.detail.project", "Project"), plugin.ProjectUrl)); - infoPanel.Children.Add(CreateInfoRow(T("market.detail.homepage", "Homepage"), plugin.HomepageUrl)); - infoPanel.Children.Add(CreateInfoRow(T("market.detail.repository", "Repository"), plugin.RepositoryUrl)); - infoPanel.Children.Add(CreateInfoRow(T("market.detail.market_source", "Market Source"), _marketSourceDisplay)); - - if (!string.IsNullOrWhiteSpace(plugin.ReleaseNotes)) - { - infoPanel.Children.Add(CreateInfoRow(T("market.detail.release_notes", "Release Notes"), plugin.ReleaseNotes)); - } - - return infoPanel; - } - - private Button CreateInstallButton( - AirAppMarketPluginEntry plugin, - AirAppMarketInstallState installState, - bool isCompatible, - double minWidth) - { - var isThisInstalling = _isInstalling && - string.Equals(_installingPluginId, plugin.Id, StringComparison.OrdinalIgnoreCase); - - var button = new Button - { - Content = isThisInstalling - ? T("market.button.installing", "Installing...") - : T(ButtonKey(installState), ButtonFallback(installState)), - IsEnabled = !_isInstalling && - isCompatible && - installState is not AirAppMarketInstallState.Installed and not AirAppMarketInstallState.RestartRequired, - MinWidth = minWidth, - HorizontalAlignment = HorizontalAlignment.Right - }; - - button.Click += (_, _) => UiExceptionGuard.FireAndForgetGuarded( - async () => - { - _selectedPlugin = plugin; - RebuildSurface(); - await EnsureReadmeLoadedAsync(plugin); - await InstallSelectedPluginAsync(plugin); - }, - "PluginMarket.InstallPlugin", - BuildMarketContext(plugin), - ex => HandleTopLevelUiActionExceptionAsync( - ex, - F( - "market.status.install_failed_format", - "Failed to install plugin: {0}", - DescribeException(ex)))); - - return button; - } - - private async Task SelectPluginAsync(AirAppMarketPluginEntry plugin) - { - _selectedPlugin = plugin; - RebuildSurface(); - await EnsureReadmeLoadedAsync(plugin); - } - - private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin) - { - if (_isInstalling || _isDisposed || _lifetimeCts.IsCancellationRequested) - { - return; - } - - _isInstalling = true; - _installingPluginId = plugin.Id; - BuildDetailPanel(); - BuildPluginList(GetFilteredPlugins()); - SetStatus( - F("market.status.installing_format", "Downloading and staging plugin '{0}'...", plugin.Name), - WarningBrush); - - try - { - var result = await _installService.InstallAsync(plugin, _lifetimeCts.Token); - if (!CanUpdateUi()) - { - return; - } - - if (!result.Success || result.Manifest is null) - { - SetStatus( - F( - "market.status.install_failed_format", - "Failed to install plugin: {0}", - result.ErrorMessage ?? T("market.detail.unknown", "Unknown")), - ErrorBrush); - return; - } - - RefreshInstalledSnapshot(); - - if (result.RestartRequired) - { - _pendingRestartPluginIds.Add(result.Manifest.Id); - SetStatus( - F( - "market.status.install_success_format", - "Plugin '{0}' has been staged. Restart the app to apply it.", - result.Manifest.Name), - WarningBrush); - PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true); - } - else - { - SetStatus( - F( - "market.status.install_success_format", - "Plugin '{0}' has been installed successfully.", - result.Manifest.Name), - SuccessBrush); - } - - RebuildSurface(); - } - catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) - { - AppLogger.Info( - "PluginMarket", - $"Install canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}"); - } - finally - { - _isInstalling = false; - _installingPluginId = null; - RebuildSurface(); - } - } - - private async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin) - { - if (plugin is null || - _isDisposed || - _readmeContents.ContainsKey(plugin.Id) || - string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - _loadingReadmePluginId = plugin.Id; - _readmeErrors.Remove(plugin.Id); - BuildDetailPanel(); - - try - { - var readme = await _readmeService.LoadAsync(plugin, _lifetimeCts.Token); - _readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme) - ? T("market.detail.readme_empty", "README is empty.") - : readme.Trim(); - } - catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) - { - AppLogger.Info( - "PluginMarket", - $"README load canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}"); - } - catch (Exception ex) - { - AppLogger.Warn( - "PluginMarket", - $"README load failed. PluginId={plugin.Id}; ExceptionType={ex.GetType().FullName}; Classification={ClassifyException(ex)}; Context={BuildMarketContext(plugin)}", - ex); - _readmeErrors[plugin.Id] = ex.Message; - } - finally - { - _loadingReadmePluginId = null; - if (CanUpdateUi() && - string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase)) - { - BuildDetailPanel(); - } - } - } - - private async Task EnsureIconLoadedAsync(AirAppMarketPluginEntry? plugin) - { - if (plugin is null || - _isDisposed || - _iconBitmaps.ContainsKey(plugin.Id) || - !_loadingIconPluginIds.Add(plugin.Id)) - { - return; - } - - try - { - _iconBitmaps[plugin.Id] = await _iconService.LoadAsync(plugin, _lifetimeCts.Token); - } - catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) - { - AppLogger.Info( - "PluginMarket", - $"Icon load canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}"); - } - catch - { - _iconBitmaps[plugin.Id] = null; - } - finally - { - _loadingIconPluginIds.Remove(plugin.Id); - if (CanUpdateUi()) - { - RebuildSurface(); - } - } - } - - private Task HandleTopLevelUiActionExceptionAsync(Exception ex, string fallbackStatus) - { - if (CanUpdateUi()) - { - SetStatus(fallbackStatus, ErrorBrush); - RebuildSurface(); - } - - return Task.CompletedTask; - } - - private bool CanUpdateUi() - { - return !_isDisposed && _isAttachedToVisualTree && !_lifetimeCts.IsCancellationRequested; - } - - private string BuildMarketContext(AirAppMarketPluginEntry? plugin = null) - { - return UiExceptionGuard.BuildContext( - ("SelectedPluginId", _selectedPlugin?.Id), - ("PluginId", plugin?.Id), - ("Source", _marketSourceDisplay), - ("IsRefreshing", _isRefreshing), - ("IsInstalling", _isInstalling), - ("IsDisposed", _isDisposed)); - } - - private static string ClassifyException(Exception ex) - { - return ex switch - { - OperationCanceledException => "Canceled", - TimeoutException => "Timeout", - HttpRequestException => "Network", - IOException => "IO", - _ => "Unexpected" - }; - } - - private static string DescribeException(Exception ex) - { - return ex switch - { - OperationCanceledException => "The request timed out or was canceled.", - TimeoutException => "The request timed out.", - HttpRequestException => ex.Message, - _ => ex.Message - }; - } - - private string GetReadmeContent(AirAppMarketPluginEntry plugin) - { - if (_readmeContents.TryGetValue(plugin.Id, out var readme)) - { - return readme; - } - - if (_readmeErrors.TryGetValue(plugin.Id, out var error)) - { - return F( - "market.detail.readme_error_format", - "README could not be loaded: {0}", - error); - } - - if (string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase)) - { - return T("market.detail.readme_loading", "Loading README..."); - } - - return plugin.ReleaseNotes; - } - - private AirAppMarketPluginEntry? ResolveSelectedPlugin( - string? selectedPluginId, - IReadOnlyList plugins) - { - if (plugins.Count == 0) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(selectedPluginId)) - { - var existing = plugins.FirstOrDefault(plugin => - string.Equals(plugin.Id, selectedPluginId, StringComparison.OrdinalIgnoreCase)); - if (existing is not null) - { - return existing; - } - } - - return plugins[0]; - } - - private AirAppMarketInstallState ResolveInstallState( - AirAppMarketPluginEntry plugin, - out PluginCatalogEntry? installedPlugin) - { - if (_pendingRestartPluginIds.Contains(plugin.Id)) - { - installedPlugin = null; - return AirAppMarketInstallState.RestartRequired; - } - - if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin)) - { - return AirAppMarketInstallState.NotInstalled; - } - - return CompareVersions(plugin.Version, installedPlugin.Manifest.Version) > 0 - ? AirAppMarketInstallState.UpdateAvailable - : AirAppMarketInstallState.Installed; - } - - private bool IsCompatibleWithHost(AirAppMarketPluginEntry plugin) - { - if (_hostVersion is null || - !AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) || - minHostVersion is null) - { - return true; - } - - return _hostVersion >= minHostVersion; - } - - private void SetStatus(string message, IBrush foreground) - { - _statusTextBlock.Text = message; - _statusTextBlock.Foreground = foreground; - } - - private static int CompareVersions(string? left, string? right) - { - var leftParsed = AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion); - var rightParsed = AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion); - - if (!leftParsed && !rightParsed) - { - return 0; - } - - if (!leftParsed) - { - return -1; - } - - if (!rightParsed) - { - return 1; - } - - return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0)); - } - - private Border CreatePanelShell(double padding) - { - return new Border - { - Background = SurfaceBrush, - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusComponent", 18), - Padding = new Thickness(padding) - }; - } - - private Control CreateEmptyState(string text) - { - return new Border - { - Background = SurfaceBrush, - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusComponent", 16), - BorderBrush = CardBorderBrush, - BorderThickness = new Thickness(1), - Padding = new Thickness(18), - Child = new TextBlock - { - Text = text, - TextWrapping = TextWrapping.Wrap - } - }; - } - - private Control CreatePluginIcon(AirAppMarketPluginEntry plugin, double size) - { - if (!_iconBitmaps.ContainsKey(plugin.Id)) - { - _ = EnsureIconLoadedAsync(plugin); - } - - Control iconChild; - if (_iconBitmaps.TryGetValue(plugin.Id, out var bitmap) && bitmap is not null) - { - iconChild = new Image - { - Source = bitmap, - Stretch = Stretch.UniformToFill - }; - } - else - { - var glyph = string.IsNullOrWhiteSpace(plugin.Name) ? "?" : plugin.Name.Trim()[0].ToString().ToUpperInvariant(); - iconChild = new TextBlock - { - Text = glyph, - FontSize = Math.Max(18, size * 0.32), - FontWeight = FontWeight.Bold, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - TextAlignment = TextAlignment.Center - }; - } - - return new Border - { - Width = size, - Height = size, - CornerRadius = new CornerRadius(Math.Max(12, size * 0.24)), - ClipToBounds = true, - Background = IconSurfaceBrush, - Child = iconChild - }; - } - - private WrapPanel CreateChipWrapPanel(params Control[] chips) - { - var panel = new WrapPanel - { - Orientation = Orientation.Horizontal - }; - - foreach (var chip in chips) - { - chip.Margin = new Thickness(0, 0, 8, 8); - panel.Children.Add(chip); - } - - return panel; - } - - private Border CreateStateChip(string text) - { - return new Border - { - Background = ChipBrush, - CornerRadius = new CornerRadius(999), - Padding = new Thickness(10, 4), - Child = new TextBlock - { - Text = text, - FontSize = 12 - } - }; - } - - private TextBlock CreateSectionTitle(string text) - { - return new TextBlock - { - Text = text, - FontSize = 18, - FontWeight = FontWeight.SemiBold - }; - } - - private Border CreateInfoCard(string label, string value) - { - return new Border - { - Width = 190, - Margin = new Thickness(0, 0, 12, 12), - Background = SurfaceBrush, - BorderBrush = CardBorderBrush, - BorderThickness = new Thickness(1), - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusComponent", 14), - Padding = new Thickness(14), - Child = new StackPanel - { - Spacing = 6, - Children = - { - new TextBlock - { - Text = label, - FontSize = 12, - Foreground = MutedBrush - }, - new TextBlock - { - Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "Unknown") : value, - TextWrapping = TextWrapping.Wrap - } - } - } - }; - } - - private Control CreateInfoRow(string label, string value) - { - return new Border - { - Background = SurfaceBrush, - BorderBrush = CardBorderBrush, - BorderThickness = new Thickness(1), - CornerRadius = ResolveCornerRadiusResource("DesignCornerRadiusComponent", 14), - Padding = new Thickness(14), - Child = new StackPanel - { - Spacing = 6, - Children = - { - new TextBlock - { - Text = label, - FontSize = 12, - Foreground = MutedBrush - }, - new TextBlock - { - Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "Unknown") : value, - TextWrapping = TextWrapping.Wrap - } - } - } - }; - } - - private static string FormatPackageSize(long packageSizeBytes) - { - var size = packageSizeBytes; - string[] units = ["B", "KB", "MB", "GB"]; - var unitIndex = 0; - decimal display = size; - - while (display >= 1024 && unitIndex < units.Length - 1) - { - display /= 1024; - unitIndex++; - } - - return string.Format( - CultureInfo.CurrentCulture, - display >= 10 || unitIndex == 0 ? "{0:0} {1}" : "{0:0.0} {1}", - display, - units[unitIndex]); - } - - private static string FormatTimestamp(DateTimeOffset timestamp) - { - if (timestamp == default) - { - return string.Empty; - } - - return timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture); - } - - private string T(string key, string fallback) - { - var snapshot = _appSettingsService.Load(); - return _localizationService.GetString(snapshot.LanguageCode, key, fallback); - } - - private string F(string key, string fallback, params object[] args) - { - return string.Format(CultureInfo.CurrentCulture, T(key, fallback), args); - } - - private static string StateKey(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.RestartRequired => "market.detail.state.restart_required", - AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available", - AirAppMarketInstallState.Installed => "market.detail.state.installed", - _ => "market.detail.state.not_installed" - }; - } - - private static string StateFallback(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.RestartRequired => "Restart required", - AirAppMarketInstallState.UpdateAvailable => "Update available", - AirAppMarketInstallState.Installed => "Installed", - _ => "Not installed" - }; - } - - private static string ButtonKey(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.RestartRequired => "market.button.restart", - AirAppMarketInstallState.UpdateAvailable => "market.button.update", - AirAppMarketInstallState.Installed => "market.button.installed", - _ => "market.button.install" - }; - } - - private static string ButtonFallback(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.RestartRequired => "Restart to apply", - AirAppMarketInstallState.UpdateAvailable => "Update", - AirAppMarketInstallState.Installed => "Installed", - _ => "Install" - }; - } - - private static CornerRadius ResolveCornerRadiusResource(string key, double fallback) - { - return Application.Current?.TryFindResource(key, out var resource) == true && resource is CornerRadius radius - ? radius - : new CornerRadius(fallback); - } -} diff --git a/LanMountainDesktop/plugins/PluginMarketIconService.cs b/LanMountainDesktop/plugins/PluginMarketIconService.cs index eef3bd9..811777f 100644 --- a/LanMountainDesktop/plugins/PluginMarketIconService.cs +++ b/LanMountainDesktop/plugins/PluginMarketIconService.cs @@ -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; +/// +/// 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. +/// 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 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 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); } diff --git a/LanMountainDesktop/plugins/PluginMarketIndexService.cs b/LanMountainDesktop/plugins/PluginMarketIndexService.cs index 357dfae..eb68e97 100644 --- a/LanMountainDesktop/plugins/PluginMarketIndexService.cs +++ b/LanMountainDesktop/plugins/PluginMarketIndexService.cs @@ -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 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(); } } diff --git a/LanMountainDesktop/plugins/PluginMarketModels.cs b/LanMountainDesktop/plugins/PluginMarketModels.cs index f38ea32..628e0c4 100644 --- a/LanMountainDesktop/plugins/PluginMarketModels.cs +++ b/LanMountainDesktop/plugins/PluginMarketModels.cs @@ -5,9 +5,19 @@ using System.IO; using System.Linq; using System.Text.Json; using LanMountainDesktop.PluginSdk; +using static LanMountainDesktop.Services.PluginMarket.AirAppMarketDefaults; +using static LanMountainDesktop.Services.PluginMarket.AirAppMarketIndexDocument; namespace LanMountainDesktop.Services.PluginMarket; +/// +/// Market index schema version. The host only understands this single self-contained flat format. +/// +internal static class AirAppMarketSchema +{ + public const string Version = "3.0.0"; +} + internal static class AirAppMarketDefaults { public const string DefaultIndexUrl = @@ -213,17 +223,12 @@ internal static class AirAppMarketDefaults public static bool TryParsePackageSourceKind(string? value, out PluginPackageSourceKind kind) { kind = PluginPackageSourceKind.ReleaseAsset; - var normalized = AirAppMarketIndexDocument.NormalizeValue(value); + var normalized = NormalizeValue(value); if (string.IsNullOrWhiteSpace(normalized)) { return false; } - if (Enum.TryParse(normalized, ignoreCase: true, out kind)) - { - return true; - } - switch (normalized) { case "releaseAsset": @@ -278,6 +283,11 @@ internal static class AirAppMarketDefaults assetName = Uri.UnescapeDataString(segments[5]); return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(assetName); } + + internal static string? NormalizeValue(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } } internal enum AirAppMarketLoadSource @@ -287,14 +297,6 @@ internal enum AirAppMarketLoadSource Cache = 2 } -internal enum AirAppMarketInstallState -{ - NotInstalled = 0, - UpdateAvailable = 1, - Installed = 2, - RestartRequired = 3 -} - internal sealed record AirAppMarketLoadResult( bool Success, AirAppMarketIndexDocument? Document, @@ -309,6 +311,11 @@ internal sealed record AirAppMarketInstallResult( string? ErrorMessage, bool RestartRequired = false); +/// +/// The market index document. Self-contained flat schema (schemaVersion 3.0.0). +/// Every plugin entry carries all of its display and acquisition metadata inline; +/// the host never needs to call back to GitHub to enrich entries. +/// internal sealed class AirAppMarketIndexDocument { private static readonly JsonSerializerOptions SerializerOptions = new() @@ -349,11 +356,16 @@ internal sealed class AirAppMarketIndexDocument private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName) { - var contracts = Contracts ?? []; - var normalizedContracts = new List(contracts.Count); - var seenContracts = new HashSet(StringComparer.OrdinalIgnoreCase); + var schemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName); + if (!string.Equals(schemaVersion, AirAppMarketSchema.Version, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' uses schemaVersion '{schemaVersion}', but the host only supports '{AirAppMarketSchema.Version}'."); + } - foreach (var contract in contracts) + var normalizedContracts = new List((Contracts ?? []).Count); + var seenContracts = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var contract in Contracts ?? []) { var normalizedContract = contract.ValidateAndNormalize(sourceName); var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}"; @@ -366,11 +378,9 @@ internal sealed class AirAppMarketIndexDocument normalizedContracts.Add(normalizedContract); } - var plugins = Plugins ?? []; - var normalizedPlugins = new List(plugins.Count); + var normalizedPlugins = new List((Plugins ?? []).Count); var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var plugin in plugins) + foreach (var plugin in Plugins ?? []) { var normalizedPlugin = plugin.ValidateAndNormalize(sourceName); if (!seenIds.Add(normalizedPlugin.Id)) @@ -384,7 +394,7 @@ internal sealed class AirAppMarketIndexDocument return new AirAppMarketIndexDocument { - SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName), + SchemaVersion = AirAppMarketSchema.Version, SourceId = RequireValue(SourceId, nameof(SourceId), sourceName), SourceName = RequireValue(SourceName, nameof(SourceName), sourceName), GeneratedAt = GeneratedAt == default @@ -400,7 +410,7 @@ internal sealed class AirAppMarketIndexDocument }; } - private static string RequireValue(string? value, string propertyName, string sourceName) + internal static string RequireValue(string? value, string propertyName, string sourceName) { var normalized = NormalizeValue(value); if (string.IsNullOrWhiteSpace(normalized)) @@ -411,11 +421,6 @@ internal sealed class AirAppMarketIndexDocument return normalized; } - internal static string? NormalizeValue(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - internal static string NormalizeVersion(string? value, string propertyName, string sourceName) { var normalized = RequireValue(value, propertyName, sourceName); @@ -526,7 +531,7 @@ internal sealed class AirAppMarketSharedContractEntry public AirAppMarketSharedContractEntry ValidateAndNormalize(string sourceName) { - var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() + var normalizedSha = NormalizeValue(Sha256)?.ToLowerInvariant() ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing required property '{nameof(Sha256)}' for a shared contract."); if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch))) @@ -535,10 +540,10 @@ internal sealed class AirAppMarketSharedContractEntry $"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for shared contract '{Id}'."); } - var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl) + var normalizedDownloadUrl = NormalizeValue(DownloadUrl) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}' for shared contract '{Id}'."); - AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName); + EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName); if (PackageSizeBytes <= 0) { @@ -548,10 +553,10 @@ internal sealed class AirAppMarketSharedContractEntry return new AirAppMarketSharedContractEntry { - Id = AirAppMarketIndexDocument.NormalizeValue(Id) + Id = NormalizeValue(Id) ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing a shared contract id."), - Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName), - AssemblyName = AirAppMarketIndexDocument.NormalizeValue(AssemblyName) + Version = NormalizeVersion(Version, nameof(Version), sourceName), + AssemblyName = NormalizeValue(AssemblyName) ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing assemblyName for shared contract '{Id}'."), DownloadUrl = normalizedDownloadUrl, Sha256 = normalizedSha, @@ -572,190 +577,28 @@ internal sealed class AirAppMarketPluginDependencyEntry { return new AirAppMarketPluginDependencyEntry { - Id = AirAppMarketIndexDocument.NormalizeValue(Id) + Id = NormalizeValue(Id) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing dependency id for a plugin entry."), - Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName), - AssemblyName = AirAppMarketIndexDocument.NormalizeValue(AssemblyName) + Version = NormalizeVersion(Version, nameof(Version), sourceName), + AssemblyName = NormalizeValue(AssemblyName) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing assemblyName for dependency '{Id}'.") }; } } -internal sealed class AirAppMarketPluginManifestEntry -{ - public string Id { get; init; } = string.Empty; - - public string Name { get; init; } = string.Empty; - - public string Description { get; init; } = string.Empty; - - public string Author { get; init; } = string.Empty; - - public string Version { get; init; } = string.Empty; - - public string ApiVersion { get; init; } = string.Empty; - - public string EntranceAssembly { get; init; } = string.Empty; - - public List SharedContracts { get; init; } = []; - - public AirAppMarketPluginManifestEntry ValidateAndNormalize(string sourceName) - { - return new AirAppMarketPluginManifestEntry - { - Id = AirAppMarketIndexDocument.NormalizeValue(Id) - ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.id."), - Name = AirAppMarketIndexDocument.NormalizeValue(Name) - ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.name."), - Description = AirAppMarketIndexDocument.NormalizeValue(Description) - ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.description."), - Author = AirAppMarketIndexDocument.NormalizeValue(Author) - ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.author."), - Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName), - ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName), - EntranceAssembly = AirAppMarketIndexDocument.NormalizeValue(EntranceAssembly) ?? string.Empty, - SharedContracts = NormalizeDependencies(sourceName, SharedContracts) - }; - } - - private static List NormalizeDependencies( - string sourceName, - IReadOnlyList? dependencies) - { - var normalizedDependencies = new List((dependencies ?? []).Count); - var seenDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var dependency in dependencies ?? []) - { - var normalizedDependency = dependency.ValidateAndNormalize(sourceName); - var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}"; - if (!seenDependencies.Add(dependencyKey)) - { - throw new InvalidOperationException( - $"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' in plugin manifest."); - } - - normalizedDependencies.Add(normalizedDependency); - } - - return normalizedDependencies; - } -} - -internal sealed class AirAppMarketPluginCompatibilityEntry -{ - public string MinHostVersion { get; init; } = string.Empty; - - public string ApiVersion { get; init; } = string.Empty; - - public string PluginApiVersion { get; init; } = string.Empty; - - public AirAppMarketPluginCompatibilityEntry ValidateAndNormalize(string sourceName) - { - return new AirAppMarketPluginCompatibilityEntry - { - MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion( - MinHostVersion, - nameof(MinHostVersion), - sourceName), - ApiVersion = AirAppMarketIndexDocument.NormalizeVersion( - AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion, - nameof(ApiVersion), - sourceName), - PluginApiVersion = AirAppMarketIndexDocument.NormalizeVersion( - AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion, - nameof(ApiVersion), - sourceName) - }; - } -} - -internal sealed class AirAppMarketPluginRepositoryEntry -{ - public string IconUrl { get; init; } = string.Empty; - - public string ProjectUrl { get; init; } = string.Empty; - - public string ReadmeUrl { get; init; } = string.Empty; - - public string HomepageUrl { get; init; } = string.Empty; - - public string RepositoryUrl { get; init; } = string.Empty; - - public List Tags { get; init; } = []; - - public string ReleaseNotes { get; init; } = string.Empty; - - public AirAppMarketPluginRepositoryEntry ValidateAndNormalize(string sourceName) - { - var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl( - AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl) - ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.repositoryUrl."), - nameof(RepositoryUrl), - sourceName); - - var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl) ?? string.Empty; - if (!string.IsNullOrWhiteSpace(normalizedIconUrl)) - { - AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName); - } - - var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl) ?? string.Empty; - if (!string.IsNullOrWhiteSpace(normalizedProjectUrl)) - { - AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl( - normalizedProjectUrl, - nameof(ProjectUrl), - sourceName); - } - - var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl) ?? string.Empty; - if (!string.IsNullOrWhiteSpace(normalizedReadmeUrl)) - { - AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName); - } - - var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl) ?? string.Empty; - if (!string.IsNullOrWhiteSpace(normalizedHomepageUrl)) - { - AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName); - } - - var normalizedTags = (Tags ?? []) - .Select(AirAppMarketIndexDocument.NormalizeValue) - .Where(tag => !string.IsNullOrWhiteSpace(tag)) - .Select(tag => tag!) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase) - .ToList(); - - return new AirAppMarketPluginRepositoryEntry - { - IconUrl = normalizedIconUrl, - ProjectUrl = normalizedProjectUrl, - ReadmeUrl = normalizedReadmeUrl, - HomepageUrl = normalizedHomepageUrl, - RepositoryUrl = normalizedRepositoryUrl, - Tags = normalizedTags, - ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) ?? string.Empty - }; - } -} - internal sealed class AirAppMarketPluginPackageSourceEntry { public string Kind { get; init; } = string.Empty; public string Url { get; init; } = string.Empty; - public string Path { get; init; } = string.Empty; - public PluginPackageSourceKind SourceKind { get; init; } = PluginPackageSourceKind.ReleaseAsset; public AirAppMarketPluginPackageSourceEntry ValidateAndNormalize(string sourceName, string pluginId) { - var normalizedKind = AirAppMarketIndexDocument.NormalizeValue(Kind) + var normalizedKind = NormalizeValue(Kind) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing package source kind for plugin '{pluginId}'."); if (!AirAppMarketDefaults.TryParsePackageSourceKind(normalizedKind, out var sourceKind)) @@ -764,11 +607,9 @@ internal sealed class AirAppMarketPluginPackageSourceEntry $"Market index '{sourceName}' declares invalid package source kind '{normalizedKind}' for plugin '{pluginId}'."); } - var normalizedPath = AirAppMarketIndexDocument.NormalizeValue(Path); - var normalizedUrl = AirAppMarketIndexDocument.NormalizeValue(Url) - ?? normalizedPath + var normalizedUrl = NormalizeValue(Url) ?? throw new InvalidOperationException( - $"Market index '{sourceName}' is missing package source url/path for plugin '{pluginId}'."); + $"Market index '{sourceName}' is missing package source url for plugin '{pluginId}'."); EnsurePackageSourceUrl(normalizedUrl, sourceName, pluginId); return new AirAppMarketPluginPackageSourceEntry @@ -781,7 +622,6 @@ internal sealed class AirAppMarketPluginPackageSourceEntry _ => normalizedKind }, Url = normalizedUrl, - Path = normalizedPath ?? string.Empty, SourceKind = sourceKind }; } @@ -790,11 +630,6 @@ internal sealed class AirAppMarketPluginPackageSourceEntry { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { - if (File.Exists(url)) - { - return; - } - throw new InvalidOperationException( $"Market index '{sourceName}' declares invalid package source url '{url}' for plugin '{pluginId}'."); } @@ -812,66 +647,204 @@ internal sealed class AirAppMarketPluginPackageSourceEntry } } -internal sealed class AirAppMarketPluginPublicationEntry +/// +/// A single market plugin entry in the self-contained flat schema. +/// All display and acquisition metadata lives directly on this object; there are no +/// nested manifest/compatibility/repository/publication fallback objects. +/// +internal sealed class AirAppMarketPluginEntry { + public string PluginId { get; init; } = string.Empty; + + public string Id { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public string Description { get; init; } = string.Empty; + + public string Author { get; init; } = string.Empty; + + public string Version { get; init; } = string.Empty; + + public string ApiVersion { get; init; } = string.Empty; + + public string MinHostVersion { get; init; } = string.Empty; + + public string EntranceAssembly { get; init; } = string.Empty; + + public string IconUrl { get; init; } = string.Empty; + + public string ReadmeUrl { get; init; } = string.Empty; + + public string ProjectUrl { get; init; } = string.Empty; + + public string HomepageUrl { get; init; } = string.Empty; + + public string RepositoryUrl { get; init; } = string.Empty; + public string ReleaseTag { get; init; } = string.Empty; public string ReleaseAssetName { get; init; } = string.Empty; - public DateTimeOffset PublishedAt { get; init; } - - public DateTimeOffset UpdatedAt { get; init; } - - public long PackageSizeBytes { get; init; } - public string Sha256 { get; init; } = string.Empty; public string Md5 { get; init; } = string.Empty; + public long PackageSizeBytes { get; init; } + + public DateTimeOffset PublishedAt { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } + + public string ReleaseNotes { get; init; } = string.Empty; + + public List Tags { get; init; } = []; + + public List SharedContracts { get; init; } = []; + public List PackageSources { get; init; } = []; - public AirAppMarketPluginPublicationEntry ValidateAndNormalize(string sourceName, string pluginId) + public List DesktopComponents { get; init; } = []; + + public List SettingsSections { get; init; } = []; + + public List Exports { get; init; } = []; + + public List MessageTypes { get; init; } = []; + + public string DownloadUrl => PackageSources + .OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind)) + .FirstOrDefault()?.Url ?? string.Empty; + + public bool HasReleaseDownloadMetadata => + !string.IsNullOrWhiteSpace(ReleaseTag) && + !string.IsNullOrWhiteSpace(ReleaseAssetName); + + public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName) { - var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId); - var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag) ?? string.Empty; - if (!string.IsNullOrWhiteSpace(normalizedReleaseTag)) + var resolvedId = NormalizeValue(PluginId) ?? NormalizeValue(Id) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."); + + var resolvedName = NormalizeValue(Name) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name for '{resolvedId}'."); + + var resolvedDescription = NormalizeValue(Description) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description for '{resolvedId}'."); + + var resolvedAuthor = NormalizeValue(Author) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author for '{resolvedId}'."); + + var resolvedVersion = NormalizeVersion(Version, nameof(Version), sourceName); + var resolvedApiVersion = NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName); + var resolvedMinHostVersion = NormalizeValue(MinHostVersion) ?? string.Empty; + if (!string.IsNullOrWhiteSpace(resolvedMinHostVersion)) { - normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag( - normalizedReleaseTag, - nameof(ReleaseTag), - sourceName); + resolvedMinHostVersion = NormalizeVersion(resolvedMinHostVersion, nameof(MinHostVersion), sourceName); } - var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName) ?? string.Empty; - var normalizedSha256 = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() ?? string.Empty; - if (!string.IsNullOrWhiteSpace(normalizedSha256) && - (normalizedSha256.Length != 64 || normalizedSha256.Any(ch => !Uri.IsHexDigit(ch)))) + var resolvedRepositoryUrl = NormalizeGitHubRepositoryUrl( + NormalizeValue(RepositoryUrl) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repositoryUrl for plugin '{resolvedId}'."), + nameof(RepositoryUrl), + sourceName); + + var resolvedIconUrl = NormalizeValue(IconUrl) ?? string.Empty; + if (!string.IsNullOrWhiteSpace(resolvedIconUrl)) + { + EnsureUrl(resolvedIconUrl, nameof(IconUrl), sourceName); + } + + var resolvedReadmeUrl = NormalizeValue(ReadmeUrl) ?? string.Empty; + if (!string.IsNullOrWhiteSpace(resolvedReadmeUrl)) + { + EnsureUrl(resolvedReadmeUrl, nameof(ReadmeUrl), sourceName); + } + + var resolvedProjectUrl = NormalizeValue(ProjectUrl) ?? string.Empty; + var resolvedHomepageUrl = NormalizeValue(HomepageUrl) ?? string.Empty; + + var resolvedReleaseTag = NormalizeValue(ReleaseTag) ?? string.Empty; + if (!string.IsNullOrWhiteSpace(resolvedReleaseTag)) + { + resolvedReleaseTag = NormalizeReleaseTag(resolvedReleaseTag, nameof(ReleaseTag), sourceName); + } + + var resolvedReleaseAssetName = NormalizeValue(ReleaseAssetName) ?? string.Empty; + + var resolvedSha256 = NormalizeValue(Sha256)?.ToLowerInvariant() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(resolvedSha256) && + (resolvedSha256.Length != 64 || resolvedSha256.Any(ch => !Uri.IsHexDigit(ch)))) { throw new InvalidOperationException( - $"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha256}' for plugin '{pluginId}'."); + $"Market index '{sourceName}' declares invalid SHA-256 '{resolvedSha256}' for plugin '{resolvedId}'."); } - var normalizedMd5 = AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant() ?? string.Empty; - if (!string.IsNullOrWhiteSpace(normalizedMd5) && - (normalizedMd5.Length != 32 || normalizedMd5.Any(ch => !Uri.IsHexDigit(ch)))) + var resolvedMd5 = NormalizeValue(Md5)?.ToLowerInvariant() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(resolvedMd5) && + (resolvedMd5.Length != 32 || resolvedMd5.Any(ch => !Uri.IsHexDigit(ch)))) { throw new InvalidOperationException( - $"Market index '{sourceName}' declares invalid MD5 '{normalizedMd5}' for plugin '{pluginId}'."); + $"Market index '{sourceName}' declares invalid MD5 '{resolvedMd5}' for plugin '{resolvedId}'."); } - return new AirAppMarketPluginPublicationEntry + var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, resolvedId); + if (normalizedPackageSources.Count == 0) { - ReleaseTag = normalizedReleaseTag, - ReleaseAssetName = normalizedReleaseAssetName, + throw new InvalidOperationException( + $"Market index '{sourceName}' is missing package sources for plugin '{resolvedId}'."); + } + + return new AirAppMarketPluginEntry + { + PluginId = resolvedId, + Id = resolvedId, + Name = resolvedName, + Description = resolvedDescription, + Author = resolvedAuthor, + Version = resolvedVersion, + ApiVersion = resolvedApiVersion, + MinHostVersion = resolvedMinHostVersion, + EntranceAssembly = NormalizeValue(EntranceAssembly) ?? string.Empty, + IconUrl = resolvedIconUrl, + ReadmeUrl = resolvedReadmeUrl, + ProjectUrl = resolvedProjectUrl, + HomepageUrl = resolvedHomepageUrl, + RepositoryUrl = resolvedRepositoryUrl, + ReleaseTag = resolvedReleaseTag, + ReleaseAssetName = resolvedReleaseAssetName, + Sha256 = resolvedSha256, + Md5 = resolvedMd5, + PackageSizeBytes = PackageSizeBytes, PublishedAt = PublishedAt, UpdatedAt = UpdatedAt, - PackageSizeBytes = PackageSizeBytes, - Sha256 = normalizedSha256, - Md5 = normalizedMd5, - PackageSources = normalizedPackageSources + ReleaseNotes = NormalizeValue(ReleaseNotes) ?? string.Empty, + Tags = NormalizeValues(Tags), + SharedContracts = NormalizeDependencies(SharedContracts, sourceName, resolvedId), + PackageSources = normalizedPackageSources, + DesktopComponents = NormalizeValues(DesktopComponents), + SettingsSections = NormalizeValues(SettingsSections), + Exports = NormalizeValues(Exports), + MessageTypes = NormalizeValues(MessageTypes) }; } + public string GetVersionSummary() + { + return string.Format( + CultureInfo.InvariantCulture, + "v{0} | API {1} | Host >= {2}", + string.IsNullOrWhiteSpace(Version) ? "?" : Version, + string.IsNullOrWhiteSpace(ApiVersion) ? "?" : ApiVersion, + string.IsNullOrWhiteSpace(MinHostVersion) ? "?" : MinHostVersion); + } + + public IReadOnlyList GetPackageSourcesInInstallOrder() + { + return PackageSources + .OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind)) + .ToList(); + } + private static List NormalizePackageSources( IReadOnlyList? packageSources, string sourceName, @@ -902,385 +875,6 @@ internal sealed class AirAppMarketPluginPublicationEntry return normalizedSources; } -} - -internal sealed class AirAppMarketPluginCapabilitiesEntry -{ - public List SharedContracts { get; init; } = []; - - public List DesktopComponents { get; init; } = []; - - public List SettingsSections { get; init; } = []; - - public List Exports { get; init; } = []; - - public List MessageTypes { get; init; } = []; - - public AirAppMarketPluginCapabilitiesEntry ValidateAndNormalize(string sourceName) - { - return new AirAppMarketPluginCapabilitiesEntry - { - SharedContracts = NormalizeDependencies(sourceName, SharedContracts), - DesktopComponents = NormalizeValues(DesktopComponents), - SettingsSections = NormalizeValues(SettingsSections), - Exports = NormalizeValues(Exports), - MessageTypes = NormalizeValues(MessageTypes) - }; - } - - private static List NormalizeDependencies( - string sourceName, - IReadOnlyList? dependencies) - { - var normalizedDependencies = new List((dependencies ?? []).Count); - var seenDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var dependency in dependencies ?? []) - { - var normalizedDependency = dependency.ValidateAndNormalize(sourceName); - var key = $"{normalizedDependency.Id}@{normalizedDependency.Version}"; - if (!seenDependencies.Add(key)) - { - throw new InvalidOperationException( - $"Market index '{sourceName}' declares duplicate capability dependency '{key}'."); - } - - normalizedDependencies.Add(normalizedDependency); - } - - return normalizedDependencies; - } - - private static List NormalizeValues(IReadOnlyList? values) - { - return (values ?? []) - .Select(AirAppMarketIndexDocument.NormalizeValue) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value!) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) - .ToList(); - } -} - -internal sealed class AirAppMarketPluginEntry -{ - public string PluginId { get; init; } = string.Empty; - - public AirAppMarketPluginManifestEntry? Manifest { get; init; } - - public AirAppMarketPluginCompatibilityEntry? Compatibility { get; init; } - - public AirAppMarketPluginRepositoryEntry? Repository { get; init; } - - public AirAppMarketPluginPublicationEntry? Publication { get; init; } - - public AirAppMarketPluginCapabilitiesEntry? Capabilities { get; init; } - - public string Id { get; init; } = string.Empty; - - public string Name { get; init; } = string.Empty; - - public string Description { get; init; } = string.Empty; - - public string Author { get; init; } = string.Empty; - - public string Version { get; init; } = string.Empty; - - public string ApiVersion { get; init; } = string.Empty; - - public string MinHostVersion { get; init; } = string.Empty; - - public string DownloadUrl { get; init; } = string.Empty; - - public string Sha256 { get; init; } = string.Empty; - - public long PackageSizeBytes { get; init; } - - public string IconUrl { get; init; } = string.Empty; - - public string ReleaseTag { get; init; } = string.Empty; - - public string ReleaseAssetName { get; init; } = string.Empty; - - public string ProjectUrl { get; init; } = string.Empty; - - public string ReadmeUrl { get; init; } = string.Empty; - - public string HomepageUrl { get; init; } = string.Empty; - - public string RepositoryUrl { get; init; } = string.Empty; - - public List Tags { get; init; } = []; - - public List SharedContracts { get; init; } = []; - - public List PackageSources { get; init; } = []; - - public string Md5 { get; init; } = string.Empty; - - public DateTimeOffset PublishedAt { get; init; } - - public DateTimeOffset UpdatedAt { get; init; } - - public string ReleaseNotes { get; init; } = string.Empty; - - public bool HasReleaseDownloadMetadata => - !string.IsNullOrWhiteSpace(ReleaseTag) && - !string.IsNullOrWhiteSpace(ReleaseAssetName); - - public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName) - { - var normalizedManifest = HasManifestData(Manifest) - ? Manifest!.ValidateAndNormalize(sourceName) - : null; - var normalizedCompatibility = HasCompatibilityData(Compatibility) - ? Compatibility!.ValidateAndNormalize(sourceName) - : null; - var normalizedRepository = HasRepositoryData(Repository) - ? Repository!.ValidateAndNormalize(sourceName) - : null; - var normalizedCapabilities = HasCapabilitiesData(Capabilities) - ? Capabilities!.ValidateAndNormalize(sourceName) - : null; - var resolvedPluginId = FirstNonEmpty( - normalizedManifest?.Id, - AirAppMarketIndexDocument.NormalizeValue(PluginId), - AirAppMarketIndexDocument.NormalizeValue(Id)) - ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."); - var normalizedPublication = HasPublicationData(Publication) - ? Publication!.ValidateAndNormalize(sourceName, resolvedPluginId) - : null; - - var resolvedPackageSources = NormalizePackageSources( - normalizedPublication?.PackageSources ?? PackageSources, - sourceName, - resolvedPluginId, - AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)); - if (resolvedPackageSources.Count == 0) - { - throw new InvalidOperationException( - $"Market index '{sourceName}' is missing package sources for plugin '{resolvedPluginId}'."); - } - - var resolvedRepositoryUrl = FirstNonEmpty( - normalizedRepository?.RepositoryUrl, - AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)); - if (string.IsNullOrWhiteSpace(resolvedRepositoryUrl)) - { - throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin repositoryUrl."); - } - - var resolvedDownloadUrl = FirstNonEmpty( - resolvedPackageSources.FirstOrDefault()?.Url, - AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)) - ?? string.Empty; - - var resolvedName = FirstNonEmpty( - normalizedManifest?.Name, - AirAppMarketIndexDocument.NormalizeValue(Name)) - ?? string.Empty; - var resolvedDescription = FirstNonEmpty( - normalizedManifest?.Description, - AirAppMarketIndexDocument.NormalizeValue(Description)) - ?? string.Empty; - var resolvedAuthor = FirstNonEmpty( - normalizedManifest?.Author, - AirAppMarketIndexDocument.NormalizeValue(Author)) - ?? string.Empty; - var resolvedVersion = FirstNonEmpty( - normalizedManifest?.Version, - AirAppMarketIndexDocument.NormalizeValue(Version)) - ?? string.Empty; - var resolvedApiVersion = FirstNonEmpty( - normalizedCompatibility?.PluginApiVersion, - normalizedManifest?.ApiVersion, - AirAppMarketIndexDocument.NormalizeValue(ApiVersion)) - ?? string.Empty; - var resolvedMinHostVersion = FirstNonEmpty( - normalizedCompatibility?.MinHostVersion, - AirAppMarketIndexDocument.NormalizeValue(MinHostVersion)) - ?? string.Empty; - - var resolvedIconUrl = FirstNonEmpty( - normalizedRepository?.IconUrl, - AirAppMarketIndexDocument.NormalizeValue(IconUrl)) - ?? string.Empty; - var resolvedProjectUrl = FirstNonEmpty( - normalizedRepository?.ProjectUrl, - AirAppMarketIndexDocument.NormalizeValue(ProjectUrl)) - ?? string.Empty; - var resolvedReadmeUrl = FirstNonEmpty( - normalizedRepository?.ReadmeUrl, - AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl)) - ?? string.Empty; - var resolvedHomepageUrl = FirstNonEmpty( - normalizedRepository?.HomepageUrl, - AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)) - ?? string.Empty; - - var resolvedReleaseTag = FirstNonEmpty( - normalizedPublication?.ReleaseTag, - AirAppMarketIndexDocument.NormalizeValue(ReleaseTag)) - ?? string.Empty; - var resolvedReleaseAssetName = FirstNonEmpty( - normalizedPublication?.ReleaseAssetName, - AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName)) - ?? string.Empty; - var resolvedPackageSize = normalizedPublication?.PackageSizeBytes ?? PackageSizeBytes; - var resolvedSha256 = FirstNonEmpty( - normalizedPublication?.Sha256, - AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()) - ?? string.Empty; - var resolvedMd5 = FirstNonEmpty( - normalizedPublication?.Md5, - AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant()) - ?? string.Empty; - var resolvedPublishedAt = normalizedPublication?.PublishedAt ?? PublishedAt; - var resolvedUpdatedAt = normalizedPublication?.UpdatedAt ?? UpdatedAt; - - var resolvedDependencies = NormalizeDependencies( - normalizedManifest?.SharedContracts ?? SharedContracts, - sourceName, - resolvedPluginId); - var resolvedTags = (normalizedRepository?.Tags ?? Tags ?? []) - .Select(AirAppMarketIndexDocument.NormalizeValue) - .Where(tag => !string.IsNullOrWhiteSpace(tag)) - .Select(tag => tag!) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase) - .ToList(); - var resolvedReleaseNotes = FirstNonEmpty( - normalizedRepository?.ReleaseNotes, - AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)) - ?? string.Empty; - - return new AirAppMarketPluginEntry - { - PluginId = resolvedPluginId, - Manifest = normalizedManifest, - Compatibility = normalizedCompatibility, - Repository = normalizedRepository, - Publication = normalizedPublication, - Capabilities = normalizedCapabilities, - Id = resolvedPluginId, - Name = resolvedName, - Description = resolvedDescription, - Author = resolvedAuthor, - Version = resolvedVersion, - ApiVersion = resolvedApiVersion, - MinHostVersion = resolvedMinHostVersion, - DownloadUrl = resolvedDownloadUrl, - Sha256 = resolvedSha256, - Md5 = resolvedMd5, - PackageSizeBytes = resolvedPackageSize, - IconUrl = resolvedIconUrl, - ReleaseTag = resolvedReleaseTag ?? string.Empty, - ReleaseAssetName = resolvedReleaseAssetName ?? string.Empty, - ProjectUrl = resolvedProjectUrl, - ReadmeUrl = resolvedReadmeUrl, - HomepageUrl = resolvedHomepageUrl, - RepositoryUrl = resolvedRepositoryUrl, - Tags = resolvedTags, - SharedContracts = resolvedDependencies, - PackageSources = resolvedPackageSources, - PublishedAt = resolvedPublishedAt, - UpdatedAt = resolvedUpdatedAt, - ReleaseNotes = resolvedReleaseNotes - }; - } - - public string GetVersionSummary() - { - if (string.IsNullOrWhiteSpace(Version) && - string.IsNullOrWhiteSpace(ApiVersion) && - string.IsNullOrWhiteSpace(MinHostVersion)) - { - return "Unknown"; - } - - return string.Format( - CultureInfo.InvariantCulture, - "v{0} | API {1} | Host >= {2}", - Version, - ApiVersion, - MinHostVersion); - } - - public IReadOnlyList GetPackageSourcesInInstallOrder() - { - if (PackageSources.Count > 0) - { - return PackageSources - .OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind)) - .ToList(); - } - - if (string.IsNullOrWhiteSpace(DownloadUrl)) - { - return []; - } - - var sourceKind = HasReleaseDownloadMetadata - ? PluginPackageSourceKind.ReleaseAsset - : PluginPackageSourceKind.RawFallback; - return - [ - new AirAppMarketPluginPackageSourceEntry - { - Kind = sourceKind switch - { - PluginPackageSourceKind.ReleaseAsset => "releaseAsset", - PluginPackageSourceKind.RawFallback => "rawFallback", - PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal", - _ => "rawFallback" - }, - Url = DownloadUrl, - SourceKind = sourceKind - } - ]; - } - - private static bool HasManifestData(AirAppMarketPluginManifestEntry? manifest) - { - return manifest is not null && - (!string.IsNullOrWhiteSpace(manifest.Id) || - !string.IsNullOrWhiteSpace(manifest.Name) || - !string.IsNullOrWhiteSpace(manifest.Version)); - } - - private static bool HasCompatibilityData(AirAppMarketPluginCompatibilityEntry? compatibility) - { - return compatibility is not null && - (!string.IsNullOrWhiteSpace(compatibility.MinHostVersion) || - !string.IsNullOrWhiteSpace(compatibility.ApiVersion) || - !string.IsNullOrWhiteSpace(compatibility.PluginApiVersion)); - } - - private static bool HasRepositoryData(AirAppMarketPluginRepositoryEntry? repository) - { - return repository is not null && - (!string.IsNullOrWhiteSpace(repository.IconUrl) || - !string.IsNullOrWhiteSpace(repository.ProjectUrl) || - !string.IsNullOrWhiteSpace(repository.RepositoryUrl)); - } - - private static bool HasPublicationData(AirAppMarketPluginPublicationEntry? publication) - { - return publication is not null && - (!string.IsNullOrWhiteSpace(publication.ReleaseTag) || - !string.IsNullOrWhiteSpace(publication.ReleaseAssetName) || - publication.PackageSources.Count > 0); - } - - private static bool HasCapabilitiesData(AirAppMarketPluginCapabilitiesEntry? capabilities) - { - return capabilities is not null && - (capabilities.SharedContracts.Count > 0 || - capabilities.DesktopComponents.Count > 0 || - capabilities.SettingsSections.Count > 0 || - capabilities.Exports.Count > 0 || - capabilities.MessageTypes.Count > 0); - } private static List NormalizeDependencies( IReadOnlyList? dependencies, @@ -1305,52 +899,14 @@ internal sealed class AirAppMarketPluginEntry return normalizedDependencies; } - private static List NormalizePackageSources( - IReadOnlyList? packageSources, - string sourceName, - string pluginId, - string? legacyDownloadUrl) + private static List NormalizeValues(IReadOnlyList? values) { - var normalizedSources = new List((packageSources ?? []).Count + 1); - foreach (var source in packageSources ?? []) - { - normalizedSources.Add(source.ValidateAndNormalize(sourceName, pluginId)); - } - - if (normalizedSources.Count > 0) - { - return normalizedSources - .OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind)) - .ToList(); - } - - var normalizedLegacyDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(legacyDownloadUrl); - if (!string.IsNullOrWhiteSpace(normalizedLegacyDownloadUrl)) - { - var legacySource = new AirAppMarketPluginPackageSourceEntry - { - Kind = "rawFallback", - Url = normalizedLegacyDownloadUrl, - SourceKind = PluginPackageSourceKind.RawFallback - }; - normalizedSources.Add(legacySource.ValidateAndNormalize(sourceName, pluginId)); - return normalizedSources; - } - - return normalizedSources; - } - - 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; + return (values ?? []) + .Select(NormalizeValue) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToList(); } } diff --git a/LanMountainDesktop/plugins/PluginMarketReadmeService.cs b/LanMountainDesktop/plugins/PluginMarketReadmeService.cs index 1ecd46e..c0d3e54 100644 --- a/LanMountainDesktop/plugins/PluginMarketReadmeService.cs +++ b/LanMountainDesktop/plugins/PluginMarketReadmeService.cs @@ -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; +/// +/// 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. +/// 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 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 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() diff --git a/LanMountainDesktop/plugins/PluginSharedContractManager.cs b/LanMountainDesktop/plugins/PluginSharedContractManager.cs index 9f9f2c7..fb74751 100644 --- a/LanMountainDesktop/plugins/PluginSharedContractManager.cs +++ b/LanMountainDesktop/plugins/PluginSharedContractManager.cs @@ -22,14 +22,15 @@ internal sealed class PluginSharedContractManager : IDisposable private readonly Dictionary _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(); diff --git a/SECURITY_AUDIT_REPORT.md b/SECURITY_AUDIT_REPORT.md deleted file mode 100644 index 2568382..0000000 --- a/SECURITY_AUDIT_REPORT.md +++ /dev/null @@ -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. **安全清单**: 建立安全相关的持续集成检查 - ---- - -*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。* diff --git a/SECURITY_AUDIT_REPORT_2026-05-24.md b/SECURITY_AUDIT_REPORT_2026-05-24.md deleted file mode 100644 index 97bcd35..0000000 --- a/SECURITY_AUDIT_REPORT_2026-05-24.md +++ /dev/null @@ -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. **代码审计**: 建议进行定期安全审计 - ---- - -*报告生成工具: 自动安全审计系统* -*审计方法: 静态代码分析 + 架构审查 + 攻击面映射* diff --git a/SECURITY_AUDIT_REPORT_2026-06-01.md b/SECURITY_AUDIT_REPORT_2026-06-01.md deleted file mode 100644 index 5b57e6a..0000000 --- a/SECURITY_AUDIT_REPORT_2026-06-01.md +++ /dev/null @@ -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>(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(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)结论一致,代码库在安全性方面保持良好状态,未发现新增的中等及以上漏洞。 - ---- - -*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。* diff --git a/TestFluentIcons/Program.cs b/TestFluentIcons/Program.cs deleted file mode 100644 index eb130f4..0000000 --- a/TestFluentIcons/Program.cs +++ /dev/null @@ -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(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")}"); - } - } -} diff --git a/TestFluentIcons/TestFluentIcons.csproj b/TestFluentIcons/TestFluentIcons.csproj deleted file mode 100644 index 9cc1bd1..0000000 --- a/TestFluentIcons/TestFluentIcons.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - - - - diff --git a/ago --name-only --oneline b/ago --name-only --oneline deleted file mode 100644 index cc617f8..0000000 --- a/ago --name-only --oneline +++ /dev/null @@ -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 . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_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 . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_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. diff --git a/ago --name-only --stat b/ago --name-only --stat deleted file mode 100644 index fb2f412..0000000 --- a/ago --name-only --stat +++ /dev/null @@ -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 . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_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 diff --git a/ago --stat --format=short b/ago --stat --format=short deleted file mode 100644 index 18ab95a..0000000 --- a/ago --stat --format=short +++ /dev/null @@ -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 . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_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 - diff --git a/mocks/class-schedule-mock.html b/mocks/class-schedule-mock.html deleted file mode 100644 index 454d787..0000000 --- a/mocks/class-schedule-mock.html +++ /dev/null @@ -1,459 +0,0 @@ - - - - - -课程表组件 Mock - 阑山桌面 - - - - -
- - -
- -
-
-
-
-
- 7 - / - 24 -
-
- 周一 -
-
6节课
-
-
-
-

2×4 标准尺寸

-
- -
-
-
-
- 7 - / - 24 -
-
- 周一 -
-
6节课
-
-
-
-

4×4 大尺寸

-
-
- - - - diff --git a/mocks/weather-widget-mock.html b/mocks/weather-widget-mock.html deleted file mode 100644 index d68e104..0000000 --- a/mocks/weather-widget-mock.html +++ /dev/null @@ -1,209 +0,0 @@ - - - - - -天气组件 Mock V2 - 阑山桌面 - - - - -
- - - | - - - -
- -
Google Weather — 纯渐变,无装饰
-
- -
Geometric — 径向渐变光晕 + 弧线段
-
- -
Breezy — 径向渐变光晕 + 波浪线 + 弧线段
-
- -
Lemon — 径向渐变光晕 + 天气场景装饰
-
- - - - diff --git a/testicon/Program.cs b/testicon/Program.cs deleted file mode 100644 index bc5534a..0000000 --- a/testicon/Program.cs +++ /dev/null @@ -1 +0,0 @@ -using System; class Program { static void Main() { foreach (var name in Enum.GetNames(typeof(FluentIcons.Common.Symbol))) Console.WriteLine(name); } } diff --git a/testicon/testicon.csproj b/testicon/testicon.csproj deleted file mode 100644 index 294d4da..0000000 --- a/testicon/testicon.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - Exe - net10.0 - enable - enable - 1.0.0 - - -