mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
7 Commits
Avalonia12
...
v0.8.6.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
458494d131 | ||
|
|
01670147f6 | ||
|
|
0348324fa3 | ||
|
|
fc4d0c4cd8 | ||
|
|
eb066b53f1 | ||
|
|
5ea242af9a | ||
|
|
abfa64b3d7 |
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"diffEditor.renderSideBySide": false
|
||||
"diffEditor.renderSideBySide": false,
|
||||
"clawMode.mode": "editor",
|
||||
"workbench.activityBar.location": "default"
|
||||
}
|
||||
106
.trae/documents/avalonia12-migration-plan.md
Normal file
106
.trae/documents/avalonia12-migration-plan.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Avalonia 12 迁移计划
|
||||
|
||||
## 当前状态
|
||||
|
||||
项目已完成以下迁移准备:
|
||||
|
||||
* `Directory.Packages.props` 中 Avalonia 包已升级到 `12.0.1`
|
||||
|
||||
* `FluentAvaloniaUI` 已升级到 `3.0.0-preview1`
|
||||
|
||||
* `Avalonia.Diagnostics` 已替换为 `AvaloniaUI.DiagnosticsSupport`
|
||||
|
||||
* `Avalonia.Controls.WebView` 已升级到 `12.0.0`
|
||||
|
||||
* `ClassIsland.Markdown.Avalonia` 已升级到 `12.0.0`
|
||||
|
||||
## 构建错误清单(26 errors)
|
||||
|
||||
### 1. 窗口装饰 API 移除(8 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/SettingsWindow.axaml.cs`(4 errors)
|
||||
|
||||
* `ExtendClientAreaChromeHints` 不存在(line 166, 179)
|
||||
|
||||
* `SystemDecorations` 已过时,需改用 `WindowDecorations`(line 168, 177)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs`(4 errors)
|
||||
|
||||
* `ExtendClientAreaChromeHints` 不存在(line 63, 72)
|
||||
|
||||
* `SystemDecorations` 已过时,需改用 `WindowDecorations`(line 65, 70)
|
||||
|
||||
**AXAML 文件**:13 个文件使用 `SystemDecorations` 属性(编译警告)
|
||||
|
||||
### 2. 变量/字段未找到(8 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
|
||||
|
||||
* `centerLeft` 不存在(line 759, 766, 778)
|
||||
|
||||
* `positions` 不存在(line 1266)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/MainWindow.DesktopPaging.cs`
|
||||
|
||||
* `child` 不存在(line 312)
|
||||
|
||||
* `_isThreeFingerOrRightDragSwipeActive` 不存在(line 517, 828, 847, 850)
|
||||
|
||||
### 3. API 变更(3 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/App.axaml.cs`
|
||||
|
||||
* `BindingPlugins` 不可访问(line 532, 537)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs`
|
||||
|
||||
* `IClipboard.SetTextAsync` 不存在(line 187)
|
||||
|
||||
**文件**:`LanMountainDesktop/Services/MonetColorService.cs`
|
||||
|
||||
* `Bitmap.CopyPixels` 参数不匹配(line 91)
|
||||
|
||||
### 4. 第三方库变更(1 error)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/SettingsWindow.axaml.cs`
|
||||
|
||||
* `FluentIcons.Avalonia.SymbolIconSource` 不存在(line 215)
|
||||
|
||||
### 5. 过时属性警告(需同步修复)
|
||||
|
||||
* `TextBox.Watermark` → `PlaceholderText`(7 处 .cs + 7 处 .axaml)
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### Phase 1: 修复窗口装饰 API(高优先级)
|
||||
|
||||
1. 重写 `SettingsWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
|
||||
2. 重写 `ComponentEditorWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
|
||||
3. 批量替换所有 `.axaml` 中的 `SystemDecorations` → `WindowDecorations`
|
||||
|
||||
### Phase 2: 修复 MainWindow 编译错误(高优先级)
|
||||
|
||||
1. 检查 `MainWindow.ComponentSystem.cs` 中 `centerLeft` 和 `positions` 的作用域问题
|
||||
2. 检查 `MainWindow.DesktopPaging.cs` 中 `child` 和 `_isThreeFingerOrRightDragSwipeActive` 的作用域问题
|
||||
3. 确认这些变量是否被意外删除或重命名
|
||||
|
||||
### Phase 3: 修复 Avalonia 12 API 变更(中优先级)
|
||||
|
||||
1. `App.axaml.cs`: 替换 `BindingPlugins.DataValidators` 的访问方式
|
||||
2. `DesktopComponentFailureView.cs`: 使用新的剪贴板 API
|
||||
3. `MonetColorService.cs`: 更新 `Bitmap.CopyPixels` 调用签名
|
||||
|
||||
### Phase 4: 修复第三方库变更(中优先级)
|
||||
|
||||
1. `SettingsWindow.axaml.cs`: 替换 `FluentIcons.Avalonia.SymbolIconSource` 为 v3 等效 API
|
||||
|
||||
### Phase 5: 清理过时属性(低优先级)
|
||||
|
||||
1. 批量替换 `Watermark` → `PlaceholderText`(所有 .cs 和 .axaml)
|
||||
|
||||
## 验证步骤
|
||||
|
||||
* 每阶段修复后运行 `dotnet build LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
* 最终运行 `dotnet test LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
21
.trae/specs/avalonia-12-migration/checklist.md
Normal file
21
.trae/specs/avalonia-12-migration/checklist.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `Directory.Packages.props` contains the Avalonia 12 dependency baseline.
|
||||
- [x] Main host references `Avalonia.Controls.WebView`.
|
||||
- [x] Source no longer references `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
|
||||
- [x] `BrowserWidget` uses `NativeWebView.Source`, `Navigate`, `Refresh()`, `NavigationStarted`, and `EnvironmentRequested`.
|
||||
- [x] WebView blanking navigates to `about:blank`.
|
||||
- [x] Plugin SDK package version is `5.0.0`.
|
||||
- [x] `PluginSdkInfo.ApiVersion` is `5.0.0`.
|
||||
- [x] Plugin template package version default is `5.0.0`.
|
||||
- [x] Plugin template manifest `apiVersion` is `5.0.0`.
|
||||
- [x] Launcher data location config resolution cannot recurse through `ResolveDataRoot()`.
|
||||
- [x] `OobeStateServiceTests` pass.
|
||||
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` has 0 errors.
|
||||
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` completes without a test host stack overflow.
|
||||
- [ ] Windows host smoke test completed.
|
||||
- [ ] Windows Launcher smoke test completed.
|
||||
- [ ] Settings window FluentAvalonia 3 smoke test completed.
|
||||
- [ ] Component editor Material smoke test completed.
|
||||
- [ ] BrowserWidget navigation/refresh/page activation smoke test completed.
|
||||
- [ ] WebView2 missing-runtime diagnostic smoke test completed.
|
||||
49
.trae/specs/avalonia-12-migration/spec.md
Normal file
49
.trae/specs/avalonia-12-migration/spec.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Avalonia 12 Full Stack Migration
|
||||
|
||||
## Summary
|
||||
|
||||
LanMountainDesktop has moved its desktop stack to the Avalonia 12 baseline. The migration covers the main host, Launcher, Plugin SDK, plugin runtime loading policy, official WebView usage, ClassIsland Markdown, FluentAvalonia, FluentIcons, and Material-related dependencies.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Centralized Avalonia 12 dependency baseline
|
||||
|
||||
The solution SHALL use central package management for direct Avalonia-facing projects and keep the core UI dependency baseline on Avalonia `12.0.1`.
|
||||
|
||||
Required package baseline:
|
||||
|
||||
- `Avalonia*` `12.0.1`
|
||||
- `Avalonia.Controls.WebView` `12.0.0`
|
||||
- `ClassIsland.Markdown.Avalonia` `12.0.0`
|
||||
- `FluentAvaloniaUI` `3.0.0-preview1`
|
||||
- `FluentIcons.Avalonia` `2.1.325`
|
||||
- `Material.Avalonia` `3.16.0`
|
||||
- `Material.Icons.Avalonia` `3.0.2`
|
||||
|
||||
### Requirement: Official WebView
|
||||
|
||||
The host SHALL use `Avalonia.Controls.NativeWebView` for the browser widget and SHALL NOT reference `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
|
||||
|
||||
Windows WebView2 user data configuration SHALL be supplied through `EnvironmentRequested` using `WindowsWebView2EnvironmentRequestedEventArgs.UserDataFolder`.
|
||||
|
||||
### Requirement: Plugin SDK v5
|
||||
|
||||
The Plugin SDK API baseline SHALL be `5.0.0`. SDK v4 plugins are treated as incompatible until rebuilt.
|
||||
|
||||
The SDK SHALL keep the existing public UI extension shape, including `SettingsPageBase` and Avalonia `Control` based desktop components.
|
||||
|
||||
### Requirement: Launcher data location stability
|
||||
|
||||
Launcher data location configuration SHALL be read from a fixed bootstrap Launcher data directory so resolving the selected data root cannot recursively require resolving itself.
|
||||
|
||||
### Requirement: OOBE state compatibility
|
||||
|
||||
The Launcher SHALL read current OOBE state from the resolved `Launcher/state` directory and SHALL continue to migrate the legacy `.launcher/state/first_run_completed` marker.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `dotnet build LanMountainDesktop.slnx -c Debug` completes with 0 errors.
|
||||
- `OobeStateServiceTests` pass.
|
||||
- Full `dotnet test LanMountainDesktop.slnx -c Debug` no longer aborts from `DataLocationResolver` recursion.
|
||||
- Plugin template defaults to SDK package version `5.0.0` and manifest `apiVersion` `5.0.0`.
|
||||
- Current developer documentation points to SDK v5 and Avalonia 12.
|
||||
18
.trae/specs/avalonia-12-migration/tasks.md
Normal file
18
.trae/specs/avalonia-12-migration/tasks.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Centralize Avalonia 12 package versions in `Directory.Packages.props`.
|
||||
- [x] Move the host, Launcher, Plugin SDK, DesktopHost, Shared.Contracts, and Avalonia-facing projects onto central package versions.
|
||||
- [x] Replace third-party `WebView.Avalonia` usage with official `NativeWebView`.
|
||||
- [x] Configure WebView2 user data through `EnvironmentRequested`.
|
||||
- [x] Move FluentAvalonia usages to the FA3 control names and package baseline.
|
||||
- [x] Move FluentIcons usage to `FluentIcons.Avalonia` and remove the old `.Fluent` package.
|
||||
- [x] Update Plugin SDK package version and API baseline to `5.0.0`.
|
||||
- [x] Update plugin runtime shared assembly policy for Avalonia 12 / FluentAvalonia / FluentIcons / Material.
|
||||
- [x] Fix Avalonia 12 compile breaks in window chrome, binding plugin access, clipboard, bitmap copy, and icon source usage.
|
||||
- [x] Fix Launcher data location recursion by using a fixed bootstrap config path.
|
||||
- [x] Fix OOBE state tests and legacy marker compatibility.
|
||||
- [x] Update PluginTemplate defaults to SDK v5.
|
||||
- [x] Add SDK v5 migration documentation.
|
||||
- [x] Update current docs from SDK v4 / Avalonia 11 examples to SDK v5 / Avalonia 12.
|
||||
- [x] Run full solution tests and record any remaining non-upgrade failures.
|
||||
- [ ] Perform Windows manual smoke test for host, Launcher, settings, component editor, BrowserWidget, and WebView2 missing-runtime handling.
|
||||
42
.trae/specs/localization-fix/checklist.md
Normal file
42
.trae/specs/localization-fix/checklist.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 本地化修复 Checklist
|
||||
|
||||
## MainWindow 修复
|
||||
- [ ] `TaskbarProfileDisplayNameTextBlock.Text` 在中文下显示"用户"(或保持动态)
|
||||
- [ ] `TaskbarProfileSettingsActionTextBlock.Text` 在中文下显示"设置"
|
||||
- [ ] `TaskbarProfileDesktopEditActionTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
- [ ] `TaskbarProfilePowerActionTextBlock.Text` 在中文下显示"电源"
|
||||
- [ ] `TaskbarPowerBackTextBlock.Text` 在中文下显示"返回"
|
||||
- [ ] `TaskbarPowerTitleTextBlock.Text` 在中文下显示"电源"
|
||||
- [ ] `PowerShutdownTextBlock.Text` 在中文下显示"关机"
|
||||
- [ ] `PowerRestartTextBlock.Text` 在中文下显示"重启"
|
||||
- [ ] `PowerLogoutTextBlock.Text` 在中文下显示"注销"
|
||||
- [ ] `PowerSleepTextBlock.Text` 在中文下显示"睡眠"
|
||||
- [ ] `PowerLockTextBlock.Text` 在中文下显示"锁定屏幕"
|
||||
- [ ] `ComponentLibraryTitleTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
- [ ] `ComponentLibraryEmptyTextBlock.Text` 在中文下显示"左右滑动选择类别,点击进入,然后拖动组件到桌面放置。"
|
||||
- [ ] `ComponentLibraryBackTextBlock.Text` 在中文下显示"返回"
|
||||
- [ ] `ComponentLibraryCollapsedChipTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
|
||||
## Launcher 修复
|
||||
- [ ] `SplashWindow` 在中文下显示中文启动文本
|
||||
- [ ] `DataLocationPromptWindow` 在中文下全部显示中文
|
||||
- [ ] `ErrorWindow` 在中文下全部显示中文
|
||||
- [ ] `LoadingDetailsWindow` 在中文下全部显示中文
|
||||
- [ ] `UpdateWindow` 在中文下显示中文标题
|
||||
|
||||
## 组件修复
|
||||
- [ ] `BrowserWidget` 在中文下显示"浏览器运行时不可用"
|
||||
- [ ] `WhiteboardWidget` 工具提示在中文下显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
|
||||
- [ ] `HolidayCalendarWidget` 在中文下显示"节假日倒计时"、"天"
|
||||
- [ ] `BilibiliHotSearchWidget` 在中文下显示"热门话题"
|
||||
- [ ] `WallpaperSettingsPage` 自定义颜色 Tooltip 在中文下显示"自定义颜色"
|
||||
|
||||
## 资源文件
|
||||
- [ ] `zh-CN.json` 包含所有新增键值
|
||||
- [ ] `en-US.json` 包含所有新增键值
|
||||
- [ ] Launcher 本地化文件包含所有新增键值
|
||||
|
||||
## 构建与质量
|
||||
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过,无错误
|
||||
- [ ] 无新增警告
|
||||
- [ ] 无遗漏的硬编码英文(通过 `grep -r 'Text="[a-zA-Z]'` 等检查)
|
||||
85
.trae/specs/localization-fix/spec.md
Normal file
85
.trae/specs/localization-fix/spec.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 本地化修复 Spec
|
||||
|
||||
## Why
|
||||
|
||||
- 项目在中文设置下,多处 UI 仍显示英文。
|
||||
- 主要问题集中在:
|
||||
1. `MainWindow.axaml` 中任务栏头像弹窗、电源菜单、组件库等文本硬编码为英文,且未被 `ApplyLocalization()` 覆盖。
|
||||
2. `LanMountainDesktop.Launcher` 的所有视图完全没有接入本地化系统。
|
||||
3. 部分组件(BrowserWidget、WhiteboardWidget、HolidayCalendarWidget 等)存在未覆盖的硬编码英文。
|
||||
4. 少量设置页面 Tooltip 硬编码英文。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 1. MainWindow.axaml 硬编码修复
|
||||
将以下硬编码文本改为由 `ApplyLocalization()` 通过 `L()` 动态设置:
|
||||
- 任务栏头像弹窗:`User` → `power.user` / `Settings` → `settings.title` / `Edit Desktop` → `button.component_library` / `Power` → `power.title`
|
||||
- 电源菜单:`Back` → `common.back` / `Power` → `power.title` / `Shutdown` → `power.shutdown` / `Restart` → `power.restart` / `Log Out` → `power.logout` / `Sleep` → `power.sleep` / `Lock Screen` → `power.lock_screen`
|
||||
- 组件库:`Widgets` → `component_library.title` / `Back` → `common.back` / `No components.` → `component_library.empty`
|
||||
- 悬浮芯片:`Widgets` → `component_library.title`
|
||||
|
||||
### 2. Launcher 视图本地化
|
||||
为 `LanMountainDesktop.Launcher/Views/` 下的窗口引入独立本地化机制(复用 `LocalizationService` 或内嵌资源字典):
|
||||
- `SplashWindow.axaml`:`LanMountain Desktop`、`Initializing...`
|
||||
- `DataLocationPromptWindow.axaml`:全部文本
|
||||
- `ErrorWindow.axaml`:全部文本
|
||||
- `LoadingDetailsWindow.axaml`:全部文本
|
||||
- `UpdateWindow.axaml`:`Update`
|
||||
|
||||
### 3. 组件硬编码修复
|
||||
- `BrowserWidget.axaml`:`Browser runtime unavailable.` → 新增键 `browser.widget.unavailable`
|
||||
- `WhiteboardWidget.axaml`:`Pen` / `Eraser` / `Clear` / `Export SVG` → 新增键 `whiteboard.tool.pen` 等
|
||||
- `HolidayCalendarWidget.axaml`:`Holiday countdown` / `Days` → 新增键 `holiday.widget.title` / `holiday.widget.days`
|
||||
- `BilibiliHotSearchWidget.axaml`:`Trending Topic` → 新增键 `bilihot.widget.trending_topic`
|
||||
- `WallpaperSettingsPage.axaml`:`Custom color` Tooltip → 复用 `settings.wallpaper.custom_color_tooltip`
|
||||
|
||||
### 4. 本地化资源文件补充
|
||||
在 `zh-CN.json` 和 `en-US.json` 中补充上述新增键值。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code:
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml`
|
||||
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
|
||||
- `LanMountainDesktop.Launcher/Views/*.axaml`(多个文件)
|
||||
- `LanMountainDesktop/Views/Components/BrowserWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/WhiteboardWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/HolidayCalendarWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml`
|
||||
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
|
||||
- `LanMountainDesktop/Localization/zh-CN.json`
|
||||
- `LanMountainDesktop/Localization/en-US.json`
|
||||
- Affected behavior:
|
||||
- 中文设置下上述位置将正确显示中文。
|
||||
- Launcher 各窗口将支持中英文切换。
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: MainWindow 任务栏弹窗与电源菜单本地化
|
||||
系统 SHALL 在 `ApplyLocalization()` 中覆盖任务栏头像弹窗和电源菜单的所有文本。
|
||||
|
||||
#### Scenario: 中文设置下打开任务栏弹窗
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** 弹窗中显示"设置"、"桌面编辑"、"电源"等中文文本
|
||||
- **AND THEN** 电源菜单中显示"返回"、"关机"、"重启"、"注销"、"睡眠"、"锁定屏幕"等中文文本
|
||||
|
||||
### Requirement: Launcher 窗口本地化
|
||||
系统 SHALL 让 Launcher 的所有窗口文本通过本地化服务获取。
|
||||
|
||||
#### Scenario: 中文设置下启动应用
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** SplashWindow 显示中文启动文本
|
||||
- **AND THEN** 数据位置选择、错误页、加载详情页等显示中文
|
||||
|
||||
### Requirement: 组件与设置页硬编码修复
|
||||
系统 SHALL 移除或覆盖所有组件和设置页中的英文硬编码文本。
|
||||
|
||||
#### Scenario: 中文设置下查看各组件
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** BrowserWidget 显示"浏览器运行时不可用"
|
||||
- **AND THEN** WhiteboardWidget 工具提示显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
|
||||
- **AND THEN** HolidayCalendarWidget 显示"节假日倒计时"、"天"
|
||||
- **AND THEN** BilibiliHotSearchWidget 显示"热门话题"
|
||||
- **AND THEN** 壁纸设置页自定义颜色 Tooltip 显示"自定义颜色"
|
||||
39
.trae/specs/localization-fix/tasks.md
Normal file
39
.trae/specs/localization-fix/tasks.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 本地化修复 Tasks
|
||||
|
||||
## Task 1: MainWindow.axaml 硬编码文本移除与代码覆盖
|
||||
- [ ] 1.1 在 `MainWindow.axaml` 中,将任务栏头像弹窗的 `User`、`Settings`、`Edit Desktop`、`Power` 的 `Text` 属性改为空或绑定(保留 x:Name)
|
||||
- [ ] 1.2 在 `MainWindow.axaml` 中,将电源菜单的 `Back`、`Power`、`Shutdown`、`Restart`、`Log Out`、`Sleep`、`Lock Screen` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.3 在 `MainWindow.axaml` 中,将组件库的 `Widgets`、`Back`、`No components.` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.4 在 `MainWindow.axaml` 中,将悬浮芯片的 `Widgets` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.5 在 `MainWindow.SettingsHardCut.Stubs.cs` 的 `ApplyLocalization()` 中补充上述所有控件的 `L()` 赋值
|
||||
|
||||
## Task 2: Launcher 视图本地化
|
||||
- [ ] 2.1 在 `LanMountainDesktop.Launcher` 中引入 `LocalizationService`(或共享主应用服务)
|
||||
- [ ] 2.2 为 Launcher 创建独立的 `Localization/` 目录和 `zh-CN.json` / `en-US.json`
|
||||
- [ ] 2.3 修改 `SplashWindow.axaml`:将 `LanMountain Desktop`、`Initializing...` 改为动态绑定
|
||||
- [ ] 2.4 修改 `DataLocationPromptWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.5 修改 `ErrorWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.6 修改 `LoadingDetailsWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.7 修改 `UpdateWindow.axaml`:将 `Update` 改为动态绑定
|
||||
- [ ] 2.8 在 Launcher 启动流程中初始化语言设置
|
||||
|
||||
## Task 3: 组件硬编码修复
|
||||
- [ ] 3.1 `BrowserWidget.axaml`:将 `Browser runtime unavailable.` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.2 `WhiteboardWidget.axaml`:将 `Pen`、`Eraser`、`Clear`、`Export SVG` Tooltip 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.3 `HolidayCalendarWidget.axaml`:将 `Holiday countdown`、`Days` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.4 `BilibiliHotSearchWidget.axaml`:将 `Trending Topic` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.5 `WallpaperSettingsPage.axaml`:将 `Custom color` Tooltip 改为绑定到 `settings.wallpaper.custom_color_tooltip`
|
||||
|
||||
## Task 4: 本地化资源文件补充
|
||||
- [ ] 4.1 在 `zh-CN.json` 中补充以下键值:
|
||||
- `browser.widget.unavailable`
|
||||
- `whiteboard.tool.pen`、`whiteboard.tool.eraser`、`whiteboard.tool.clear`、`whiteboard.tool.export_svg`
|
||||
- `holiday.widget.title`、`holiday.widget.days`
|
||||
- `bilihot.widget.trending_topic`
|
||||
- `power.user`(或复用现有键)
|
||||
- [ ] 4.2 在 `en-US.json` 中补充上述键值的英文版本
|
||||
- [ ] 4.3 为 Launcher 创建独立的本地化 JSON 文件并填充中英文
|
||||
|
||||
## Task 5: 验证
|
||||
- [ ] 5.1 执行 `dotnet build LanMountainDesktop.slnx -c Debug` 确保编译通过
|
||||
- [ ] 5.2 检查是否有遗漏的硬编码英文(通过正则搜索)
|
||||
@@ -74,7 +74,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
|
||||
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
|
||||
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
|
||||
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V5_MIGRATION.md`
|
||||
|
||||
### 设置与主题
|
||||
|
||||
@@ -91,6 +91,6 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
- 视觉规范:`docs/VISUAL_SPEC.md`
|
||||
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
|
||||
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
|
||||
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
- SDK v5 迁移:`docs/PLUGIN_SDK_V5_MIGRATION.md`
|
||||
|
||||
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。
|
||||
|
||||
42
Directory.Packages.props
Normal file
42
Directory.Packages.props
Normal file
@@ -0,0 +1,42 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="12.0.2" />
|
||||
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.2" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.2" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
||||
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
|
||||
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||
<PackageVersion Include="Downloader" Version="5.4.0" />
|
||||
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview2" />
|
||||
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
|
||||
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.3.26207.106" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="11.0.0-preview.3.26207.106" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="11.0.0-preview.3.26207.106" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="11.0.0-preview.3.26207.106" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.9" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.9" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
|
||||
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageVersion Include="PostHog" Version="2.5.0" />
|
||||
<PackageVersion Include="Sentry" Version="6.4.1" />
|
||||
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
|
||||
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
|
||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0-pre.4" />
|
||||
<PackageVersion Include="YamlDotNet" Version="17.1.0" />
|
||||
<PackageVersion Include="log4net" Version="3.3.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -5,7 +5,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
|
||||
@@ -41,4 +41,6 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||
[JsonSerializable(typeof(PrivacyConfig))]
|
||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -22,16 +22,14 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.250403001" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
<!-- Markdown.Avalonia 支持 (兼容 Avalonia 11.x) -->
|
||||
<PackageReference Include="Markdown.Avalonia" Version="11.0.3-a1" />
|
||||
<!-- MVVM 支持 -->
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="FluentAvaloniaUI" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 资源文件 -->
|
||||
|
||||
@@ -3,11 +3,30 @@ using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 解析应用数据目录位置。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 安装后的目录结构:
|
||||
/// <code>
|
||||
/// {AppRoot}/ ← 应用安装根目录
|
||||
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
|
||||
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
|
||||
/// app-{version}/ ← Host 部署目录
|
||||
/// LanMountainDesktop.exe
|
||||
/// ...
|
||||
/// </code>
|
||||
///
|
||||
/// Launcher 数据目录固定位于应用安装根目录下的 <c>.Launcher</c> 文件夹中,
|
||||
/// 与 app-* 部署目录同级。此目录不随数据位置模式改变。
|
||||
///
|
||||
/// Desktop(Host)数据目录则根据用户选择可位于系统目录或便携目录。
|
||||
/// </remarks>
|
||||
internal sealed class DataLocationResolver
|
||||
{
|
||||
private const string ConfigFileName = "data-location.config.json";
|
||||
private const string LauncherFolderName = "Launcher";
|
||||
private const string DesktopFolderName = "Desktop";
|
||||
private const string LauncherDataFolderName = ".Launcher";
|
||||
|
||||
private readonly string _appRoot;
|
||||
private readonly string _defaultSystemDataPath;
|
||||
@@ -28,58 +47,17 @@ internal sealed class DataLocationResolver
|
||||
public string DefaultSystemDataPath => _defaultSystemDataPath;
|
||||
|
||||
/// <summary>
|
||||
/// 默认便携模式数据路径(应用目录下的 AppData)
|
||||
/// 默认便携模式数据路径(应用目录下的 Desktop 文件夹)
|
||||
/// </summary>
|
||||
public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData");
|
||||
public string DefaultPortableDataPath => Path.Combine(_appRoot, DesktopFolderName);
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否允许便携模式(应用目录是否可写)
|
||||
/// </summary>
|
||||
public bool IsPortableModeAllowed()
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(_appRoot, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(testFile, string.Empty);
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析数据根目录(用户选择的位置)
|
||||
/// </summary>
|
||||
public string ResolveDataRoot()
|
||||
{
|
||||
var config = LoadConfig();
|
||||
if (config is null)
|
||||
{
|
||||
return _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
|
||||
? config.PortableDataPath
|
||||
: _defaultSystemDataPath;
|
||||
return Path.GetFullPath(portablePath);
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(config.SystemDataPath)
|
||||
? Path.GetFullPath(config.SystemDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动器数据目录(日志、配置、状态等)
|
||||
/// Launcher 数据目录,固定位于应用安装根目录下的 .Launcher 文件夹。
|
||||
/// 该目录与 app-* 部署目录同级,不随数据位置模式改变。
|
||||
/// </summary>
|
||||
public string ResolveLauncherDataPath()
|
||||
{
|
||||
return Path.Combine(ResolveDataRoot(), LauncherFolderName);
|
||||
return Path.Combine(_appRoot, LauncherDataFolderName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -91,7 +69,7 @@ internal sealed class DataLocationResolver
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据位置配置文件路径(保存在 Launcher 目录下)
|
||||
/// 数据位置配置文件路径(保存在 Launcher 数据目录下)
|
||||
/// </summary>
|
||||
public string ResolveConfigPath()
|
||||
{
|
||||
@@ -114,6 +92,24 @@ internal sealed class DataLocationResolver
|
||||
return Path.Combine(ResolveLauncherDataPath(), "state");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否允许便携模式(应用目录是否可写)
|
||||
/// </summary>
|
||||
public bool IsPortableModeAllowed()
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(_appRoot, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(testFile, string.Empty);
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public DataLocationMode ResolveMode()
|
||||
{
|
||||
var config = LoadConfig();
|
||||
@@ -127,13 +123,40 @@ internal sealed class DataLocationResolver
|
||||
: DataLocationMode.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析数据根目录(用户选择的位置)
|
||||
/// </summary>
|
||||
public string ResolveDataRoot()
|
||||
{
|
||||
var config = LoadConfig();
|
||||
return ResolveDataRoot(config);
|
||||
}
|
||||
|
||||
private string ResolveDataRoot(DataLocationConfig? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
|
||||
? config.PortableDataPath
|
||||
: DefaultPortableDataPath;
|
||||
return Path.GetFullPath(portablePath);
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(config.SystemDataPath)
|
||||
? Path.GetFullPath(config.SystemDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
public DataLocationConfig? LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 配置文件必须位于默认系统数据路径下的 Launcher 目录中
|
||||
// 避免循环依赖:不能调用 ResolveConfigPath() -> ResolveLauncherDataPath() -> ResolveDataRoot() -> LoadConfig()
|
||||
var configPath = Path.Combine(_defaultSystemDataPath, LauncherFolderName, ConfigFileName);
|
||||
var configPath = ResolveConfigPath();
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return null;
|
||||
@@ -153,8 +176,8 @@ internal sealed class DataLocationResolver
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = ResolveLauncherDataPath();
|
||||
Directory.CreateDirectory(launcherPath);
|
||||
var launcherDataPath = ResolveLauncherDataPath();
|
||||
Directory.CreateDirectory(launcherDataPath);
|
||||
|
||||
var configPath = ResolveConfigPath();
|
||||
var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig);
|
||||
@@ -185,7 +208,7 @@ internal sealed class DataLocationResolver
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ResolveLauncherDataPath());
|
||||
Directory.CreateDirectory(ResolveDesktopDataPath());
|
||||
Directory.CreateDirectory(Path.Combine(ResolveDataRoot(config), DesktopFolderName));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
public interface IUpdateProgressReporter
|
||||
{
|
||||
void ReportProgress(InstallProgressReport report);
|
||||
void ReportComplete(InstallCompleteReport report);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||
|
||||
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
|
||||
{
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private NamedPipeServerStream? _pipe;
|
||||
private Task? _listenTask;
|
||||
private volatile bool _clientConnected;
|
||||
|
||||
public LauncherUpdateProgressIpcServer(int launcherPid)
|
||||
{
|
||||
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
|
||||
}
|
||||
|
||||
public string PipeName => _pipeName;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
|
||||
}
|
||||
|
||||
private async Task AcceptConnectionAsync()
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pipe = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.Out,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
|
||||
_clientConnected = true;
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
|
||||
try
|
||||
{
|
||||
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportProgress(InstallProgressReport report)
|
||||
{
|
||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportComplete(InstallCompleteReport report)
|
||||
{
|
||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteMessage(Stream stream, string json)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(json);
|
||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||
stream.Write(lengthPrefix, 0, LengthPrefixSize);
|
||||
stream.Write(payload, 0, payload.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_pipe?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter
|
||||
{
|
||||
public void ReportProgress(InstallProgressReport report) { }
|
||||
public void ReportComplete(InstallCompleteReport report) { }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ internal sealed class OobeStateService
|
||||
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _statePath;
|
||||
private readonly string _legacyStatePath;
|
||||
private readonly string _legacyMarkerPath;
|
||||
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||
|
||||
@@ -25,7 +26,13 @@ internal sealed class OobeStateService
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||
|
||||
var legacyRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
var legacyStateDirectory = Path.Combine(legacyRoot, ".launcher", "state");
|
||||
_legacyStatePath = Path.Combine(legacyStateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(legacyStateDirectory, "first_run_completed");
|
||||
}
|
||||
|
||||
public OobeLaunchDecision Evaluate(CommandContext context)
|
||||
@@ -100,14 +107,12 @@ internal sealed class OobeStateService
|
||||
var migratedLegacyMarker = false;
|
||||
if (File.Exists(_statePath))
|
||||
{
|
||||
using var stream = File.OpenRead(_statePath);
|
||||
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
|
||||
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
|
||||
{
|
||||
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
|
||||
}
|
||||
return EvaluateStateFile(context, _statePath, migratedLegacyState: false);
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: false);
|
||||
if (File.Exists(_legacyStatePath))
|
||||
{
|
||||
return EvaluateStateFile(context, _legacyStatePath, migratedLegacyState: false);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
@@ -140,6 +145,18 @@ internal sealed class OobeStateService
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
private OobeLaunchDecision EvaluateStateFile(CommandContext context, string statePath, bool migratedLegacyState)
|
||||
{
|
||||
using var stream = File.OpenRead(statePath);
|
||||
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
|
||||
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
|
||||
{
|
||||
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: migratedLegacyState);
|
||||
}
|
||||
|
||||
private void TryDeleteLegacyMarker()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -9,8 +9,10 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
/// </summary>
|
||||
internal sealed class PluginInstallerService
|
||||
{
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".lmdp";
|
||||
private const string ManifestFileName = "plugin.json";
|
||||
private const string LegacyManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".laapp";
|
||||
private const string LegacyPackageFileExtension = ".lmdp";
|
||||
private const string RuntimeDirectoryName = "runtime";
|
||||
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
@@ -114,14 +116,16 @@ internal sealed class PluginInstallerService
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var entries = FindManifestEntries(archive, ManifestFileName);
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
entries = FindManifestEntries(archive, LegacyManifestFileName);
|
||||
}
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}' or '{LegacyManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
@@ -141,6 +145,13 @@ internal sealed class PluginInstallerService
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static ZipArchiveEntry[] FindManifestEntries(ZipArchive archive, string manifestFileName)
|
||||
{
|
||||
return archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, manifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
|
||||
@@ -148,8 +159,11 @@ internal sealed class PluginInstallerService
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||
.EnumerateFiles(pluginsDirectory, "*", SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path =>
|
||||
path.EndsWith(PackageFileExtension, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(LegacyPackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
@@ -20,14 +21,16 @@ internal sealed class UpdateEngineService
|
||||
private const string PublicKeyFileName = "public-key.pem";
|
||||
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly IUpdateProgressReporter _progressReporter;
|
||||
private readonly string _appRoot;
|
||||
private readonly string _launcherRoot;
|
||||
private readonly string _incomingRoot;
|
||||
private readonly string _snapshotsRoot;
|
||||
|
||||
public UpdateEngineService(DeploymentLocator deploymentLocator)
|
||||
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
|
||||
{
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_progressReporter = progressReporter ?? new NullUpdateProgressReporter();
|
||||
_appRoot = deploymentLocator.GetAppRoot();
|
||||
var resolver = new DataLocationResolver(_appRoot);
|
||||
_launcherRoot = resolver.ResolveLauncherDataPath();
|
||||
@@ -149,9 +152,11 @@ internal sealed class UpdateEngineService
|
||||
};
|
||||
}
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0));
|
||||
var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName);
|
||||
if (!verifyResult.Success)
|
||||
{
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
|
||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||
}
|
||||
|
||||
@@ -159,6 +164,7 @@ internal sealed class UpdateEngineService
|
||||
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
|
||||
if (fileMap is null || fileMap.Files.Count == 0)
|
||||
{
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false));
|
||||
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
|
||||
}
|
||||
|
||||
@@ -206,14 +212,21 @@ internal sealed class UpdateEngineService
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count));
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(partialMarker, string.Empty);
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, 0, fileMap.Files.Count));
|
||||
var fileIndex = 0;
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
|
||||
fileIndex++;
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (fileIndex * 30 / fileMap.Files.Count), file.Path, fileIndex, fileMap.Files.Count));
|
||||
}
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, 0, fileMap.Files.Count));
|
||||
var verifyIndex = 0;
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
if (!NeedsVerification(file))
|
||||
@@ -227,16 +240,22 @@ internal sealed class UpdateEngineService
|
||||
{
|
||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||
}
|
||||
|
||||
verifyIndex++;
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (verifyIndex * 15 / fileMap.Files.Count), file.Path, verifyIndex, fileMap.Files.Count));
|
||||
}
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count));
|
||||
ActivateDeployment(currentDeployment, targetDeployment);
|
||||
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
// 婵炴挸鎳愰幃濠囧籍瑜忔晶妤呭嫉椤掑﹦绀夊ù锝呮缁绘岸鎮惧▎鎰粯閺?濞戞搩浜炴晶妤呭嫉椤戝じ绨伴柡鈧娑樼槷闁搞儳鍋炵划?
|
||||
CleanupDestroyedDeployments();
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count));
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false));
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
@@ -249,9 +268,11 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, ex.Message, true));
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
@@ -283,9 +304,11 @@ internal sealed class UpdateEngineService
|
||||
string pdcSignaturePath,
|
||||
string pdcUpdatePath)
|
||||
{
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0));
|
||||
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
|
||||
if (!verifyResult.Success)
|
||||
{
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
|
||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||
}
|
||||
|
||||
@@ -299,6 +322,7 @@ internal sealed class UpdateEngineService
|
||||
|
||||
if (fileEntries.Count == 0)
|
||||
{
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
|
||||
return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
|
||||
}
|
||||
|
||||
@@ -347,17 +371,26 @@ internal sealed class UpdateEngineService
|
||||
Directory.Delete(targetDeployment, true);
|
||||
}
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(partialMarker, string.Empty);
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, 0, fileEntries.Count));
|
||||
var fileIndex = 0;
|
||||
foreach (var entry in fileEntries)
|
||||
{
|
||||
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
|
||||
fileIndex++;
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (fileIndex * 30 / fileEntries.Count), entry.Path, fileIndex, fileEntries.Count));
|
||||
}
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, 0, fileEntries.Count));
|
||||
var verifyIndex = 0;
|
||||
foreach (var entry in fileEntries)
|
||||
{
|
||||
VerifyPlondsFileEntry(entry, targetDeployment);
|
||||
verifyIndex++;
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (verifyIndex * 15 / fileEntries.Count), entry.Path, verifyIndex, fileEntries.Count));
|
||||
}
|
||||
|
||||
if (isInitialDeployment)
|
||||
@@ -370,6 +403,7 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
else
|
||||
{
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count));
|
||||
ActivateDeployment(currentDeployment!, targetDeployment);
|
||||
}
|
||||
|
||||
@@ -378,6 +412,9 @@ internal sealed class UpdateEngineService
|
||||
CleanupIncomingArtifacts();
|
||||
CleanupDestroyedDeployments();
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
@@ -405,6 +442,7 @@ internal sealed class UpdateEngineService
|
||||
|
||||
snapshot.Status = "failed";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false));
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
@@ -417,9 +455,11 @@ internal sealed class UpdateEngineService
|
||||
};
|
||||
}
|
||||
|
||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, ex.Message, true));
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="480"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.DataLocationPromptWindow"
|
||||
x:DataType="views:DataLocationPromptWindow"
|
||||
Title="选择数据保存位置"
|
||||
Title="Choose Data Location"
|
||||
Width="520"
|
||||
Height="480"
|
||||
CanResize="False"
|
||||
@@ -24,12 +24,13 @@
|
||||
</Grid.RenderTransform>
|
||||
<Grid Margin="36" RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,20">
|
||||
<TextBlock Text="选择数据保存位置"
|
||||
<TextBlock Text="Choose Data Location"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="决定将应用数据保存在哪里,可随时在设置中更改"
|
||||
<TextBlock Text="Choose where launcher and desktop data should be stored. You can change this later in settings."
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -41,15 +42,15 @@
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ui:SymbolIcon Symbol="Important"
|
||||
<fi:SymbolIcon Symbol="Important"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
<TextBlock Text="无法保存到应用目录"
|
||||
<TextBlock Text="App folder is not writable"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
|
||||
<TextBlock Text="The current install directory requires elevated permissions. Data will be stored in the system user profile instead."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
@@ -70,11 +71,11 @@
|
||||
GroupName="DataLocation"
|
||||
IsChecked="True" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="保存在系统用户目录(推荐)"
|
||||
<TextBlock Text="Store in the system user profile (Recommended)"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失"
|
||||
<TextBlock Text="Data stays tied to the current Windows user and remains intact across app reinstalls and updates."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
@@ -101,11 +102,11 @@
|
||||
GroupName="DataLocation"
|
||||
IsEnabled="False" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="保存在应用安装目录"
|
||||
<TextBlock Text="Store next to the app"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑"
|
||||
<TextBlock Text="Useful for portable installs. The whole app folder can be moved to another machine together with its data."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
@@ -124,7 +125,7 @@
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ui:SymbolIcon Symbol="Message"
|
||||
<fi:SymbolIcon Symbol="Info"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
<TextBlock x:Name="MigrationInfoText"
|
||||
@@ -141,11 +142,11 @@
|
||||
Spacing="10"
|
||||
Margin="0,20,0,0">
|
||||
<Button x:Name="CancelButton"
|
||||
Content="取消"
|
||||
Content="Cancel"
|
||||
Theme="{DynamicResource ButtonTheme}"
|
||||
IsVisible="False" />
|
||||
<Button x:Name="ConfirmButton"
|
||||
Content="确认"
|
||||
Content="Confirm"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -48,14 +48,12 @@ internal partial class DataLocationPromptWindow : Window
|
||||
|
||||
if (systemRadio is not null)
|
||||
{
|
||||
systemRadio.Checked += OnSelectionChanged;
|
||||
systemRadio.Unchecked += OnSelectionChanged;
|
||||
systemRadio.IsCheckedChanged += OnSelectionChanged;
|
||||
}
|
||||
|
||||
if (portableRadio is not null)
|
||||
{
|
||||
portableRadio.Checked += OnSelectionChanged;
|
||||
portableRadio.Unchecked += OnSelectionChanged;
|
||||
portableRadio.IsCheckedChanged += OnSelectionChanged;
|
||||
}
|
||||
|
||||
if (confirmButton is not null)
|
||||
@@ -108,7 +106,7 @@ internal partial class DataLocationPromptWindow : Window
|
||||
|
||||
if (migrationInfoText is not null && hasExistingData)
|
||||
{
|
||||
migrationInfoText.Text = "检测到系统用户目录已有应用数据。如果选择保存在应用安装目录,将自动迁移现有数据。";
|
||||
migrationInfoText.Text = "Existing system data was detected. Choosing portable mode will migrate the current data automatically.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
Background="#0B0B0B"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginIsolation.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginIsolation.Ipc</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -17,7 +17,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>4.0.2</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -19,13 +19,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="FluentAvaloniaUI" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "4.0.2";
|
||||
public const string ApiVersion = "5.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
|
||||
@@ -16,7 +16,7 @@ Official SDK package for LanMountainDesktop plugins.
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Official `dotnet new` template package for LanMountainDesktop plugins.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Target framework: `net10.0`
|
||||
- Plugin SDK: `LanMountainDesktop.PluginSdk` `5.0.0`
|
||||
- Manifest: `plugin.json`
|
||||
- Package: `.laapp`
|
||||
- Runtime mode: `in-proc`
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
@@ -15,3 +23,19 @@ dotnet new lmd-plugin -n YourPluginName
|
||||
```
|
||||
|
||||
The generated project references `LanMountainDesktop.PluginSdk` and produces a `.laapp` package automatically when built.
|
||||
|
||||
## Package contract
|
||||
|
||||
Every plugin package must contain:
|
||||
|
||||
- `plugin.json`
|
||||
- the entrance assembly declared by `entranceAssembly`
|
||||
- the `.deps.json` next to the entrance assembly
|
||||
|
||||
Optional package content:
|
||||
|
||||
- `Localization/*.json`
|
||||
- plugin assets and other managed dependencies
|
||||
- `airappmarket-entry.template.json` in the repository root for market publishing
|
||||
|
||||
Market publishing uses `market-manifest.json` with `schemaVersion`, `manifest`, `compatibility`, `repository`, `publication.packageSources`, and `capabilities`.
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"pluginSdkVersion": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "4.0.2",
|
||||
"defaultValue": "5.0.0",
|
||||
"description": "LanMountainDesktop.PluginSdk package version.",
|
||||
"replaces": "__PLUGIN_SDK_VERSION__"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "__PLUGIN_DESCRIPTION__",
|
||||
"author": "__PLUGIN_AUTHOR__",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "4.0.2",
|
||||
"apiVersion": "5.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||
"sharedContracts": [],
|
||||
"runtime": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -17,7 +17,7 @@
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
|
||||
86
LanMountainDesktop.Shared.Contracts/Update/UpdateManifest.cs
Normal file
86
LanMountainDesktop.Shared.Contracts/Update/UpdateManifest.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
public sealed record UpdateManifest(
|
||||
string DistributionId,
|
||||
string FromVersion,
|
||||
string ToVersion,
|
||||
string Platform,
|
||||
string Channel,
|
||||
DateTimeOffset PublishedAt,
|
||||
UpdatePayloadKind Kind,
|
||||
string? FileMapUrl,
|
||||
string? FileMapSignatureUrl,
|
||||
string? FileMapSha256,
|
||||
IReadOnlyList<UpdateFileEntry> Files,
|
||||
IReadOnlyList<UpdateMirrorAsset>? InstallerMirrors,
|
||||
IReadOnlyDictionary<string, string> Metadata)
|
||||
{
|
||||
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
|
||||
|
||||
public long EstimatedDeltaBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var f in Files)
|
||||
{
|
||||
if (f.Action is not ("reuse" or "delete"))
|
||||
{
|
||||
total += f.Size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateFileEntry(
|
||||
string Path,
|
||||
string Action,
|
||||
string Sha256,
|
||||
long Size,
|
||||
string Mode,
|
||||
string? ObjectKey,
|
||||
string? ObjectUrl,
|
||||
string? ArchiveSha256,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
public sealed record UpdateMirrorAsset(
|
||||
string Platform,
|
||||
string? Url,
|
||||
string? Name,
|
||||
string? Sha256,
|
||||
long Size);
|
||||
|
||||
public sealed record UpdateSettingsState(
|
||||
string UpdateChannel,
|
||||
string UpdateMode,
|
||||
string UpdateDownloadSource,
|
||||
int UpdateDownloadThreads,
|
||||
string? PreferredDistributionId,
|
||||
string? LastAppliedVersion,
|
||||
DateTimeOffset? LastAppliedAt,
|
||||
int ConsecutiveFailCount,
|
||||
DateTimeOffset? LastFailureAt,
|
||||
string? PendingUpdateInstallerPath,
|
||||
string? PendingUpdateVersion,
|
||||
long? PendingUpdatePublishedAtUtcMs,
|
||||
long? LastUpdateCheckUtcMs,
|
||||
string? PendingUpdateSha256)
|
||||
{
|
||||
public static UpdateSettingsState Default => new(
|
||||
UpdateChannel: "stable",
|
||||
UpdateMode: "download_then_confirm",
|
||||
UpdateDownloadSource: "plonds-api",
|
||||
UpdateDownloadThreads: 4,
|
||||
PreferredDistributionId: null,
|
||||
LastAppliedVersion: null,
|
||||
LastAppliedAt: null,
|
||||
ConsecutiveFailCount: 0,
|
||||
LastFailureAt: null,
|
||||
PendingUpdateInstallerPath: null,
|
||||
PendingUpdateVersion: null,
|
||||
PendingUpdatePublishedAtUtcMs: null,
|
||||
LastUpdateCheckUtcMs: null,
|
||||
PendingUpdateSha256: null);
|
||||
}
|
||||
66
LanMountainDesktop.Shared.Contracts/Update/UpdateMessages.cs
Normal file
66
LanMountainDesktop.Shared.Contracts/Update/UpdateMessages.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
public sealed record InstallProgressReport(
|
||||
InstallStage Stage,
|
||||
string Message,
|
||||
int ProgressPercent,
|
||||
string? CurrentFile,
|
||||
int FilesCompleted,
|
||||
int FilesTotal)
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed record InstallCompleteReport(
|
||||
bool Success,
|
||||
string? FromVersion,
|
||||
string? ToVersion,
|
||||
string? ErrorMessage,
|
||||
bool WasRolledBack)
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed record DownloadProgressReport(
|
||||
string CurrentFile,
|
||||
long BytesDownloaded,
|
||||
long BytesTotal,
|
||||
double BytesPerSecond,
|
||||
int FilesCompleted,
|
||||
int FilesTotal,
|
||||
double OverallFraction)
|
||||
{
|
||||
public int OverallPercent => (int)Math.Clamp(OverallFraction * 100, 0, 100);
|
||||
}
|
||||
|
||||
public sealed record UpdateProgressReport(
|
||||
UpdatePhase Phase,
|
||||
string Message,
|
||||
double ProgressFraction,
|
||||
DownloadProgressReport? DownloadDetail,
|
||||
InstallProgressReport? InstallDetail)
|
||||
{
|
||||
public int ProgressPercent => (int)Math.Clamp(ProgressFraction * 100, 0, 100);
|
||||
}
|
||||
|
||||
public sealed record UpdateCheckReport(
|
||||
bool IsUpdateAvailable,
|
||||
string? LatestVersion,
|
||||
string? CurrentVersion,
|
||||
UpdatePayloadKind? PayloadKind,
|
||||
string? DistributionId,
|
||||
string? Channel,
|
||||
DateTimeOffset? PublishedAt,
|
||||
long? TotalDownloadBytes,
|
||||
long? FullInstallerBytes,
|
||||
string? ErrorMessage);
|
||||
|
||||
public sealed record InstallRequest(
|
||||
UpdatePayloadKind PayloadKind,
|
||||
string LauncherRoot,
|
||||
string? LaunchSource = null);
|
||||
|
||||
public sealed record LaunchResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
int? ProcessId);
|
||||
71
LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs
Normal file
71
LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
public static class UpdatePaths
|
||||
{
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string ObjectsDirectoryName = "objects";
|
||||
private const string SnapshotsDirectoryName = "snapshots";
|
||||
|
||||
public static string ResolveLauncherRoot(string appBaseDirectory)
|
||||
{
|
||||
var trimmed = appBaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var parent = Path.GetDirectoryName(trimmed);
|
||||
return string.IsNullOrWhiteSpace(parent) ? appBaseDirectory : parent;
|
||||
}
|
||||
|
||||
public static string GetLauncherDataRoot(string launcherRoot)
|
||||
{
|
||||
return Path.Combine(launcherRoot, LauncherDirectoryName);
|
||||
}
|
||||
|
||||
public static string GetIncomingDirectory(string launcherRoot)
|
||||
{
|
||||
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
|
||||
}
|
||||
|
||||
public static string GetObjectsDirectory(string launcherRoot)
|
||||
{
|
||||
return Path.Combine(GetIncomingDirectory(launcherRoot), ObjectsDirectoryName);
|
||||
}
|
||||
|
||||
public static string GetSnapshotsDirectory(string launcherRoot)
|
||||
{
|
||||
return Path.Combine(launcherRoot, LauncherDirectoryName, SnapshotsDirectoryName);
|
||||
}
|
||||
|
||||
public static string GetDownloadMarkerPath(string launcherRoot)
|
||||
{
|
||||
return Path.Combine(GetIncomingDirectory(launcherRoot), ".download-complete");
|
||||
}
|
||||
|
||||
public static string GetPlondsFileMapName() => "plonds-filemap.json";
|
||||
public static string GetPlondsSignatureName() => "plonds-filemap.sig";
|
||||
public static string GetPlondsUpdateMetadataName() => "plonds-update.json";
|
||||
public static string GetLegacyFileMapName() => "files.json";
|
||||
public static string GetLegacySignatureName() => "files.json.sig";
|
||||
public static string GetLegacyArchiveName() => "update.zip";
|
||||
public static string GetPublicKeyFileName() => "public-key.pem";
|
||||
|
||||
public static string GetPlondsFileMapPath(string launcherRoot)
|
||||
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsFileMapName());
|
||||
|
||||
public static string GetPlondsSignaturePath(string launcherRoot)
|
||||
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsSignatureName());
|
||||
|
||||
public static string GetPlondsUpdateMetadataPath(string launcherRoot)
|
||||
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsUpdateMetadataName());
|
||||
|
||||
public static string GetDownloadMarkerContent(string manifestSha256, string targetVersion, int objectCount)
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"manifestSha256": "{{manifestSha256}}",
|
||||
"targetVersion": "{{targetVersion}}",
|
||||
"objectCount": {{objectCount}},
|
||||
"completedAt": "{{DateTimeOffset.UtcNow:O}}"
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
83
LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs
Normal file
83
LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
public enum UpdatePhase
|
||||
{
|
||||
Idle,
|
||||
Checking,
|
||||
Checked,
|
||||
Downloading,
|
||||
Downloaded,
|
||||
Installing,
|
||||
Installed,
|
||||
Verifying,
|
||||
Completed,
|
||||
Failed,
|
||||
Recovering,
|
||||
RollingBack,
|
||||
RolledBack
|
||||
}
|
||||
|
||||
public enum UpdatePayloadKind
|
||||
{
|
||||
DeltaPlonds,
|
||||
DeltaLegacy,
|
||||
FullInstaller
|
||||
}
|
||||
|
||||
public enum InstallStage
|
||||
{
|
||||
None,
|
||||
VerifySignature,
|
||||
CreateTarget,
|
||||
ApplyFiles,
|
||||
VerifyHashes,
|
||||
ActivateDeployment,
|
||||
Cleanup,
|
||||
Completed,
|
||||
Failed,
|
||||
RollingBack
|
||||
}
|
||||
|
||||
public enum UpdateChannel
|
||||
{
|
||||
Stable,
|
||||
Preview
|
||||
}
|
||||
|
||||
public enum UpdateMode
|
||||
{
|
||||
Manual,
|
||||
DownloadThenConfirm,
|
||||
SilentOnExit
|
||||
}
|
||||
|
||||
public enum UpdateDownloadSource
|
||||
{
|
||||
PlondsApi,
|
||||
GitHub,
|
||||
GhProxy
|
||||
}
|
||||
|
||||
public static class UpdatePhaseExtensions
|
||||
{
|
||||
public static bool IsTerminal(this UpdatePhase phase) =>
|
||||
phase is UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
|
||||
|
||||
public static bool IsBusy(this UpdatePhase phase) =>
|
||||
phase is not (UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
|
||||
or UpdatePhase.Installed or UpdatePhase.Completed or UpdatePhase.Failed
|
||||
or UpdatePhase.RolledBack);
|
||||
|
||||
public static bool CanCheck(this UpdatePhase phase) =>
|
||||
phase is UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
|
||||
or UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
|
||||
|
||||
public static bool CanDownload(this UpdatePhase phase) =>
|
||||
phase is UpdatePhase.Checked;
|
||||
|
||||
public static bool CanInstall(this UpdatePhase phase) =>
|
||||
phase is UpdatePhase.Downloaded;
|
||||
|
||||
public static bool CanRollback(this UpdatePhase phase) =>
|
||||
phase is UpdatePhase.Failed;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.IPC</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -17,8 +17,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -10,11 +10,10 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
|
||||
{
|
||||
var margin = new Thickness(24, 24, 24, 100);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
|
||||
Assert.Equal(margin, state.ExpandedMargin);
|
||||
Assert.Equal(0.75, state.ExpandedOpacity, 3);
|
||||
Assert.False(state.IsChipVisible);
|
||||
}
|
||||
|
||||
@@ -22,7 +21,7 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
|
||||
{
|
||||
var margin = new Thickness(20, 18, 20, 96);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
|
||||
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
@@ -36,24 +35,19 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
Assert.Equal(margin, collapsed.ExpandedMargin);
|
||||
Assert.Equal(margin, restoring.ExpandedMargin);
|
||||
|
||||
Assert.Equal(1, collapsing.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, collapsed.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, restoring.ExpandedOpacity, 3);
|
||||
|
||||
Assert.True(collapsing.IsChipVisible);
|
||||
Assert.True(collapsed.IsChipVisible);
|
||||
Assert.False(restoring.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow()
|
||||
public void CreateExpanded_DoesNotCaptureTransientOpacityAsRestorableState()
|
||||
{
|
||||
var margin = new Thickness(18, 22, 18, 88);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
|
||||
Assert.Equal(margin, restored.ExpandedMargin);
|
||||
Assert.Equal(0.15, restored.ExpandedOpacity, 3);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState);
|
||||
Assert.False(restored.IsChipVisible);
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentPreviewImageServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var executionOrder = new List<string>();
|
||||
var activeCount = 0;
|
||||
var maxActiveCount = 0;
|
||||
|
||||
Task<ComponentPreviewImageEntry> Queue(string componentTypeId)
|
||||
{
|
||||
var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2);
|
||||
return service.QueueGenerationAsync(
|
||||
key,
|
||||
visualSignature: $"sig:{componentTypeId}",
|
||||
async _ =>
|
||||
{
|
||||
var activeNow = Interlocked.Increment(ref activeCount);
|
||||
maxActiveCount = Math.Max(maxActiveCount, activeNow);
|
||||
lock (executionOrder)
|
||||
{
|
||||
executionOrder.Add(componentTypeId);
|
||||
}
|
||||
|
||||
await Task.Delay(40);
|
||||
Interlocked.Decrement(ref activeCount);
|
||||
return CreateImage();
|
||||
});
|
||||
}
|
||||
|
||||
var first = Queue("Clock");
|
||||
var second = Queue("Weather");
|
||||
var third = Queue("Calendar");
|
||||
|
||||
await Task.WhenAll(first, second, third);
|
||||
|
||||
Assert.Equal(1, maxActiveCount);
|
||||
Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var generationCount = 0;
|
||||
var bitmap = CreateImage();
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task<IImage?> Generation(CancellationToken _)
|
||||
{
|
||||
Interlocked.Increment(ref generationCount);
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
var first = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
var second = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
|
||||
Assert.Same(first, second);
|
||||
|
||||
completion.SetResult(bitmap);
|
||||
var entry = await first;
|
||||
|
||||
Assert.Equal(1, generationCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, entry.State);
|
||||
Assert.Same(bitmap, entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_ResetsSingleKeyToPending()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
var stored = service.Store(key, image, "clock-sig");
|
||||
var previousRevision = stored.Revision;
|
||||
|
||||
var result = service.Invalidate(key);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, stored.State);
|
||||
Assert.Null(stored.Bitmap);
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.True(stored.Revision > previousRevision);
|
||||
Assert.Equal("clock-sig", stored.VisualSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
|
||||
var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2);
|
||||
var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2);
|
||||
var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2);
|
||||
var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var removedClockImage = CreateDisposableImage();
|
||||
var removedWeatherImage = CreateDisposableImage();
|
||||
var keptPlacementImage = CreateDisposableImage();
|
||||
var keptTypeImage = CreateDisposableImage();
|
||||
|
||||
service.Store(removedClock, removedClockImage, "sig-a");
|
||||
service.Store(removedWeather, removedWeatherImage, "sig-b");
|
||||
service.Store(keptPlacement, keptPlacementImage, "sig-c");
|
||||
service.Store(keptType, keptTypeImage, "sig-d");
|
||||
|
||||
var removedCount = service.RemovePlacementPreviews("desk-1");
|
||||
|
||||
Assert.Equal(2, removedCount);
|
||||
Assert.False(service.TryGetEntry(removedClock, out _));
|
||||
Assert.False(service.TryGetEntry(removedWeather, out _));
|
||||
Assert.True(service.TryGetEntry(keptPlacement, out _));
|
||||
Assert.True(service.TryGetEntry(keptType, out _));
|
||||
Assert.True(removedClockImage.IsDisposed);
|
||||
Assert.True(removedWeatherImage.IsDisposed);
|
||||
Assert.False(keptPlacementImage.IsDisposed);
|
||||
Assert.False(keptTypeImage.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
const string matchingSignature = "shared-sig";
|
||||
const string otherSignature = "other-sig";
|
||||
|
||||
var first = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var second = service.Store(
|
||||
ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var third = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1),
|
||||
CreateImage(),
|
||||
otherSignature);
|
||||
|
||||
var invalidatedCount = service.InvalidateVisualSignature(matchingSignature);
|
||||
|
||||
Assert.Equal(2, invalidatedCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, first.State);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, second.State);
|
||||
Assert.Null(first.Bitmap);
|
||||
Assert.Null(second.Bitmap);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, third.State);
|
||||
Assert.NotNull(third.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var first = CreateDisposableImage();
|
||||
var second = CreateDisposableImage();
|
||||
|
||||
service.Store(key, first, "sig-a");
|
||||
service.Store(key, second, "sig-b");
|
||||
|
||||
Assert.True(first.IsDisposed);
|
||||
Assert.False(second.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
service.Store(key, image, "sig-b");
|
||||
|
||||
Assert.False(image.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreFailure_DisposesExistingBitmap()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
var entry = service.StoreFailure(key, "sig-a", "failed");
|
||||
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Failed, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var stale = CreateDisposableImage();
|
||||
|
||||
var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task);
|
||||
_ = service.Invalidate(key);
|
||||
completion.SetResult(stale);
|
||||
var entry = await generationTask;
|
||||
|
||||
Assert.True(stale.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
private static IImage CreateImage() => new TestImage();
|
||||
private static DisposableTestImage CreateDisposableImage() => new();
|
||||
|
||||
private sealed class TestImage : IImage
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DisposableTestImage : IImage, IDisposable
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
IsDisposed = true;
|
||||
}
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
@@ -34,10 +33,14 @@ public sealed class ComponentSettingsServiceTests
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.Single(snapshot.ImportedClassSchedules);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings));
|
||||
Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _));
|
||||
Assert.True(File.Exists(sandbox.DatabasePath));
|
||||
Assert.False(File.Exists(sandbox.SettingsPath));
|
||||
Assert.True(File.Exists(sandbox.SettingsBackupPath));
|
||||
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
var reloadedService = sandbox.CreateService();
|
||||
var reloaded = reloadedService.Load();
|
||||
Assert.Equal("Sweep", reloaded.DesktopClockSecondHandMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,11 +75,16 @@ public sealed class ComponentSettingsServiceTests
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings));
|
||||
Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _));
|
||||
Assert.True(File.Exists(sandbox.DatabasePath));
|
||||
Assert.False(File.Exists(sandbox.SettingsPath));
|
||||
Assert.True(File.Exists(sandbox.SettingsBackupPath));
|
||||
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
var reloadedService = sandbox.CreateService();
|
||||
var reloadedSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2");
|
||||
var reloadedPluginSettings = reloadedService.LoadPluginSettings<SamplePluginSettings>("DesktopClock", "clock-2x2");
|
||||
Assert.Equal("Sweep", reloadedSnapshot.DesktopClockSecondHandMode);
|
||||
Assert.True(reloadedPluginSettings.SampleFlag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,12 +140,7 @@ public sealed class ComponentSettingsServiceTests
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
Assert.Equal("schedule-settings", pluginSettings.Title);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode));
|
||||
Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
Assert.True(File.Exists(sandbox.DatabasePath));
|
||||
}
|
||||
|
||||
private sealed class ComponentSettingsSandbox : IDisposable
|
||||
@@ -155,6 +158,10 @@ public sealed class ComponentSettingsServiceTests
|
||||
|
||||
public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json");
|
||||
|
||||
public string SettingsBackupPath => $"{SettingsPath}.migrated.bak";
|
||||
|
||||
public string DatabasePath => Path.Combine(_directoryPath, "component-state.db");
|
||||
|
||||
public ComponentSettingsService CreateService()
|
||||
{
|
||||
return new ComponentSettingsService(_directoryPath);
|
||||
|
||||
135
LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs
Normal file
135
LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopComponentRenderModeTests
|
||||
{
|
||||
private const string ComponentId = "RenderModeProbe";
|
||||
|
||||
[Fact]
|
||||
public void DescriptorCreateControl_DefaultsToLiveRenderMode()
|
||||
{
|
||||
var descriptor = CreateDescriptor();
|
||||
var control = (ProbeControl)descriptor.CreateControl(
|
||||
cellSize: 64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
placementId: "desktop-placement");
|
||||
|
||||
Assert.Equal(DesktopComponentRenderMode.Live, control.RuntimeContext?.RenderMode);
|
||||
Assert.Equal("desktop-placement", control.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescriptorCreateControl_CanCreateLibraryPreviewRenderModeWithoutPlacement()
|
||||
{
|
||||
var descriptor = CreateDescriptor();
|
||||
var control = (ProbeControl)descriptor.CreateControl(
|
||||
cellSize: 64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
placementId: null,
|
||||
renderMode: DesktopComponentRenderMode.LibraryPreview);
|
||||
|
||||
Assert.Equal(DesktopComponentRenderMode.LibraryPreview, control.RuntimeContext?.RenderMode);
|
||||
Assert.Null(control.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentLibraryService_CreatesLibraryPreviewRenderMode()
|
||||
{
|
||||
var service = new ComponentLibraryService(
|
||||
CreateComponentRegistry(),
|
||||
CreateRuntimeRegistry());
|
||||
|
||||
var created = service.TryCreateControl(
|
||||
ComponentId,
|
||||
new ComponentLibraryCreateContext(
|
||||
64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
PlacementId: null,
|
||||
RenderMode: DesktopComponentRenderMode.LibraryPreview),
|
||||
out var control,
|
||||
out var exception);
|
||||
|
||||
Assert.True(created, exception?.ToString());
|
||||
var probe = Assert.IsType<ProbeControl>(control);
|
||||
Assert.Equal(DesktopComponentRenderMode.LibraryPreview, probe.RuntimeContext?.RenderMode);
|
||||
Assert.Null(probe.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
private static DesktopComponentRuntimeDescriptor CreateDescriptor()
|
||||
{
|
||||
Assert.True(CreateRuntimeRegistry().TryGetDescriptor(ComponentId, out var descriptor));
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private static DesktopComponentRuntimeRegistry CreateRuntimeRegistry()
|
||||
{
|
||||
return new DesktopComponentRuntimeRegistry(
|
||||
CreateComponentRegistry(),
|
||||
[
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
ComponentId,
|
||||
displayNameLocalizationKey: null,
|
||||
_ => new ProbeControl(),
|
||||
cornerRadiusResolver: (System.Func<double, double>?)null)
|
||||
]);
|
||||
}
|
||||
|
||||
private static ComponentRegistry CreateComponentRegistry()
|
||||
{
|
||||
return new ComponentRegistry(
|
||||
[
|
||||
new DesktopComponentDefinition(
|
||||
ComponentId,
|
||||
"Render Mode Probe",
|
||||
"Apps",
|
||||
"Test",
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true)
|
||||
]);
|
||||
}
|
||||
|
||||
private static ISettingsFacadeService CreateSettingsFacade()
|
||||
{
|
||||
return HostSettingsFacadeProvider.GetOrCreate();
|
||||
}
|
||||
|
||||
private static TimeZoneService CreateTimeZoneService()
|
||||
{
|
||||
return CreateSettingsFacade().Region.GetTimeZoneService();
|
||||
}
|
||||
|
||||
private static IWeatherInfoService CreateWeatherInfoService()
|
||||
{
|
||||
return CreateSettingsFacade().Weather.GetWeatherInfoService();
|
||||
}
|
||||
|
||||
private sealed class ProbeControl : Control, IComponentRuntimeContextAware
|
||||
{
|
||||
public DesktopComponentRuntimeContext? RuntimeContext { get; private set; }
|
||||
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
RuntimeContext = context;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -118,7 +118,7 @@ public sealed class OobeStateServiceTests : IDisposable
|
||||
executionSnapshot: executionSnapshot ?? new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
|
||||
}
|
||||
|
||||
private string GetStatePath() => Path.Combine(_tempRoot, ".launcher", "state", "oobe-state.json");
|
||||
private string GetStatePath() => Path.Combine(_tempRoot, "Launcher", "state", "oobe-state.json");
|
||||
|
||||
private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using System.IO.Compression;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -26,6 +27,96 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Assert.Equal("plugin_elevation_required", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_InstallsLaappWithPluginJson_InsideUserScope()
|
||||
{
|
||||
var packagePath = Path.Combine(_tempRoot, "sample.laapp");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
Assert.Equal("plugin.install.sample", result.ManifestId);
|
||||
Assert.Equal("Sample Plugin", result.ManifestName);
|
||||
Assert.NotNull(result.InstalledPackagePath);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
Assert.EndsWith(".laapp", result.InstalledPackagePath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||
{
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var firstPackagePath = Path.Combine(_tempRoot, "sample-1.laapp");
|
||||
var secondPackagePath = Path.Combine(_tempRoot, "sample-2.laapp");
|
||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
Assert.Single(Directory.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.TopDirectoryOnly));
|
||||
Assert.True(File.Exists(second.InstalledPackagePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_StillSupportsLegacyManifestJson()
|
||||
{
|
||||
var packagePath = Path.Combine(_tempRoot, "legacy.lmdp");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
}
|
||||
|
||||
private static void CreatePluginPackage(string packagePath, string manifestFileName, string pluginId, string pluginName)
|
||||
{
|
||||
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
|
||||
var entry = archive.CreateEntry(manifestFileName);
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(
|
||||
$$"""
|
||||
{
|
||||
"id": "{{pluginId}}",
|
||||
"name": "{{pluginName}}",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static string CreateUserScopedPluginsDirectory()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Tests",
|
||||
nameof(PluginInstallerServiceTests),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
|
||||
127
LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs
Normal file
127
LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginMarketIndexDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_WithNestedV2Entry_MapsDisplayFieldsAndWorkspacePath()
|
||||
{
|
||||
var document = AirAppMarketIndexDocument.Load(CreateNestedIndexJson(), "test-index.json");
|
||||
var plugin = Assert.Single(document.Plugins);
|
||||
var source = Assert.Single(plugin.PackageSources);
|
||||
|
||||
Assert.Equal("LanMountainDesktop.SamplePlugin", plugin.Id);
|
||||
Assert.Equal("LanMountain Sample Plugin", plugin.Name);
|
||||
Assert.Equal("SDK v5 sample plugin.", plugin.Description);
|
||||
Assert.Equal("LanMountainDesktop", plugin.Author);
|
||||
Assert.Equal("0.4.0", plugin.Version);
|
||||
Assert.Equal("5.0.0", plugin.ApiVersion);
|
||||
Assert.Equal("0.0.1", plugin.MinHostVersion);
|
||||
Assert.Equal("https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg", plugin.IconUrl);
|
||||
Assert.Equal("https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop.SamplePlugin/main/README.md", plugin.ReadmeUrl);
|
||||
Assert.Equal("workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp", source.Url);
|
||||
Assert.Equal(PluginPackageSourceKind.WorkspaceLocal, source.SourceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichAsync_WhenRepositoryMetadataUnavailable_PreservesNestedDisplayFields()
|
||||
{
|
||||
var document = AirAppMarketIndexDocument.Load(
|
||||
CreateNestedIndexJson("LanMountainDesktop.MissingPlugin"),
|
||||
"test-index.json");
|
||||
using var httpClient = new HttpClient(new NotFoundHandler());
|
||||
using var resolver = new AirAppMarketMetadataResolverService(httpClient);
|
||||
|
||||
var enriched = await resolver.EnrichAsync(document);
|
||||
var plugin = Assert.Single(enriched.Plugins);
|
||||
|
||||
Assert.Equal("LanMountain Sample Plugin", plugin.Name);
|
||||
Assert.Equal("SDK v5 sample plugin.", plugin.Description);
|
||||
Assert.Equal("LanMountainDesktop", plugin.Author);
|
||||
Assert.Equal("0.4.0", plugin.Version);
|
||||
Assert.Equal("5.0.0", plugin.ApiVersion);
|
||||
Assert.Equal("0.0.1", plugin.MinHostVersion);
|
||||
Assert.Equal("v0.4.0", plugin.ReleaseTag);
|
||||
Assert.Equal("LanMountainDesktop.SamplePlugin.0.4.0.laapp", plugin.ReleaseAssetName);
|
||||
Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin.Sha256);
|
||||
Assert.Equal(1024, plugin.PackageSizeBytes);
|
||||
}
|
||||
|
||||
private static string CreateNestedIndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin")
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"schemaVersion": "2.0.0",
|
||||
"sourceId": "official",
|
||||
"sourceName": "LanAirApp",
|
||||
"generatedAt": "2026-04-29T00:00:00Z",
|
||||
"contracts": [],
|
||||
"plugins": [
|
||||
{
|
||||
"manifest": {
|
||||
"id": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "SDK v5 sample plugin.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "0.4.0",
|
||||
"apiVersion": "5.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll",
|
||||
"sharedContracts": []
|
||||
},
|
||||
"compatibility": {
|
||||
"minHostVersion": "0.0.1",
|
||||
"apiVersion": "5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"projectUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/wwiinnddyy/{{repositoryName}}/main/README.md",
|
||||
"homepageUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"repositoryUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg",
|
||||
"tags": [ "official", "sdk" ],
|
||||
"releaseNotes": "Reference plugin for SDK v5 validation."
|
||||
},
|
||||
"publication": {
|
||||
"releaseTag": "v0.4.0",
|
||||
"releaseAssetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"packageSizeBytes": 1024,
|
||||
"publishedAt": "2026-04-29T00:00:00Z",
|
||||
"updatedAt": "2026-04-29T00:00:00Z",
|
||||
"packageSources": [
|
||||
{
|
||||
"kind": "workspaceLocal",
|
||||
"path": "workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"assetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"sizeBytes": 1024,
|
||||
"releaseTag": "v0.4.0",
|
||||
"priority": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"desktopComponents": [ "LanMountainDesktop.SamplePlugin.StatusClock" ],
|
||||
"settingsSections": [ "status" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class NotFoundHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent("{}")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -78,7 +77,6 @@ public partial class App : Application
|
||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
private PublicIpcHostService? _publicIpcHostService;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
@@ -194,8 +192,6 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigureWebViewUserDataFolder();
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
ApplyThemeFromSettings();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
EnsureSettingsWindowService();
|
||||
@@ -212,8 +208,7 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePublicIpc();
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
@@ -532,43 +527,8 @@ public partial class App : Application
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
// Get an array of plugins to remove
|
||||
var dataValidationPluginsToRemove =
|
||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||
|
||||
// remove each entry found
|
||||
foreach (var plugin in dataValidationPluginsToRemove)
|
||||
{
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureWebViewUserDataFolder()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string userDataFolderEnvVar = "WEBVIEW2_USER_DATA_FOLDER";
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(userDataFolderEnvVar)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userDataFolder = WebView2RuntimeProbe.ResolveUserDataFolder();
|
||||
Environment.SetEnvironmentVariable(
|
||||
userDataFolderEnvVar,
|
||||
userDataFolder,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep startup resilient if user profile folders are unavailable.
|
||||
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
|
||||
}
|
||||
// Avalonia 12 中 BindingPlugins 已移除,数据验证插件不再需要手动禁用
|
||||
// 编译型绑定默认开启,数据注解验证行为已改变
|
||||
}
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
@@ -1178,43 +1138,6 @@ public partial class App : Application
|
||||
_appearanceThemeService.ApplyThemeResources(Resources);
|
||||
}
|
||||
|
||||
private void RegisterUiUnhandledExceptionGuard()
|
||||
{
|
||||
if (_uiUnhandledExceptionHooked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.UnhandledException += OnUiThreadUnhandledException;
|
||||
_uiUnhandledExceptionHooked = true;
|
||||
}
|
||||
|
||||
private void OnUiThreadUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
if (!IsKnownWebViewStartupException(e.Exception))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
AppLogger.Warn(
|
||||
"WebView2",
|
||||
"Suppressed a known WebView startup exception from AvaloniaWebView.Navigate to keep the host process alive.",
|
||||
e.Exception);
|
||||
}
|
||||
|
||||
private static bool IsKnownWebViewStartupException(Exception exception)
|
||||
{
|
||||
if (exception is not NullReferenceException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stackTrace = exception.StackTrace ?? string.Empty;
|
||||
return stackTrace.Contains("AvaloniaWebView.WebView.Navigate", StringComparison.Ordinal) &&
|
||||
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void ReleaseSingleInstanceAfterExit(string source)
|
||||
{
|
||||
if (_singleInstanceReleased)
|
||||
@@ -1959,4 +1882,3 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
internal static class ComponentPreviewRuntimeQuiescer
|
||||
{
|
||||
private static readonly BindingFlags TimerMemberFlags =
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
public static void Attach(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
control.IsHitTestVisible = false;
|
||||
control.Focusable = false;
|
||||
control.AttachedToVisualTree += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => Quiesce(control), DispatcherPriority.Background);
|
||||
control.DetachedFromVisualTree += (_, _) => Quiesce(control);
|
||||
Quiesce(control);
|
||||
}
|
||||
|
||||
public static void Detach(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
Quiesce(control);
|
||||
}
|
||||
|
||||
public static void Quiesce(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
foreach (var candidate in EnumerateControls(control))
|
||||
{
|
||||
StopDispatcherTimers(candidate);
|
||||
candidate.IsHitTestVisible = false;
|
||||
candidate.Focusable = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Control> EnumerateControls(Control root)
|
||||
{
|
||||
yield return root;
|
||||
|
||||
foreach (var descendant in root.GetVisualDescendants().OfType<Control>())
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StopDispatcherTimers(object target)
|
||||
{
|
||||
var type = target.GetType();
|
||||
foreach (var field in type.GetFields(TimerMemberFlags))
|
||||
{
|
||||
if (typeof(DispatcherTimer).IsAssignableFrom(field.FieldType) &&
|
||||
field.GetValue(target) is DispatcherTimer timer)
|
||||
{
|
||||
timer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in type.GetProperties(TimerMemberFlags))
|
||||
{
|
||||
if (!property.CanRead ||
|
||||
property.GetIndexParameters().Length != 0 ||
|
||||
!typeof(DispatcherTimer).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (property.GetValue(target) is DispatcherTimer timer)
|
||||
{
|
||||
timer.Stop();
|
||||
}
|
||||
}
|
||||
catch (TargetInvocationException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public enum DesktopComponentRenderMode
|
||||
{
|
||||
Live = 0,
|
||||
LibraryPreview = 1
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public sealed record DesktopComponentRuntimeContext(
|
||||
IAppearanceThemeService AppearanceTheme,
|
||||
ComponentChromeContext Chrome,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
|
||||
|
||||
@@ -108,7 +108,7 @@ public partial class SettingsOptionCard : UserControl
|
||||
"Info" => Symbol.Info,
|
||||
"ArrowSync" => Symbol.ArrowSync,
|
||||
"Alert" => Symbol.Alert,
|
||||
"Bell" => Symbol.Alert, // Bell也映射到Alert图标
|
||||
"Bell" => Symbol.AlertOn,
|
||||
_ => Symbol.Settings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ public partial class SettingsSectionCard : UserControl
|
||||
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||
"Info" => Symbol.Info,
|
||||
"ArrowSync" => Symbol.ArrowSync,
|
||||
"Bell" => Symbol.AlertOn,
|
||||
_ => Symbol.Settings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
{
|
||||
private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(150);
|
||||
private static readonly Easing TransitionEasing = new CubicEaseOut();
|
||||
private const double StableOpacityThreshold = 0.01;
|
||||
|
||||
private readonly Border _componentLibraryWindow;
|
||||
private readonly Border _collapsedChipHost;
|
||||
@@ -37,9 +36,7 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
_collapsedChipIcon = collapsedChipIcon;
|
||||
|
||||
EnsureTransforms();
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(
|
||||
_componentLibraryWindow.Margin,
|
||||
_componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity);
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(_componentLibraryWindow.Margin);
|
||||
ApplyExpandedSnapshot();
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
@@ -50,19 +47,16 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
|
||||
public ComponentLibraryCollapseVisualState VisualState => _state.VisualState;
|
||||
|
||||
public void SyncExpandedState(Thickness margin, double opacity)
|
||||
public void SyncExpandedState(Thickness margin)
|
||||
{
|
||||
var hasStableOpacity = IsStableExpandedOpacity(opacity);
|
||||
var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity;
|
||||
_state = _state with
|
||||
{
|
||||
ExpandedMargin = margin,
|
||||
ExpandedOpacity = nextExpandedOpacity
|
||||
ExpandedMargin = margin
|
||||
};
|
||||
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring)
|
||||
{
|
||||
ApplyExpandedSnapshot(applyOpacity: hasStableOpacity);
|
||||
ApplyExpandedSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +116,7 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
_componentLibraryWindow.Opacity = 1;
|
||||
_windowTranslate.Y = 0;
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
@@ -190,14 +184,10 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyExpandedSnapshot(bool applyOpacity = true)
|
||||
private void ApplyExpandedSnapshot()
|
||||
{
|
||||
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||
if (applyOpacity)
|
||||
{
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = 1;
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
_windowTranslate.Y = 0;
|
||||
@@ -270,11 +260,4 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
_componentLibraryWindow.Opacity = 0;
|
||||
_windowTranslate.Y = 28;
|
||||
}
|
||||
|
||||
private static bool IsStableExpandedOpacity(double opacity)
|
||||
{
|
||||
return !double.IsNaN(opacity) &&
|
||||
!double.IsInfinity(opacity) &&
|
||||
opacity > StableOpacityThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ internal enum ComponentLibraryCollapseVisualState
|
||||
internal readonly record struct ComponentLibraryCollapseState(
|
||||
ComponentLibraryCollapseVisualState VisualState,
|
||||
Thickness ExpandedMargin,
|
||||
double ExpandedOpacity,
|
||||
bool IsChipVisible)
|
||||
{
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity)
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin)
|
||||
{
|
||||
return new(
|
||||
ComponentLibraryCollapseVisualState.Expanded,
|
||||
expandedMargin,
|
||||
expandedOpacity,
|
||||
IsChipVisible: false);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,44 +41,42 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.12">
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Controls.WebView" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<!--Condition below is needed to remove developer tools support from build output in Release configuration.-->
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||
<PackageReference Include="Downloader" Version="4.1.1" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" />
|
||||
<PackageReference Include="Downloader" />
|
||||
<PackageReference Include="FluentAvaloniaUI" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" />
|
||||
<PackageReference Include="Material.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="MudTools.OfficeInterop" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Word" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Excel" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" />
|
||||
|
||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
||||
<PackageReference Include="PostHog" Version="2.4.0" />
|
||||
<PackageReference Include="Sentry" Version="4.0.0" />
|
||||
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="PortAudioSharp2" />
|
||||
<PackageReference Include="MaterialColorUtilities" />
|
||||
<PackageReference Include="PostHog" />
|
||||
<PackageReference Include="Sentry" />
|
||||
<PackageReference Include="System.Runtime.WindowsRuntime" />
|
||||
<PackageReference Include="System.Drawing.Common" />
|
||||
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
<PackageReference Include="log4net" Version="3.3.0" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" />
|
||||
<PackageReference Include="log4net" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Launcher 构建目标已移除 - Launcher 现在是独立应用,由 CI/CD 单独构建 -->
|
||||
|
||||
@@ -646,6 +646,27 @@
|
||||
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
|
||||
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
|
||||
"settings.update.force_full_label": "Force Full Update",
|
||||
"settings.update.force_full_desc": "Skip incremental update and force download the full installer. Use this if incremental update fails repeatedly.",
|
||||
"settings.update.network_accel_label": "Network Acceleration",
|
||||
"settings.update.network_accel_desc": "Use gh-proxy mirror to accelerate GitHub downloads. Only applies when falling back to GitHub for full updates.",
|
||||
"settings.update.redownload_button": "Redownload",
|
||||
"settings.update.phase_scanning": "Scanning update source...",
|
||||
"settings.update.phase_force_scanning": "Force scanning update source...",
|
||||
"settings.update.phase_locating_resources": "Locating update resources...",
|
||||
"settings.update.phase_force_full": "Forcing full update...",
|
||||
"settings.update.phase_downloading_full": "Downloading full installer...",
|
||||
"settings.update.phase_downloading_delta": "Downloading incremental update...",
|
||||
"settings.update.status_downloading_full": "Downloading full installer...",
|
||||
"settings.update.status_force_full_checking": "Checking for full installer...",
|
||||
"settings.update.status_force_full_failed": "No full installer available.",
|
||||
"settings.update.status_downloaded_no_hash_format": "Update downloaded. Hash: {0}",
|
||||
"settings.update.status_redownload_no_check": "Please check for updates first before redownloading.",
|
||||
"settings.update.status_redownloading": "Redownloading installer...",
|
||||
"settings.update.status_redownload_failed_format": "Redownload failed: {0}",
|
||||
"settings.update.source_plonds": "PLONDS",
|
||||
"settings.update.source_plonds_desc": "Prefer PLONDS distribution endpoints, then automatically fallback to GitHub.",
|
||||
"settings.update.status_check_failed_plonds": "PLONDS update check failed, falling back to GitHub...",
|
||||
"settings.window.drawer_default": "Details",
|
||||
"market.toolbar.search_placeholder": "Search plugins",
|
||||
"market.toolbar.refresh": "Refresh",
|
||||
|
||||
@@ -579,6 +579,27 @@
|
||||
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
||||
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})",
|
||||
"settings.update.status_up_to_date_format": "最新版です({0})。",
|
||||
"settings.update.force_full_label": "完全更新を強制",
|
||||
"settings.update.force_full_desc": "差分更新をスキップし、完全インストーラを強制ダウンロードします。差分更新が繰り返し失敗する場合に使用してください。",
|
||||
"settings.update.network_accel_label": "ネットワーク高速化",
|
||||
"settings.update.network_accel_desc": "gh-proxyミラーを使用してGitHubダウンロードを加速します。GitHubフルアップデートにフォールバック時のみ適用されます。",
|
||||
"settings.update.redownload_button": "再ダウンロード",
|
||||
"settings.update.phase_scanning": "更新ソースをスキャン中...",
|
||||
"settings.update.phase_force_scanning": "更新ソースを強制スキャン中...",
|
||||
"settings.update.phase_locating_resources": "更新リソースを特定中...",
|
||||
"settings.update.phase_force_full": "完全更新を強制中...",
|
||||
"settings.update.phase_downloading_full": "完全インストーラをダウンロード中...",
|
||||
"settings.update.phase_downloading_delta": "差分更新をダウンロード中...",
|
||||
"settings.update.status_downloading_full": "完全インストーラをダウンロード中...",
|
||||
"settings.update.status_force_full_checking": "完全インストーラを確認中...",
|
||||
"settings.update.status_force_full_failed": "利用可能な完全インストーラがありません。",
|
||||
"settings.update.status_downloaded_no_hash_format": "更新がダウンロードされました。ハッシュ:{0}",
|
||||
"settings.update.status_redownload_no_check": "再ダウンロードする前に更新を確認してください。",
|
||||
"settings.update.status_redownloading": "インストーラを再ダウンロード中...",
|
||||
"settings.update.status_redownload_failed_format": "再ダウンロードに失敗しました:{0}",
|
||||
"settings.update.source_plonds": "PLONDS",
|
||||
"settings.update.source_plonds_desc": "PLONDS配信エンドポイントを優先し、利用不可時にGitHubに自動フォールバックします。",
|
||||
"settings.update.status_check_failed_plonds": "PLONDS更新確認に失敗しました。GitHubにフォールバック中...",
|
||||
"settings.window.drawer_default": "詳細",
|
||||
"market.toolbar.search_placeholder": "プラグインを検索",
|
||||
"market.toolbar.refresh": "更新",
|
||||
|
||||
@@ -624,6 +624,27 @@
|
||||
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
||||
"settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).",
|
||||
"settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).",
|
||||
"settings.update.force_full_label": "전체 업데이트 강제",
|
||||
"settings.update.force_full_desc": "증분 업데이트를 건너뛰고 전체 설치 프로그램을 강제로 다운로드합니다. 증분 업데이트가 반복적으로 실패할 때 사용하세요.",
|
||||
"settings.update.network_accel_label": "네트워크 가속",
|
||||
"settings.update.network_accel_desc": "gh-proxy 미러를 사용하여 GitHub 다운로드를 가속합니다. GitHub 전체 업데이트로 대체될 때만 적용됩니다.",
|
||||
"settings.update.redownload_button": "다시 다운로드",
|
||||
"settings.update.phase_scanning": "업데이트 소스 스캔 중...",
|
||||
"settings.update.phase_force_scanning": "업데이트 소스 강제 스캔 중...",
|
||||
"settings.update.phase_locating_resources": "업데이트 리소스 찾는 중...",
|
||||
"settings.update.phase_force_full": "전체 업데이트 강제 중...",
|
||||
"settings.update.phase_downloading_full": "전체 설치 프로그램 다운로드 중...",
|
||||
"settings.update.phase_downloading_delta": "증분 업데이트 다운로드 중...",
|
||||
"settings.update.status_downloading_full": "전체 설치 프로그램 다운로드 중...",
|
||||
"settings.update.status_force_full_checking": "전체 설치 프로그램 확인 중...",
|
||||
"settings.update.status_force_full_failed": "사용 가능한 전체 설치 프로그램이 없습니다.",
|
||||
"settings.update.status_downloaded_no_hash_format": "업데이트가 다운로드되었습니다. 해시: {0}",
|
||||
"settings.update.status_redownload_no_check": "다시 다운로드하기 전에 업데이트를 확인하세요.",
|
||||
"settings.update.status_redownloading": "설치 프로그램 다시 다운로드 중...",
|
||||
"settings.update.status_redownload_failed_format": "다시 다운로드 실패: {0}",
|
||||
"settings.update.source_plonds": "PLONDS",
|
||||
"settings.update.source_plonds_desc": "PLONDS 배포 엔드포인트를 우선 사용하며, 사용 불가 시 GitHub로 자동 대체합니다.",
|
||||
"settings.update.status_check_failed_plonds": "PLONDS 업데이트 확인 실패, GitHub로 대체 중...",
|
||||
"settings.window.drawer_default": "상세 정보",
|
||||
"market.toolbar.search_placeholder": "플러그인 검색",
|
||||
"market.toolbar.refresh": "새로고침",
|
||||
|
||||
@@ -494,11 +494,11 @@
|
||||
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
||||
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
||||
"settings.about.description": "应用信息。",
|
||||
"settings.update.description": "检查更新、选择发布通道与下载源,并控制更新安装方式。",
|
||||
"settings.update.description": "检查更新、选择发布通道与安装方式,并控制更新行为。",
|
||||
"settings.update.status_card_title": "更新状态",
|
||||
"settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。",
|
||||
"settings.update.preferences_header": "更新偏好",
|
||||
"settings.update.preferences_description": "选择发布通道、安装包下载源、安装方式以及下载并行线程数。",
|
||||
"settings.update.preferences_description": "选择发布通道、安装方式、网络加速以及下载并行线程数。",
|
||||
"settings.update.last_checked_label": "上次检查",
|
||||
"settings.update.source_label": "下载源",
|
||||
"settings.update.source_github": "GitHub",
|
||||
@@ -640,6 +640,27 @@
|
||||
"settings.update.status_check_failed": "检查更新失败。",
|
||||
"settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})。",
|
||||
"settings.update.status_up_to_date_format": "当前已是最新版本({0})。",
|
||||
"settings.update.force_full_label": "强制完整更新",
|
||||
"settings.update.force_full_desc": "跳过增量更新,直接下载完整安装包。如果增量更新反复失败,可使用此项。",
|
||||
"settings.update.network_accel_label": "网络加速",
|
||||
"settings.update.network_accel_desc": "使用 gh-proxy 镜像加速 GitHub 下载。仅在回退到 GitHub 全量更新时生效。",
|
||||
"settings.update.redownload_button": "重新下载",
|
||||
"settings.update.phase_scanning": "正在扫描更新源...",
|
||||
"settings.update.phase_force_scanning": "正在强制扫描更新源...",
|
||||
"settings.update.phase_locating_resources": "正在定位更新资源...",
|
||||
"settings.update.phase_force_full": "正在强制完整更新...",
|
||||
"settings.update.phase_downloading_full": "正在下载完整安装包...",
|
||||
"settings.update.phase_downloading_delta": "正在下载增量更新...",
|
||||
"settings.update.status_downloading_full": "正在下载完整安装包...",
|
||||
"settings.update.status_force_full_checking": "正在检查完整安装包...",
|
||||
"settings.update.status_force_full_failed": "没有可用的完整安装包。",
|
||||
"settings.update.status_downloaded_no_hash_format": "更新已下载。哈希值:{0}",
|
||||
"settings.update.status_redownload_no_check": "请先检查更新后再重新下载。",
|
||||
"settings.update.status_redownloading": "正在重新下载安装包...",
|
||||
"settings.update.status_redownload_failed_format": "重新下载失败:{0}",
|
||||
"settings.update.source_plonds": "PLONDS",
|
||||
"settings.update.source_plonds_desc": "优先使用 PLONDS 分发端点,不可用时自动回退到 GitHub。",
|
||||
"settings.update.status_check_failed_plonds": "PLONDS 更新检查失败,正在回退到 GitHub...",
|
||||
"settings.window.drawer_default": "详情",
|
||||
"market.toolbar.search_placeholder": "搜索插件",
|
||||
"market.toolbar.refresh": "刷新",
|
||||
|
||||
@@ -87,7 +87,9 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||
|
||||
public string UpdateDownloadSource { get; set; } = "stcn";
|
||||
public string UpdateDownloadSource { get; set; } = "plonds-api";
|
||||
|
||||
public bool UseGhProxyMirror { get; set; }
|
||||
|
||||
public int UpdateDownloadThreads { get; set; } = 4;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
@@ -111,7 +110,6 @@ public sealed class Program
|
||||
{
|
||||
var builder = AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.UseDesktopWebView()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ internal sealed class ComponentLibraryService : IComponentLibraryService
|
||||
context.RecommendationInfoService,
|
||||
context.CalculatorDataService,
|
||||
context.SettingsFacade,
|
||||
context.PlacementId);
|
||||
context.PlacementId,
|
||||
context.RenderMode);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentPreviewImageService : IComponentPreviewImageService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<ComponentPreviewKey, ComponentPreviewImageEntry> _entries = new(ComponentPreviewKeyComparer.Instance);
|
||||
private readonly Dictionary<ComponentPreviewKey, Task<ComponentPreviewImageEntry>> _inFlightRequests = new(ComponentPreviewKeyComparer.Instance);
|
||||
private Task _queueTail = Task.CompletedTask;
|
||||
|
||||
public ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new ComponentPreviewImageEntry(key, visualSignature);
|
||||
_entries[key] = created;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
entry = existing;
|
||||
return true;
|
||||
}
|
||||
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _entries.Values.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(generationWork);
|
||||
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
|
||||
if (entry.State == ComponentPreviewImageState.Ready &&
|
||||
entry.Bitmap is not null &&
|
||||
StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||
{
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
if (_inFlightRequests.TryGetValue(key, out var inFlight))
|
||||
{
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
var expectedRevision = entry.BeginGeneration(normalizedSignature);
|
||||
var previousTask = _queueTail;
|
||||
var queuedTask = RunGenerationAsync(
|
||||
previousTask,
|
||||
key,
|
||||
entry,
|
||||
expectedRevision,
|
||||
normalizedSignature,
|
||||
generationWork,
|
||||
cancellationToken);
|
||||
|
||||
_inFlightRequests[key] = queuedTask;
|
||||
_queueTail = queuedTask.ContinueWith(
|
||||
static _ => { },
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
return queuedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
entry.StoreBitmap(bitmap, normalizedSignature);
|
||||
_inFlightRequests.Remove(key);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null)
|
||||
{
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
entry.StoreFailure(normalizedSignature, errorMessage);
|
||||
_inFlightRequests.Remove(key);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Invalidate(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.Invalidate(visualSignature);
|
||||
_inFlightRequests.Remove(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int RemovePlacementPreviews(string placementId)
|
||||
{
|
||||
var normalizedPlacementId = NormalizeRequired(placementId, nameof(placementId));
|
||||
lock (_gate)
|
||||
{
|
||||
var entriesToRemove = _entries
|
||||
.Where(static pair => pair.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
.Where(pair => StringComparer.OrdinalIgnoreCase.Equals(pair.Key.PlacementId, normalizedPlacementId))
|
||||
.ToArray();
|
||||
|
||||
foreach (var pair in entriesToRemove)
|
||||
{
|
||||
pair.Value.DisposeBitmap();
|
||||
_entries.Remove(pair.Key);
|
||||
_inFlightRequests.Remove(pair.Key);
|
||||
}
|
||||
|
||||
return entriesToRemove.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public int InvalidateVisualSignature(string visualSignature)
|
||||
{
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entriesToInvalidate = _entries.Values
|
||||
.Where(entry => StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||
.ToArray();
|
||||
|
||||
foreach (var entry in entriesToInvalidate)
|
||||
{
|
||||
entry.Invalidate(normalizedSignature);
|
||||
_inFlightRequests.Remove(entry.Key);
|
||||
}
|
||||
|
||||
return entriesToInvalidate.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ComponentPreviewImageEntry> RunGenerationAsync(
|
||||
Task previousTask,
|
||||
ComponentPreviewKey key,
|
||||
ComponentPreviewImageEntry entry,
|
||||
long expectedRevision,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await previousTask.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep serial queue processing even if previous work faulted.
|
||||
}
|
||||
|
||||
IImage? bitmap;
|
||||
try
|
||||
{
|
||||
bitmap = await generationWork(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
entry.TryApplyFailure(expectedRevision, visualSignature, ex.Message);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (bitmap is null)
|
||||
{
|
||||
entry.TryApplyFailure(expectedRevision, visualSignature, "Preview generation returned no bitmap.");
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.TryApplyGeneratedBitmap(expectedRevision, bitmap, visualSignature);
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_inFlightRequests.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ComponentPreviewImageEntry GetOrCreateEntryCore(ComponentPreviewKey key)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new ComponentPreviewImageEntry(key);
|
||||
_entries[key] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum ComponentPreviewKeyKind
|
||||
{
|
||||
ComponentType = 0,
|
||||
PlacementInstance = 1
|
||||
}
|
||||
|
||||
public readonly record struct ComponentPreviewKey
|
||||
{
|
||||
private ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind kind,
|
||||
string componentTypeId,
|
||||
string? placementId,
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
Kind = kind;
|
||||
ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId));
|
||||
PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance
|
||||
? NormalizeRequired(placementId, nameof(placementId))
|
||||
: null;
|
||||
WidthCells = NormalizeSpan(widthCells, nameof(widthCells));
|
||||
HeightCells = NormalizeSpan(heightCells, nameof(heightCells));
|
||||
}
|
||||
|
||||
public ComponentPreviewKeyKind Kind { get; }
|
||||
|
||||
public string ComponentTypeId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public int WidthCells { get; }
|
||||
|
||||
public int HeightCells { get; }
|
||||
|
||||
public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells);
|
||||
}
|
||||
|
||||
public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind.PlacementInstance,
|
||||
componentTypeId,
|
||||
placementId,
|
||||
widthCells,
|
||||
heightCells);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Kind == ComponentPreviewKeyKind.ComponentType
|
||||
? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]"
|
||||
: $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]";
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static int NormalizeSpan(int value, string paramName)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ComponentPreviewImageState
|
||||
{
|
||||
Pending = 0,
|
||||
Ready = 1,
|
||||
Failed = 2
|
||||
}
|
||||
|
||||
public sealed class ComponentPreviewImageEntry : ObservableObject
|
||||
{
|
||||
private IImage? _bitmap;
|
||||
private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending;
|
||||
private string _visualSignature = string.Empty;
|
||||
private string? _errorMessage;
|
||||
private long _revision;
|
||||
private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
Key = key;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey Key { get; }
|
||||
|
||||
public IImage? Bitmap
|
||||
{
|
||||
get => _bitmap;
|
||||
private set => SetProperty(ref _bitmap, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageState State
|
||||
{
|
||||
get => _state;
|
||||
private set => SetProperty(ref _state, value);
|
||||
}
|
||||
|
||||
public string VisualSignature
|
||||
{
|
||||
get => _visualSignature;
|
||||
private set => SetProperty(ref _visualSignature, value);
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
|
||||
public long Revision
|
||||
{
|
||||
get => _revision;
|
||||
private set => SetProperty(ref _revision, value);
|
||||
}
|
||||
|
||||
public DateTimeOffset LastUpdatedUtc
|
||||
{
|
||||
get => _lastUpdatedUtc;
|
||||
private set => SetProperty(ref _lastUpdatedUtc, value);
|
||||
}
|
||||
|
||||
internal long BeginGeneration(string visualSignature)
|
||||
{
|
||||
var normalizedVisualSignature = NormalizeSignature(visualSignature);
|
||||
var nextRevision = Revision + 1;
|
||||
Revision = nextRevision;
|
||||
VisualSignature = normalizedVisualSignature;
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return nextRevision;
|
||||
}
|
||||
|
||||
internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
DisposeIfNeeded(bitmap);
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage)
|
||||
{
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void StoreBitmap(IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void StoreFailure(string visualSignature, string? errorMessage)
|
||||
{
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void Invalidate(string? visualSignature = null)
|
||||
{
|
||||
Revision += 1;
|
||||
if (visualSignature is not null)
|
||||
{
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void DisposeBitmap()
|
||||
{
|
||||
ReplaceBitmap(null);
|
||||
}
|
||||
|
||||
private void ReplaceBitmap(IImage? bitmap)
|
||||
{
|
||||
var previous = _bitmap;
|
||||
if (ReferenceEquals(previous, bitmap))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap = bitmap;
|
||||
DisposeIfNeeded(previous);
|
||||
}
|
||||
|
||||
private static void DisposeIfNeeded(IImage? bitmap)
|
||||
{
|
||||
if (bitmap is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeSignature(string? visualSignature)
|
||||
{
|
||||
return visualSignature?.Trim() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ComponentPreviewKeyComparer : IEqualityComparer<ComponentPreviewKey>
|
||||
{
|
||||
public static ComponentPreviewKeyComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(ComponentPreviewKey x, ComponentPreviewKey y)
|
||||
{
|
||||
return x.Kind == y.Kind &&
|
||||
StringComparer.OrdinalIgnoreCase.Equals(x.ComponentTypeId, y.ComponentTypeId) &&
|
||||
StringComparer.OrdinalIgnoreCase.Equals(x.PlacementId, y.PlacementId) &&
|
||||
x.WidthCells == y.WidthCells &&
|
||||
x.HeightCells == y.HeightCells;
|
||||
}
|
||||
|
||||
public int GetHashCode(ComponentPreviewKey obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Kind);
|
||||
hash.Add(obj.ComponentTypeId, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.PlacementId, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.WidthCells);
|
||||
hash.Add(obj.HeightCells);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ public sealed record ComponentLibraryCreateContext(
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
ICalculatorDataService CalculatorDataService,
|
||||
ISettingsFacadeService SettingsFacade,
|
||||
string? PlacementId = null);
|
||||
string? PlacementId = null,
|
||||
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
|
||||
|
||||
public interface IComponentLibraryService
|
||||
{
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IComponentPreviewImageService
|
||||
{
|
||||
ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null);
|
||||
|
||||
bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry);
|
||||
|
||||
IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot();
|
||||
|
||||
Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature);
|
||||
|
||||
ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null);
|
||||
|
||||
bool Invalidate(ComponentPreviewKey key, string? visualSignature = null);
|
||||
|
||||
int RemovePlacementPreviews(string placementId);
|
||||
|
||||
int InvalidateVisualSignature(string visualSignature);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -14,7 +15,6 @@ namespace LanMountainDesktop.Services;
|
||||
internal sealed class LauncherClient
|
||||
{
|
||||
private const int UserCanceledUacErrorCode = 1223;
|
||||
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
|
||||
|
||||
public async Task<LauncherInstallResult> InstallPackageAsync(
|
||||
string packagePath,
|
||||
@@ -33,13 +33,13 @@ internal sealed class LauncherClient
|
||||
"failed");
|
||||
}
|
||||
|
||||
var launcherPath = ResolveLauncherPath();
|
||||
if (!File.Exists(launcherPath))
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"Launcher executable was not found at '{launcherPath}'.",
|
||||
"Launcher executable was not found. Expected it to be located in the application root directory (sibling to the app-* deployment folder).",
|
||||
"failed");
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ internal sealed class LauncherClient
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
|
||||
$"plugin install --source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
|
||||
};
|
||||
|
||||
return Process.Start(startInfo);
|
||||
@@ -128,11 +128,6 @@ internal sealed class LauncherClient
|
||||
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
|
||||
90
LanMountainDesktop/Services/LauncherPathResolver.cs
Normal file
90
LanMountainDesktop/Services/LauncherPathResolver.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 统一解析 Launcher 可执行文件路径的工具类。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 安装后的目录结构:
|
||||
/// <code>
|
||||
/// {AppRoot}/ ← 应用安装根目录
|
||||
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
|
||||
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
|
||||
/// app-{version}/ ← Host 部署目录
|
||||
/// LanMountainDesktop.exe
|
||||
/// ...
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
internal static class LauncherPathResolver
|
||||
{
|
||||
private const string WindowsLauncherExeName = "LanMountainDesktop.Launcher.exe";
|
||||
private const string UnixLauncherExeName = "LanMountainDesktop.Launcher";
|
||||
|
||||
private static string LauncherExecutableName =>
|
||||
OperatingSystem.IsWindows() ? WindowsLauncherExeName : UnixLauncherExeName;
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Launcher 可执行文件的完整路径。如果找不到则返回 null。
|
||||
/// </summary>
|
||||
public static string? ResolveLauncherExecutablePath()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
|
||||
var candidates = new[]
|
||||
{
|
||||
// 1. 发布版(安装版):Host 在 app-* 子目录中,Launcher 在父目录(应用根目录)
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", LauncherExecutableName)),
|
||||
|
||||
// 2. 便携版 / 单文件发布:Launcher 与 Host 在同一目录
|
||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
||||
|
||||
// 3. 开发环境:Launcher 项目输出目录与 Host 项目输出目录同级
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName)),
|
||||
};
|
||||
|
||||
return candidates
|
||||
.Select(Path.GetFullPath)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Launcher 数据目录(.Launcher)的路径。
|
||||
/// 该目录与 app-* 文件夹同级,位于应用安装根目录下。
|
||||
/// </summary>
|
||||
public static string ResolveLauncherDataDirectory()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
|
||||
// 优先尝试应用安装根目录(Host 的父目录)
|
||||
var appRootCandidate = Path.GetFullPath(Path.Combine(baseDirectory, ".."));
|
||||
var launcherDataDir = Path.Combine(appRootCandidate, ".Launcher");
|
||||
|
||||
if (Directory.Exists(launcherDataDir) || CanWriteToDirectory(appRootCandidate))
|
||||
{
|
||||
return launcherDataDir;
|
||||
}
|
||||
|
||||
// 回退到 Host 所在目录(便携模式或开发环境)
|
||||
return Path.Combine(baseDirectory, ".Launcher");
|
||||
}
|
||||
|
||||
private static bool CanWriteToDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(path, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(testFile, string.Empty);
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,8 +88,6 @@ public sealed class MonetColorService
|
||||
PixelFormat.Bgra8888,
|
||||
AlphaFormat.Premul);
|
||||
using var framebuffer = writeable.Lock();
|
||||
scaledBitmap.CopyPixels(framebuffer, AlphaFormat.Premul);
|
||||
|
||||
var byteCount = framebuffer.RowBytes * framebuffer.Size.Height;
|
||||
if (byteCount <= 0 || framebuffer.Address == IntPtr.Zero)
|
||||
{
|
||||
@@ -97,6 +95,11 @@ public sealed class MonetColorService
|
||||
}
|
||||
|
||||
var pixelBuffer = new byte[byteCount];
|
||||
scaledBitmap.CopyPixels(
|
||||
new PixelRect(scaledBitmap.PixelSize),
|
||||
framebuffer.Address,
|
||||
byteCount,
|
||||
framebuffer.RowBytes);
|
||||
Marshal.Copy(framebuffer.Address, pixelBuffer, 0, byteCount);
|
||||
|
||||
var argbPixels = new List<uint>(framebuffer.Size.Width * framebuffer.Size.Height);
|
||||
|
||||
@@ -65,7 +65,7 @@ public interface INotificationService
|
||||
{
|
||||
void Show(NotificationContent content);
|
||||
|
||||
Task<ContentDialogResult> ShowDialogAsync(NotificationContent content);
|
||||
Task<FAContentDialogResult> ShowDialogAsync(NotificationContent content);
|
||||
|
||||
void ShowInfo(string title, string? message = null,
|
||||
NotificationPosition position = NotificationPosition.TopRight);
|
||||
@@ -79,17 +79,17 @@ public interface INotificationService
|
||||
void ShowError(string title, string? message = null,
|
||||
NotificationPosition position = NotificationPosition.TopRight);
|
||||
|
||||
Task<ContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
}
|
||||
|
||||
internal sealed class NotificationService : INotificationService
|
||||
@@ -105,20 +105,17 @@ internal sealed class NotificationService : INotificationService
|
||||
|
||||
public void Show(NotificationContent content)
|
||||
{
|
||||
// 检查通知开关是否启用
|
||||
if (!IsNotificationEnabled())
|
||||
{
|
||||
return; // 通知已禁用,不显示
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a dialog notification (center position), show as dialog window
|
||||
if (content.IsDialogNotification)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ShowDialogWindow(content), DispatcherPriority.Normal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, show as toast notification
|
||||
Dispatcher.UIThread.Post(() => ShowCore(content), DispatcherPriority.Normal);
|
||||
}
|
||||
|
||||
@@ -153,37 +150,35 @@ internal sealed class NotificationService : INotificationService
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ContentDialogResult> ShowDialogAsync(NotificationContent content)
|
||||
public async Task<FAContentDialogResult> ShowDialogAsync(NotificationContent content)
|
||||
{
|
||||
// 检查通知开关是否启用
|
||||
if (!IsNotificationEnabled())
|
||||
{
|
||||
return ContentDialogResult.None; // 通知已禁用,不显示
|
||||
return FAContentDialogResult.None;
|
||||
}
|
||||
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowDialogCoreAsync(content));
|
||||
}
|
||||
|
||||
private async Task<ContentDialogResult> ShowDialogCoreAsync(NotificationContent content)
|
||||
private async Task<FAContentDialogResult> ShowDialogCoreAsync(NotificationContent content)
|
||||
{
|
||||
// Get the main window as the dialog host
|
||||
var mainWindow = GetMainWindow();
|
||||
if (mainWindow is null)
|
||||
{
|
||||
AppLogger.Warn("Notification", "Cannot show dialog notification: main window not found");
|
||||
return ContentDialogResult.None;
|
||||
return FAContentDialogResult.None;
|
||||
}
|
||||
|
||||
var dialog = new ContentDialog
|
||||
var dialog = new FAContentDialog
|
||||
{
|
||||
Title = content.Title,
|
||||
Content = content.Message ?? string.Empty,
|
||||
PrimaryButtonText = content.PrimaryButtonText,
|
||||
SecondaryButtonText = content.SecondaryButtonText,
|
||||
CloseButtonText = content.CloseButtonText,
|
||||
DefaultButton = !string.IsNullOrEmpty(content.PrimaryButtonText) ? ContentDialogButton.Primary :
|
||||
!string.IsNullOrEmpty(content.SecondaryButtonText) ? ContentDialogButton.Secondary :
|
||||
ContentDialogButton.Close
|
||||
DefaultButton = !string.IsNullOrEmpty(content.PrimaryButtonText) ? FAContentDialogButton.Primary :
|
||||
!string.IsNullOrEmpty(content.SecondaryButtonText) ? FAContentDialogButton.Secondary :
|
||||
FAContentDialogButton.Close
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync(mainWindow);
|
||||
@@ -191,10 +186,10 @@ internal sealed class NotificationService : INotificationService
|
||||
// Execute callbacks based on result
|
||||
switch (result)
|
||||
{
|
||||
case ContentDialogResult.Primary:
|
||||
case FAContentDialogResult.Primary:
|
||||
content.OnPrimaryButtonClick?.Invoke();
|
||||
break;
|
||||
case ContentDialogResult.Secondary:
|
||||
case FAContentDialogResult.Secondary:
|
||||
content.OnSecondaryButtonClick?.Invoke();
|
||||
break;
|
||||
}
|
||||
@@ -206,14 +201,13 @@ internal sealed class NotificationService : INotificationService
|
||||
{
|
||||
try
|
||||
{
|
||||
// 从全局设置服务中读取通知开关状态
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(PluginSdk.SettingsScope.App);
|
||||
return snapshot.NotificationEnabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果读取失败,默认启用通知
|
||||
// 濠电姷顣介埀顒€鍟块埀顒€缍婇幃妯诲緞鐏炴儳鐝伴柣鐘叉处瑜板啰寰婇崹顕呯唵闁诡垱澹嗙花鍧楁偡濞嗘瑧鐣甸柡浣哥Т閻f繈宕熼鐐殿偧闂佽崵濮抽梽宥夊磹閺囥垹绠氶幖娣妽閸嬨劑鏌曟繛鐐澒闁稿鎸搁~婵囨綇閳轰礁缁?
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -286,8 +280,8 @@ internal sealed class NotificationService : INotificationService
|
||||
Show(new NotificationContent(title, message, Severity: NotificationSeverity.Error, Position: position));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -298,8 +292,8 @@ internal sealed class NotificationService : INotificationService
|
||||
CloseButtonText: closeButtonText));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -310,8 +304,8 @@ internal sealed class NotificationService : INotificationService
|
||||
CloseButtonText: closeButtonText));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -322,8 +316,8 @@ internal sealed class NotificationService : INotificationService
|
||||
CloseButtonText: closeButtonText));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -357,7 +351,7 @@ internal sealed class NotificationWindowManager
|
||||
var position = viewModel.Position;
|
||||
var windows = _windowsByPosition[position];
|
||||
|
||||
// 从设置中读取最大通知数量
|
||||
// 濠电偛顕慨鏉戭潩閿曞偆鏁婇柡鍥╁Х绾剧偓銇勯弮鈧Σ鎺楀储椤掑嫭鍋i柛銉憾閸ゆ瑧鎲搁弶鎸庡枠鐎殿喚鏁婚崺鈧い鎺嶇缁剁偟鎲搁弮鍫濈劦妞ゆ帊鐒﹂惃鎴︽煟閹垮嫮绡€鐎殿噮鍋呯€靛ジ寮堕幋鐑嗕画
|
||||
var maxNotifications = GetMaxNotificationsPerPosition();
|
||||
|
||||
if (windows.Count >= maxNotifications)
|
||||
@@ -395,14 +389,13 @@ internal sealed class NotificationWindowManager
|
||||
{
|
||||
try
|
||||
{
|
||||
// 从全局设置服务中读取最大通知数量
|
||||
// 濠电偛顕慨瀛橆殽閹间礁鐭楅煫鍥ㄦ磻濞岊亪鏌嶈閸撴盯骞忕€n喖绀堢憸蹇涘几閸岀偞鐓涢柛顐g箘瀛濇繝娈垮枤閸犳劗绮欐径鎰垫晣闁宠棄妫楀▓娲⒑閸涘﹦鎳勯柣妤侇殔閵嗘帡鎳滈棃娑氱獮閻熸粍妫冮崺鈧い鎺嶇劍閻ㄦ垿鏌i幙鍕瘈鐎殿噮鍋呯€靛ジ寮堕幋鐑嗕画
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(PluginSdk.SettingsScope.App);
|
||||
return snapshot.NotificationMaxPerPosition > 0 ? snapshot.NotificationMaxPerPosition : 5;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果读取失败,返回默认值
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ public sealed class ResumableDownloadService
|
||||
ParallelDownload = useParallelDownload,
|
||||
MinimumSizeOfChunking = options.ParallelThresholdBytes,
|
||||
MaxTryAgainOnFailure = 3,
|
||||
ResumeDownloadIfCan = true,
|
||||
EnableAutoResumeDownload = true,
|
||||
ClearPackageOnCompletionWithFailure = false,
|
||||
FileExistPolicy = FileExistPolicy.Delete,
|
||||
DownloadFileExtension = ".part"
|
||||
|
||||
@@ -337,12 +337,10 @@ public sealed class SentryCrashTelemetryService : IDisposable
|
||||
{
|
||||
scope.SetExtra("log_tail", logTail);
|
||||
scope.SetExtra("log_tail_line_count", logTail.Count(character => character == '\n') + 1);
|
||||
var attachment = new Attachment(
|
||||
AttachmentType.Default,
|
||||
new ByteAttachmentContent(Encoding.UTF8.GetBytes(logTail)),
|
||||
scope.AddAttachment(
|
||||
Encoding.UTF8.GetBytes(logTail),
|
||||
"log-tail.txt",
|
||||
"text/plain");
|
||||
scope.AddAttachment(attachment);
|
||||
contentType: "text/plain");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ public sealed record UpdateSettingsState(
|
||||
string UpdateMode,
|
||||
string UpdateDownloadSource,
|
||||
int UpdateDownloadThreads,
|
||||
bool UseGhProxyMirror,
|
||||
string? PendingUpdateInstallerPath,
|
||||
string? PendingUpdateVersion,
|
||||
long? PendingUpdatePublishedAtUtcMs,
|
||||
|
||||
@@ -789,6 +789,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
|
||||
UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource),
|
||||
UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads),
|
||||
snapshot.UseGhProxyMirror,
|
||||
snapshot.PendingUpdateInstallerPath,
|
||||
snapshot.PendingUpdateVersion,
|
||||
snapshot.PendingUpdatePublishedAtUtcMs,
|
||||
@@ -810,6 +811,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
|
||||
snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource);
|
||||
snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
|
||||
snapshot.UseGhProxyMirror = state.UseGhProxyMirror;
|
||||
snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath)
|
||||
? null
|
||||
: state.PendingUpdateInstallerPath.Trim();
|
||||
@@ -836,6 +838,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
nameof(AppSettingsSnapshot.UpdateMode),
|
||||
nameof(AppSettingsSnapshot.UpdateDownloadSource),
|
||||
nameof(AppSettingsSnapshot.UpdateDownloadThreads),
|
||||
nameof(AppSettingsSnapshot.UseGhProxyMirror),
|
||||
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
|
||||
nameof(AppSettingsSnapshot.PendingUpdateVersion),
|
||||
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
|
||||
@@ -918,45 +921,37 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
|
||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||
var plondsResult = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (plondsResult.Success)
|
||||
{
|
||||
var plondsResult = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (plondsResult.Success)
|
||||
{
|
||||
return plondsResult;
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||
|
||||
var githubFallbackResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (githubFallbackResult.Success)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
return githubFallbackResult;
|
||||
return plondsResult;
|
||||
}
|
||||
|
||||
return isForce
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||
|
||||
var githubFallbackResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (githubFallbackResult.Success)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
return githubFallbackResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1236,7 +1231,31 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
repository,
|
||||
publication,
|
||||
sources,
|
||||
[]);
|
||||
BuildCapabilities(entry));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginCapabilityInfo> BuildCapabilities(AirAppMarketPluginEntry entry)
|
||||
{
|
||||
if (entry.Capabilities is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var capabilities = new List<PluginCapabilityInfo>();
|
||||
capabilities.AddRange(entry.Capabilities.SharedContracts.Select(contract =>
|
||||
new PluginCapabilityInfo(contract.Id, contract.Version, contract.AssemblyName)));
|
||||
capabilities.AddRange(entry.Capabilities.DesktopComponents.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.SettingsSections.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.Exports.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.MessageTypes.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
|
||||
return capabilities
|
||||
.DistinctBy(capability => $"{capability.Id}@{capability.Version}@{capability.AssemblyName}")
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class CliLauncherUpdateBridge : ILauncherUpdateBridge
|
||||
{
|
||||
public Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
return Task.FromResult(new LaunchResult(false, "Launcher executable not found.", null));
|
||||
}
|
||||
|
||||
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = resolvedLauncherRoot
|
||||
};
|
||||
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return Task.FromResult(new LaunchResult(false, "Failed to start Launcher process.", null));
|
||||
}
|
||||
|
||||
return Task.FromResult(new LaunchResult(true, null, process.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new LaunchResult(false, ex.Message, null));
|
||||
}
|
||||
}
|
||||
|
||||
public IObservable<InstallProgressReport> ProgressStream => ObservableHelper<InstallProgressReport>.Empty;
|
||||
|
||||
public Task<bool> SupportsIpcAsync() => Task.FromResult(false);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class CompositeManifestProvider : IUpdateManifestProvider
|
||||
{
|
||||
private readonly IUpdateManifestProvider _primary;
|
||||
private readonly IUpdateManifestProvider _fallback;
|
||||
|
||||
public string ProviderName => $"{_primary.ProviderName}+{_fallback.ProviderName}";
|
||||
|
||||
public CompositeManifestProvider(IUpdateManifestProvider primary, IUpdateManifestProvider fallback)
|
||||
{
|
||||
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
|
||||
_fallback = fallback ?? throw new ArgumentNullException(nameof(fallback));
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetLatestAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _primary.GetLatestAsync(channel, platform, currentVersion, ct);
|
||||
if (result is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"{_primary.ProviderName} GetLatestAsync failed: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetLatestAsync");
|
||||
return await _fallback.GetLatestAsync(channel, platform, currentVersion, ct);
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||
string version,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _primary.GetByVersionAsync(version, channel, platform, ct);
|
||||
if (result is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"{_primary.ProviderName} GetByVersionAsync failed: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetByVersionAsync");
|
||||
return await _fallback.GetByVersionAsync(version, channel, platform, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version fromVersion,
|
||||
Version toVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _primary.GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct);
|
||||
if (result is not null && result.Count > 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"{_primary.ProviderName} GetIncrementalChainAsync failed: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetIncrementalChainAsync");
|
||||
return await _fallback.GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
{
|
||||
private readonly GitHubReleaseUpdateService _githubService;
|
||||
private readonly bool _ownsService;
|
||||
|
||||
public string ProviderName => "github-release";
|
||||
|
||||
public GithubReleaseManifestProvider(string owner, string repo, GitHubReleaseUpdateService? githubService = null)
|
||||
{
|
||||
if (githubService is null)
|
||||
{
|
||||
_githubService = new GitHubReleaseUpdateService(owner, repo);
|
||||
_ownsService = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_githubService = githubService;
|
||||
_ownsService = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetLatestAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var includePrerelease = string.Equals(channel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var result = await _githubService.CheckForUpdatesAsync(currentVersion, includePrerelease, ct);
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.Release is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return UpdateManifestMapper.FromGitHubRelease(result.Release, result.PlondsPayload, channel, platform);
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||
string version,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tag = version.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? version : $"v{version}";
|
||||
var release = await _githubService.GetReleaseByTagAsync(tag, ct);
|
||||
if (release is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
return UpdateManifestMapper.FromGitHubRelease(release, plondsPayload, channel, platform);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version fromVersion,
|
||||
Version toVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||
}
|
||||
|
||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||
{
|
||||
if (release.Assets is null || release.Assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var platformSuffix = GetPlatformAssetSuffix();
|
||||
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||
|
||||
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||
var channelId = release.IsPrerelease
|
||||
? UpdateSettingsValues.ChannelPreview
|
||||
: UpdateSettingsValues.ChannelStable;
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: platformSuffix,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||
{
|
||||
return assets.FirstOrDefault(a => string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
System.Runtime.InteropServices.Architecture.X86 => "x86",
|
||||
System.Runtime.InteropServices.Architecture.Arm => "arm",
|
||||
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
}
|
||||
10
LanMountainDesktop/Services/Update/ILauncherUpdateBridge.cs
Normal file
10
LanMountainDesktop/Services/Update/ILauncherUpdateBridge.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
public interface ILauncherUpdateBridge
|
||||
{
|
||||
Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct);
|
||||
IObservable<InstallProgressReport> ProgressStream { get; }
|
||||
Task<bool> SupportsIpcAsync();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
public interface IUpdateManifestProvider
|
||||
{
|
||||
string ProviderName { get; }
|
||||
|
||||
Task<UpdateManifest?> GetLatestAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct);
|
||||
|
||||
Task<UpdateManifest?> GetByVersionAsync(
|
||||
string version,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct);
|
||||
|
||||
Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version fromVersion,
|
||||
Version toVersion,
|
||||
CancellationToken ct);
|
||||
}
|
||||
171
LanMountainDesktop/Services/Update/IpcLauncherUpdateBridge.cs
Normal file
171
LanMountainDesktop/Services/Update/IpcLauncherUpdateBridge.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class IpcLauncherUpdateBridge : ILauncherUpdateBridge, IDisposable
|
||||
{
|
||||
private const int LengthPrefixSize = 4;
|
||||
private const int MaxPayloadLength = 1024 * 1024;
|
||||
private static readonly TimeSpan PipeConnectTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly UpdateProgressSubject _progressSubject = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private int? _launcherPid;
|
||||
|
||||
public IObservable<InstallProgressReport> ProgressStream => _progressSubject;
|
||||
|
||||
public async Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
return new LaunchResult(false, "Launcher executable not found.", null);
|
||||
}
|
||||
|
||||
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = resolvedLauncherRoot
|
||||
};
|
||||
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return new LaunchResult(false, "Failed to start Launcher process.", null);
|
||||
}
|
||||
|
||||
_launcherPid = process.Id;
|
||||
|
||||
_ = Task.Run(() => ConnectAndReadProgressAsync(process.Id, ct), ct);
|
||||
|
||||
return new LaunchResult(true, null, process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LaunchResult(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> SupportsIpcAsync()
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private async Task ConnectAndReadProgressAsync(int launcherPid, CancellationToken ct)
|
||||
{
|
||||
var pipeName = $"LanMountainDesktop_Update_{launcherPid}";
|
||||
|
||||
try
|
||||
{
|
||||
using var pipe = new NamedPipeClientStream(
|
||||
".",
|
||||
pipeName,
|
||||
PipeDirection.In,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
|
||||
using var timeoutCts = new CancellationTokenSource(PipeConnectTimeout);
|
||||
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(linkedCts.Token, timeoutCts.Token);
|
||||
|
||||
await pipe.ConnectAsync(combinedCts.Token).ConfigureAwait(false);
|
||||
|
||||
await ReadProgressFromPipeAsync(pipe, linkedCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("IpcLauncherUpdateBridge", $"Progress pipe connection failed (fire-and-forget): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadProgressFromPipeAsync(NamedPipeClientStream pipe, CancellationToken ct)
|
||||
{
|
||||
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
|
||||
try
|
||||
{
|
||||
while (pipe.IsConnected && !ct.IsCancellationRequested)
|
||||
{
|
||||
var totalRead = 0;
|
||||
while (totalRead < LengthPrefixSize)
|
||||
{
|
||||
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), ct).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
|
||||
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
|
||||
try
|
||||
{
|
||||
totalRead = 0;
|
||||
while (totalRead < payloadLength)
|
||||
{
|
||||
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), ct).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
var json = Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||
var report = JsonSerializer.Deserialize(json, UpdateJsonContext.Default.InstallProgressReport);
|
||||
if (report is not null)
|
||||
{
|
||||
_progressSubject.OnNext(report);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(payloadBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(lengthBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_progressSubject.OnCompleted();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
31
LanMountainDesktop/Services/Update/ObservableHelper.cs
Normal file
31
LanMountainDesktop/Services/Update/ObservableHelper.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal static class ObservableHelper<T>
|
||||
{
|
||||
private sealed class EmptyObservable : IObservable<T>
|
||||
{
|
||||
public IDisposable Subscribe(IObserver<T> observer) => EmptyDisposable.Instance;
|
||||
}
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
public static readonly IObservable<T> Empty = new EmptyObservable();
|
||||
}
|
||||
|
||||
internal sealed class ActionObserver<T> : IObserver<T>
|
||||
{
|
||||
private readonly Action<T> _onNext;
|
||||
|
||||
public ActionObserver(Action<T> onNext)
|
||||
{
|
||||
_onNext = onNext;
|
||||
}
|
||||
|
||||
public void OnCompleted() { }
|
||||
public void OnError(Exception error) { }
|
||||
public void OnNext(T value) => _onNext(value);
|
||||
}
|
||||
247
LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs
Normal file
247
LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||
{
|
||||
private const string ApiBasePath = "/api/plonds/v1";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
public string ProviderName => "plonds-api";
|
||||
|
||||
public PlondsApiManifestProvider(string baseUrl, HttpClient? httpClient = null)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl.TrimEnd('/')),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_httpClient.BaseAddress ??= new Uri(baseUrl.TrimEnd('/'));
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetLatestAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var pointer = await GetChannelPointerAsync(channel, platform, currentVersion, ct);
|
||||
if (pointer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct);
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||
string version,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var distributionId = $"{channel}-{platform}-{version}";
|
||||
return await FetchDistributionManifestAsync(distributionId, version, channel, platform, ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version fromVersion,
|
||||
Version toVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||
}
|
||||
|
||||
private async Task<PlondsChannelPointerDto?> GetChannelPointerAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var url = $"{ApiBasePath}/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(currentVersion.ToString())}";
|
||||
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
AppLogger.Warn("Update", $"PLONDS API latest endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<PlondsChannelPointerDto>(json, PlondsJsonOptions);
|
||||
}
|
||||
|
||||
private async Task<UpdateManifest?> FetchDistributionManifestAsync(
|
||||
string distributionId,
|
||||
string targetVersion,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var url = $"{ApiBasePath}/distributions/{Uri.EscapeDataString(distributionId)}";
|
||||
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
AppLogger.Warn("Update", $"PLONDS API distribution endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var dto = JsonSerializer.Deserialize<PlondsDistributionDto>(json, PlondsJsonOptions);
|
||||
if (dto is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapDistribution(dto, channel, platform);
|
||||
}
|
||||
|
||||
private static UpdateManifest MapDistribution(PlondsDistributionDto dto, string channel, string platform)
|
||||
{
|
||||
var files = new List<UpdateFileEntry>();
|
||||
if (dto.Components is not null)
|
||||
{
|
||||
foreach (var component in dto.Components)
|
||||
{
|
||||
if (component.Files is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var f in component.Files)
|
||||
{
|
||||
files.Add(new UpdateFileEntry(
|
||||
Path: f.Path ?? string.Empty,
|
||||
Action: f.Op ?? "add",
|
||||
Sha256: f.ContentHash ?? string.Empty,
|
||||
Size: f.Size,
|
||||
Mode: f.Mode ?? "file-object",
|
||||
ObjectKey: f.ObjectKey,
|
||||
ObjectUrl: null,
|
||||
ArchiveSha256: null,
|
||||
Metadata: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mirrors = dto.InstallerMirrors?.Select(m => new UpdateMirrorAsset(
|
||||
Platform: m.Platform ?? platform,
|
||||
Url: m.Url,
|
||||
Name: m.FileName,
|
||||
Sha256: m.Sha256,
|
||||
Size: m.Size)).ToArray();
|
||||
|
||||
var fileMapSignatureUrl = dto.Signatures?.FirstOrDefault()?.Signature;
|
||||
|
||||
return new UpdateManifest(
|
||||
DistributionId: dto.DistributionId ?? string.Empty,
|
||||
FromVersion: dto.SourceVersion ?? string.Empty,
|
||||
ToVersion: dto.Version ?? string.Empty,
|
||||
Platform: platform,
|
||||
Channel: channel,
|
||||
PublishedAt: dto.PublishedAt,
|
||||
Kind: UpdatePayloadKind.DeltaPlonds,
|
||||
FileMapUrl: dto.FileMapUrl,
|
||||
FileMapSignatureUrl: fileMapSignatureUrl,
|
||||
FileMapSha256: null,
|
||||
Files: files,
|
||||
InstallerMirrors: mirrors,
|
||||
Metadata: dto.Metadata as IReadOnlyDictionary<string, string> ?? new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions PlondsJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private sealed record PlondsChannelPointerDto(
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
DateTimeOffset PublishedAt);
|
||||
|
||||
private sealed record PlondsDistributionDto(
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
string? SourceVersion,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? FileMapUrl,
|
||||
List<PlondsComponentDto>? Components,
|
||||
List<PlondsMirrorDto>? InstallerMirrors,
|
||||
List<PlondsSignatureDto>? Signatures,
|
||||
Dictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record PlondsComponentDto(
|
||||
string? Id,
|
||||
string? Root,
|
||||
string? Mode,
|
||||
List<PlondsFileDto>? Files);
|
||||
|
||||
private sealed record PlondsFileDto(
|
||||
string? Path,
|
||||
string? Op,
|
||||
string? ContentHash,
|
||||
long Size,
|
||||
string? Mode,
|
||||
string? ObjectKey);
|
||||
|
||||
private sealed record PlondsMirrorDto(
|
||||
string? Platform,
|
||||
string? Url,
|
||||
string? FileName,
|
||||
string? Sha256,
|
||||
long Size);
|
||||
|
||||
private sealed record PlondsSignatureDto(
|
||||
string? Algorithm,
|
||||
string? KeyId,
|
||||
string? Signature);
|
||||
}
|
||||
384
LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs
Normal file
384
LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs
Normal file
@@ -0,0 +1,384 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
public sealed record DownloadResult(bool Success, string? FilePath, string? ErrorMessage, bool HashVerified);
|
||||
|
||||
internal sealed class UpdateDownloadEngine
|
||||
{
|
||||
private readonly IUpdateManifestProvider _manifestProvider;
|
||||
private readonly ResumableDownloadService _downloadService;
|
||||
|
||||
private const int MaxRetryAttempts = 3;
|
||||
private const int RetryDelayMs = 1000;
|
||||
|
||||
public UpdateDownloadEngine(
|
||||
IUpdateManifestProvider manifestProvider,
|
||||
ResumableDownloadService downloadService)
|
||||
{
|
||||
_manifestProvider = manifestProvider ?? throw new ArgumentNullException(nameof(manifestProvider));
|
||||
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
|
||||
}
|
||||
|
||||
public async Task<DownloadResult> DownloadPayloadAsync(
|
||||
UpdateManifest manifest,
|
||||
string incomingDirectory,
|
||||
string objectsDirectory,
|
||||
int maxConcurrency,
|
||||
IProgress<DownloadProgressReport>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(incomingDirectory);
|
||||
Directory.CreateDirectory(objectsDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DownloadResult(false, null, $"Failed to create download directories: {ex.Message}", false);
|
||||
}
|
||||
|
||||
var fileMapPath = Path.Combine(incomingDirectory, UpdatePaths.GetPlondsFileMapName());
|
||||
var signaturePath = Path.Combine(incomingDirectory, UpdatePaths.GetPlondsSignatureName());
|
||||
|
||||
try
|
||||
{
|
||||
if (manifest.FileMapUrl is not null)
|
||||
{
|
||||
await DownloadWithRetryAsync(manifest.FileMapUrl, fileMapPath, ct);
|
||||
}
|
||||
|
||||
if (manifest.FileMapSignatureUrl is not null)
|
||||
{
|
||||
await DownloadWithRetryAsync(manifest.FileMapSignatureUrl, signaturePath, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DownloadResult(false, null, $"Failed to download file map: {ex.Message}", false);
|
||||
}
|
||||
|
||||
var downloadableFiles = manifest.Files
|
||||
.Where(f => f.Action is not ("reuse" or "delete") && !string.IsNullOrWhiteSpace(f.ObjectUrl))
|
||||
.ToList();
|
||||
|
||||
var totalFiles = downloadableFiles.Count + 2;
|
||||
var completedFiles = 2;
|
||||
var seenHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency));
|
||||
var errors = new List<string>();
|
||||
long totalBytes = downloadableFiles.Sum(f => f.Size);
|
||||
long downloadedBytes = 0;
|
||||
var lockObj = new object();
|
||||
|
||||
var tasks = downloadableFiles.Select(async entry =>
|
||||
{
|
||||
await semaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!seenHashes.Add(entry.Sha256))
|
||||
{
|
||||
lock (lockObj)
|
||||
{
|
||||
completedFiles++;
|
||||
}
|
||||
|
||||
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
var objectPath = GetObjectDestinationPath(objectsDirectory, entry.Sha256);
|
||||
var objectDir = Path.GetDirectoryName(objectPath);
|
||||
if (!string.IsNullOrWhiteSpace(objectDir))
|
||||
{
|
||||
Directory.CreateDirectory(objectDir);
|
||||
}
|
||||
|
||||
if (File.Exists(objectPath))
|
||||
{
|
||||
var existingHash = await ComputeFileSha256Async(objectPath, ct);
|
||||
if (string.Equals(existingHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lock (lockObj)
|
||||
{
|
||||
completedFiles++;
|
||||
downloadedBytes += entry.Size;
|
||||
}
|
||||
|
||||
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.ObjectUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await _downloadService.DownloadAsync(
|
||||
entry.ObjectUrl,
|
||||
objectPath,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var actualHash = await ComputeFileSha256Async(objectPath, ct);
|
||||
var hashVerified = string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!hashVerified)
|
||||
{
|
||||
AppLogger.Warn("UpdateDownloadEngine",
|
||||
$"Object {entry.Path} hash mismatch after download. Expected: {entry.Sha256}, Actual: {actualHash}");
|
||||
}
|
||||
|
||||
lock (lockObj)
|
||||
{
|
||||
completedFiles++;
|
||||
downloadedBytes += entry.Size;
|
||||
}
|
||||
|
||||
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < MaxRetryAttempts)
|
||||
{
|
||||
AppLogger.Warn("UpdateDownloadEngine",
|
||||
$"Object {entry.Path} download attempt {attempt}/{MaxRetryAttempts} failed: {result.ErrorMessage}. Retrying.");
|
||||
await Task.Delay(RetryDelayMs * attempt, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (lockObj)
|
||||
{
|
||||
errors.Add($"Failed to download {entry.Path}: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (AggregateException ae) when (ae.InnerExceptions.All(e => e is OperationCanceledException))
|
||||
{
|
||||
throw new OperationCanceledException(ct);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new DownloadResult(false, null, string.Join("; ", errors), false);
|
||||
}
|
||||
|
||||
var markerPath = Path.Combine(incomingDirectory, ".download-complete");
|
||||
try
|
||||
{
|
||||
var manifestSha256 = ComputeStringSha256(System.Text.Json.JsonSerializer.Serialize(manifest));
|
||||
var markerContent = UpdatePaths.GetDownloadMarkerContent(manifestSha256, manifest.ToVersion, downloadableFiles.Count);
|
||||
await File.WriteAllTextAsync(markerPath, markerContent, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateDownloadEngine", $"Failed to write download marker: {ex.Message}");
|
||||
}
|
||||
|
||||
AppLogger.Info("UpdateDownloadEngine", $"Delta payload downloaded to {incomingDirectory}. {downloadableFiles.Count} objects processed.");
|
||||
return new DownloadResult(true, incomingDirectory, null, true);
|
||||
}
|
||||
|
||||
public async Task<DownloadResult> DownloadFullInstallerAsync(
|
||||
UpdateManifest manifest,
|
||||
string destinationPath,
|
||||
int maxThreads,
|
||||
IProgress<DownloadProgressReport>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
if (manifest.InstallerMirrors is null || manifest.InstallerMirrors.Count == 0)
|
||||
{
|
||||
return new DownloadResult(false, null, "No installer mirrors available.", false);
|
||||
}
|
||||
|
||||
var mirror = manifest.InstallerMirrors.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m.Url));
|
||||
if (mirror is null || string.IsNullOrWhiteSpace(mirror.Url))
|
||||
{
|
||||
return new DownloadResult(false, null, "No usable installer mirror URL found.", false);
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
if (File.Exists(destinationPath) && !string.IsNullOrWhiteSpace(mirror.Sha256))
|
||||
{
|
||||
var existingHash = await ComputeFileSha256Async(destinationPath, ct);
|
||||
if (string.Equals(existingHash, mirror.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Info("UpdateDownloadEngine", "Full installer already downloaded with matching hash, skipping.");
|
||||
return new DownloadResult(true, destinationPath, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
var downloadProgress = progress is null ? null : new Progress<DownloadProgressInfo>(p =>
|
||||
{
|
||||
progress.Report(new DownloadProgressReport(
|
||||
Path.GetFileName(destinationPath),
|
||||
p.DownloadedBytes,
|
||||
p.TotalBytes ?? 0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
p.Progress));
|
||||
});
|
||||
|
||||
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await _downloadService.DownloadAsync(
|
||||
mirror.Url,
|
||||
destinationPath,
|
||||
new DownloadOptions(MaxParallelSegments: Math.Max(1, maxThreads)),
|
||||
downloadProgress,
|
||||
ct);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
bool hashVerified;
|
||||
if (!string.IsNullOrWhiteSpace(mirror.Sha256))
|
||||
{
|
||||
var actualHash = await ComputeFileSha256Async(destinationPath, ct);
|
||||
hashVerified = string.Equals(actualHash, mirror.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||
if (!hashVerified)
|
||||
{
|
||||
AppLogger.Warn("UpdateDownloadEngine",
|
||||
$"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
hashVerified = false;
|
||||
}
|
||||
|
||||
AppLogger.Info("UpdateDownloadEngine", $"Full installer downloaded to {destinationPath}");
|
||||
return new DownloadResult(true, destinationPath, null, hashVerified);
|
||||
}
|
||||
|
||||
if (attempt < MaxRetryAttempts)
|
||||
{
|
||||
AppLogger.Warn("UpdateDownloadEngine",
|
||||
$"Full installer download attempt {attempt}/{MaxRetryAttempts} failed: {result.ErrorMessage}. Retrying.");
|
||||
await Task.Delay(RetryDelayMs * attempt, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new DownloadResult(false, null, $"Failed to download full installer after {MaxRetryAttempts} attempts: {result.ErrorMessage}", false);
|
||||
}
|
||||
}
|
||||
|
||||
return new DownloadResult(false, null, "Failed to download full installer.", false);
|
||||
}
|
||||
|
||||
private static string GetObjectDestinationPath(string objectsDirectory, string objectHashHex)
|
||||
{
|
||||
var normalized = objectHashHex.Trim().ToLowerInvariant();
|
||||
var shard = normalized.Length >= 2 ? normalized[..2] : normalized;
|
||||
return Path.Combine(objectsDirectory, shard, normalized);
|
||||
}
|
||||
|
||||
private static void ReportProgress(
|
||||
IProgress<DownloadProgressReport>? progress,
|
||||
string currentFile,
|
||||
long bytesDownloaded,
|
||||
long bytesTotal,
|
||||
int filesCompleted,
|
||||
int filesTotal)
|
||||
{
|
||||
if (progress is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fraction = filesTotal > 0 ? (double)filesCompleted / filesTotal : 0;
|
||||
progress.Report(new DownloadProgressReport(
|
||||
currentFile,
|
||||
bytesDownloaded,
|
||||
bytesTotal,
|
||||
0,
|
||||
filesCompleted,
|
||||
filesTotal,
|
||||
fraction));
|
||||
}
|
||||
|
||||
private async Task DownloadWithRetryAsync(string url, string destinationPath, CancellationToken ct)
|
||||
{
|
||||
Exception? lastError = null;
|
||||
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await _downloadService.DownloadAsync(url, destinationPath, cancellationToken: ct);
|
||||
if (result.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = new InvalidOperationException(result.ErrorMessage ?? "Download failed.");
|
||||
|
||||
if (attempt < MaxRetryAttempts)
|
||||
{
|
||||
AppLogger.Warn("UpdateDownloadEngine",
|
||||
$"Download of {url} attempt {attempt}/{MaxRetryAttempts} failed. Retrying.");
|
||||
await Task.Delay(RetryDelayMs * attempt, ct);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileSha256Async(string filePath, CancellationToken ct)
|
||||
{
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true);
|
||||
using var hasher = SHA256.Create();
|
||||
var hash = await hasher.ComputeHashAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeStringSha256(string content)
|
||||
{
|
||||
using var hasher = SHA256.Create();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
var hash = hasher.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
159
LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
Normal file
159
LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
public sealed record InstallResult(bool Success, string? ErrorMessage, bool UserCancelledElevation);
|
||||
|
||||
internal sealed class UpdateInstallGateway
|
||||
{
|
||||
public async Task<InstallResult> InstallAsync(
|
||||
UpdatePayloadKind payloadKind,
|
||||
string launcherRoot,
|
||||
IProgress<InstallProgressReport>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
progress?.Report(new InstallProgressReport(
|
||||
InstallStage.VerifySignature,
|
||||
"Verifying payload...",
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
0));
|
||||
|
||||
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
|
||||
{
|
||||
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
|
||||
if (!launched)
|
||||
{
|
||||
return new InstallResult(false, "Failed to launch Launcher for delta update application.", false);
|
||||
}
|
||||
|
||||
progress?.Report(new InstallProgressReport(
|
||||
InstallStage.ActivateDeployment,
|
||||
"Launcher launched for apply-update.",
|
||||
100,
|
||||
null,
|
||||
0,
|
||||
0));
|
||||
|
||||
return new InstallResult(true, null, false);
|
||||
}
|
||||
|
||||
var installerPath = FindPendingInstaller(launcherRoot);
|
||||
if (installerPath is null)
|
||||
{
|
||||
return new InstallResult(false, "No pending installer found.", false);
|
||||
}
|
||||
|
||||
var installerLaunched = LaunchFullInstaller(installerPath);
|
||||
if (!installerLaunched.Success)
|
||||
{
|
||||
return installerLaunched;
|
||||
}
|
||||
|
||||
progress?.Report(new InstallProgressReport(
|
||||
InstallStage.ActivateDeployment,
|
||||
"Full installer launched.",
|
||||
100,
|
||||
null,
|
||||
0,
|
||||
0));
|
||||
|
||||
return new InstallResult(true, null, false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateInstallGateway", $"Install failed: {ex.Message}");
|
||||
return new InstallResult(false, ex.Message, false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool LaunchLauncherForApplyUpdate(string launcherRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
AppLogger.Warn("UpdateInstallGateway", "Launcher executable not found. Falling back to next-startup apply.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source apply-update",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = resolvedLauncherRoot
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
AppLogger.Info("UpdateInstallGateway", $"Launched Launcher for apply-update: {launcherPath}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateInstallGateway", $"Failed to launch Launcher for apply-update: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private InstallResult LaunchFullInstaller(string installerPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
AppLogger.Info("UpdateInstallGateway", "Launching full installer with elevation.");
|
||||
var workingDir = Path.GetDirectoryName(installerPath) ?? Path.GetDirectoryName(installerPath)!;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = installerPath,
|
||||
WorkingDirectory = workingDir,
|
||||
UseShellExecute = true,
|
||||
Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty,
|
||||
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
return new InstallResult(true, null, false);
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
return new InstallResult(false, ex.Message, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateInstallGateway", $"Failed to launch full installer: {ex.Message}");
|
||||
return new InstallResult(false, ex.Message, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindPendingInstaller(string launcherRoot)
|
||||
{
|
||||
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
|
||||
if (!Directory.Exists(incomingDir))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var executables = Directory.GetFiles(incomingDir, "*.exe");
|
||||
return executables.Length > 0 ? executables[0] : null;
|
||||
}
|
||||
}
|
||||
15
LanMountainDesktop/Services/Update/UpdateJsonContext.cs
Normal file
15
LanMountainDesktop/Services/Update/UpdateJsonContext.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true)]
|
||||
[JsonSerializable(typeof(InstallProgressReport))]
|
||||
[JsonSerializable(typeof(InstallCompleteReport))]
|
||||
[JsonSerializable(typeof(InstallRequest))]
|
||||
[JsonSerializable(typeof(LaunchResult))]
|
||||
internal sealed partial class UpdateJsonContext : JsonSerializerContext;
|
||||
220
LanMountainDesktop/Services/Update/UpdateManifestMapper.cs
Normal file
220
LanMountainDesktop/Services/Update/UpdateManifestMapper.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal static class UpdateManifestMapper
|
||||
{
|
||||
public static UpdateManifest FromGitHubRelease(
|
||||
GitHubReleaseInfo release,
|
||||
PlondsUpdatePayload? plondsPayload,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
if (plondsPayload is not null)
|
||||
{
|
||||
return FromPlondsPayload(plondsPayload, release, channel, platform);
|
||||
}
|
||||
|
||||
return FromFullInstaller(release, channel, platform);
|
||||
}
|
||||
|
||||
public static UpdateManifest FromPlondsPayload(
|
||||
PlondsUpdatePayload payload,
|
||||
GitHubReleaseInfo release,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
var files = new List<UpdateFileEntry>();
|
||||
|
||||
if (payload.UpdateArchiveUrl is not null)
|
||||
{
|
||||
files.Add(new UpdateFileEntry(
|
||||
Path: "update.zip",
|
||||
Action: "add",
|
||||
Sha256: payload.UpdateArchiveSha256 ?? string.Empty,
|
||||
Size: payload.UpdateArchiveSizeBytes ?? 0,
|
||||
Mode: "compressed-object",
|
||||
ObjectKey: null,
|
||||
ObjectUrl: payload.UpdateArchiveUrl,
|
||||
ArchiveSha256: null,
|
||||
Metadata: null));
|
||||
}
|
||||
|
||||
var mirrors = release.Assets
|
||||
.Where(IsInstallerAsset)
|
||||
.Select(a => new UpdateMirrorAsset(
|
||||
Platform: platform,
|
||||
Url: a.BrowserDownloadUrl,
|
||||
Name: a.Name,
|
||||
Sha256: a.Sha256,
|
||||
Size: a.SizeBytes))
|
||||
.ToArray();
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "github-plonds",
|
||||
["releaseTag"] = release.TagName
|
||||
};
|
||||
|
||||
return new UpdateManifest(
|
||||
DistributionId: payload.DistributionId,
|
||||
FromVersion: string.Empty,
|
||||
ToVersion: NormalizeTagVersion(release.TagName),
|
||||
Platform: platform,
|
||||
Channel: channel,
|
||||
PublishedAt: release.PublishedAt,
|
||||
Kind: UpdatePayloadKind.DeltaPlonds,
|
||||
FileMapUrl: payload.FileMapJsonUrl,
|
||||
FileMapSignatureUrl: payload.FileMapSignatureUrl,
|
||||
FileMapSha256: null,
|
||||
Files: files,
|
||||
InstallerMirrors: mirrors,
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
public static UpdateManifest FromFullInstaller(
|
||||
GitHubReleaseInfo release,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
var installerAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
|
||||
var files = new List<UpdateFileEntry>();
|
||||
var mirrors = new List<UpdateMirrorAsset>();
|
||||
|
||||
if (installerAsset is not null)
|
||||
{
|
||||
files.Add(new UpdateFileEntry(
|
||||
Path: installerAsset.Name,
|
||||
Action: "add",
|
||||
Sha256: installerAsset.Sha256 ?? string.Empty,
|
||||
Size: installerAsset.SizeBytes,
|
||||
Mode: "file-object",
|
||||
ObjectKey: null,
|
||||
ObjectUrl: installerAsset.BrowserDownloadUrl,
|
||||
ArchiveSha256: null,
|
||||
Metadata: null));
|
||||
|
||||
foreach (var asset in release.Assets)
|
||||
{
|
||||
if (IsInstallerAsset(asset) && asset != installerAsset)
|
||||
{
|
||||
mirrors.Add(new UpdateMirrorAsset(
|
||||
Platform: platform,
|
||||
Url: asset.BrowserDownloadUrl,
|
||||
Name: asset.Name,
|
||||
Sha256: asset.Sha256,
|
||||
Size: asset.SizeBytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var distributionId = $"github-{release.TagName.Trim().TrimStart('v')}-{platform}";
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "github-release",
|
||||
["releaseTag"] = release.TagName
|
||||
};
|
||||
|
||||
return new UpdateManifest(
|
||||
DistributionId: distributionId,
|
||||
FromVersion: string.Empty,
|
||||
ToVersion: NormalizeTagVersion(release.TagName),
|
||||
Platform: platform,
|
||||
Channel: channel,
|
||||
PublishedAt: release.PublishedAt,
|
||||
Kind: UpdatePayloadKind.FullInstaller,
|
||||
FileMapUrl: null,
|
||||
FileMapSignatureUrl: null,
|
||||
FileMapSha256: null,
|
||||
Files: files,
|
||||
InstallerMirrors: mirrors,
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
private static string NormalizeTagVersion(string tagName)
|
||||
{
|
||||
var v = tagName.Trim();
|
||||
if (v.StartsWith("v", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
v = v[1..];
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
private static bool IsInstallerAsset(GitHubReleaseAsset asset)
|
||||
{
|
||||
var name = asset.Name;
|
||||
return name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var architectureToken = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.X86 => "x86",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return assets
|
||||
.Where(a => a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
|
||||
|| a.Name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return assets
|
||||
.Where(a => a.Name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)
|
||||
|| a.Name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)
|
||||
|| a.Name.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return assets
|
||||
.Where(a => a.Name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)
|
||||
|| a.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ScoreAsset(string name, string archToken)
|
||||
{
|
||||
var score = 0;
|
||||
if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (name.Contains("setup", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("installer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 20;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
}
|
||||
483
LanMountainDesktop/Services/Update/UpdateOrchestrator.cs
Normal file
483
LanMountainDesktop/Services/Update/UpdateOrchestrator.cs
Normal file
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateSettingsState;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
public sealed class UpdateOrchestrator : IDisposable
|
||||
{
|
||||
private readonly IUpdateManifestProvider _manifestProvider;
|
||||
private readonly UpdateDownloadEngine _downloadEngine;
|
||||
private readonly UpdateInstallGateway _installGateway;
|
||||
private readonly UpdateStateStore _stateStore;
|
||||
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
internal UpdateOrchestrator(
|
||||
IUpdateManifestProvider manifestProvider,
|
||||
UpdateDownloadEngine downloadEngine,
|
||||
UpdateInstallGateway installGateway,
|
||||
UpdateStateStore stateStore)
|
||||
{
|
||||
_manifestProvider = manifestProvider ?? throw new ArgumentNullException(nameof(manifestProvider));
|
||||
_downloadEngine = downloadEngine ?? throw new ArgumentNullException(nameof(downloadEngine));
|
||||
_installGateway = installGateway ?? throw new ArgumentNullException(nameof(installGateway));
|
||||
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||
|
||||
_stateStore.PhaseChanged += OnPhaseChanged;
|
||||
_stateStore.ProgressChanged += OnProgressChanged;
|
||||
}
|
||||
|
||||
public UpdatePhase CurrentPhase => _stateStore.CurrentPhase;
|
||||
|
||||
public UpdateManifest? CurrentManifest => _stateStore.PendingManifest;
|
||||
|
||||
public event Action<UpdatePhase>? PhaseChanged;
|
||||
public event Action<UpdateProgressReport>? ProgressChanged;
|
||||
|
||||
public async Task<UpdateCheckReport> CheckAsync(CancellationToken ct)
|
||||
{
|
||||
await _operationGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!CurrentPhase.CanCheck())
|
||||
{
|
||||
return new UpdateCheckReport(
|
||||
false, null, null, null, null, null, null, null, null,
|
||||
$"Cannot check in phase {CurrentPhase}.");
|
||||
}
|
||||
|
||||
_stateStore.TransitionTo(UpdatePhase.Checking);
|
||||
|
||||
var settings = _stateStore.GetSettings();
|
||||
var channel = UpdateSettingsValues.NormalizeChannel(settings.UpdateChannel);
|
||||
var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion
|
||||
?? AppVersionProvider.ResolveForCurrentProcess().Version;
|
||||
|
||||
if (!Version.TryParse(currentVersionText, out var currentVersion))
|
||||
{
|
||||
currentVersion = new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
UpdateManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = await _manifestProvider.GetLatestAsync(
|
||||
channel,
|
||||
"win-x64",
|
||||
currentVersion,
|
||||
ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
_stateStore.RecordFailure(ex.Message);
|
||||
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, ex.Message);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Checked);
|
||||
return new UpdateCheckReport(
|
||||
false, null, currentVersionText, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
_stateStore.PendingManifest = manifest;
|
||||
_stateStore.TransitionTo(UpdatePhase.Checked);
|
||||
|
||||
long? totalBytes = manifest.IsDelta ? manifest.EstimatedDeltaBytes : null;
|
||||
long? installerBytes = manifest.InstallerMirrors?.Count > 0
|
||||
? manifest.InstallerMirrors[0].Size
|
||||
: null;
|
||||
|
||||
return new UpdateCheckReport(
|
||||
true,
|
||||
manifest.ToVersion,
|
||||
currentVersionText,
|
||||
manifest.Kind,
|
||||
manifest.DistributionId,
|
||||
manifest.Channel,
|
||||
manifest.PublishedAt,
|
||||
totalBytes,
|
||||
installerBytes,
|
||||
null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DownloadResult> DownloadAsync(CancellationToken ct)
|
||||
{
|
||||
await _operationGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!CurrentPhase.CanDownload())
|
||||
{
|
||||
return new DownloadResult(false, null, $"Cannot download in phase {CurrentPhase}.", false);
|
||||
}
|
||||
|
||||
var manifest = _stateStore.PendingManifest;
|
||||
if (manifest is null)
|
||||
{
|
||||
return new DownloadResult(false, null, "No manifest available for download.", false);
|
||||
}
|
||||
|
||||
_stateStore.TransitionTo(UpdatePhase.Downloading);
|
||||
|
||||
var settings = _stateStore.GetSettings();
|
||||
var maxThreads = UpdateSettingsValues.NormalizeDownloadThreads(settings.UpdateDownloadThreads);
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
|
||||
var downloadProgress = new Progress<DownloadProgressReport>(p =>
|
||||
{
|
||||
var overallFraction = manifest.IsDelta
|
||||
? (double)p.FilesCompleted / Math.Max(1, p.FilesTotal)
|
||||
: p.OverallFraction;
|
||||
|
||||
ProgressChanged?.Invoke(new UpdateProgressReport(
|
||||
UpdatePhase.Downloading,
|
||||
$"Downloading {p.CurrentFile}",
|
||||
overallFraction,
|
||||
p,
|
||||
null));
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
DownloadResult result;
|
||||
|
||||
if (manifest.IsDelta)
|
||||
{
|
||||
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
|
||||
var objectsDir = UpdatePaths.GetObjectsDirectory(launcherRoot);
|
||||
result = await _downloadEngine.DownloadPayloadAsync(
|
||||
manifest,
|
||||
incomingDir,
|
||||
objectsDir,
|
||||
maxThreads,
|
||||
downloadProgress,
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fileName = $"{manifest.DistributionId}-{manifest.ToVersion}-installer.exe";
|
||||
var destinationPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Updates",
|
||||
fileName);
|
||||
result = await _downloadEngine.DownloadFullInstallerAsync(
|
||||
manifest,
|
||||
destinationPath,
|
||||
maxThreads,
|
||||
downloadProgress,
|
||||
ct);
|
||||
}
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Downloaded);
|
||||
|
||||
var state = _stateStore.GetSettings();
|
||||
_stateStore.SaveSettings(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath,
|
||||
PendingUpdateVersion = manifest.ToVersion,
|
||||
PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
|
||||
AppLogger.Info("UpdateOrchestrator", $"Update downloaded successfully: {manifest.ToVersion}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
_stateStore.RecordFailure(result.ErrorMessage ?? "Download failed");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
_stateStore.RecordFailure(ex.Message);
|
||||
return new DownloadResult(false, null, ex.Message, false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<InstallResult> InstallAsync(CancellationToken ct)
|
||||
{
|
||||
await _operationGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!CurrentPhase.CanInstall())
|
||||
{
|
||||
return new InstallResult(false, $"Cannot install in phase {CurrentPhase}.", false);
|
||||
}
|
||||
|
||||
var manifest = _stateStore.PendingManifest;
|
||||
if (manifest is null)
|
||||
{
|
||||
return new InstallResult(false, "No manifest available for install.", false);
|
||||
}
|
||||
|
||||
_stateStore.TransitionTo(UpdatePhase.Installing);
|
||||
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
|
||||
var installProgress = new Progress<InstallProgressReport>(p =>
|
||||
{
|
||||
var fraction = p.FilesTotal > 0 ? (double)p.FilesCompleted / p.FilesTotal : p.ProgressPercent / 100.0;
|
||||
ProgressChanged?.Invoke(new UpdateProgressReport(
|
||||
UpdatePhase.Installing,
|
||||
p.Message,
|
||||
fraction,
|
||||
null,
|
||||
p));
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _installGateway.InstallAsync(
|
||||
manifest.Kind,
|
||||
launcherRoot,
|
||||
installProgress,
|
||||
ct);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Installed);
|
||||
_stateStore.RecordSuccess(manifest.ToVersion);
|
||||
AppLogger.Info("UpdateOrchestrator", $"Update install initiated: {manifest.ToVersion}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
_stateStore.RecordFailure(result.ErrorMessage ?? "Install failed");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
_stateStore.RecordFailure(ex.Message);
|
||||
return new InstallResult(false, ex.Message, false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RollbackAsync(CancellationToken ct)
|
||||
{
|
||||
await _operationGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!CurrentPhase.CanRollback())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stateStore.TransitionTo(UpdatePhase.RollingBack);
|
||||
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (!string.IsNullOrWhiteSpace(launcherPath) && File.Exists(launcherPath))
|
||||
{
|
||||
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"rollback --app-root \"{launcherRoot}\"",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = launcherRoot
|
||||
};
|
||||
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
AppLogger.Info("UpdateOrchestrator", "Launched Launcher for rollback.");
|
||||
}
|
||||
|
||||
_stateStore.TransitionTo(UpdatePhase.RolledBack);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateOrchestrator", $"Rollback failed: {ex.Message}");
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CancelAsync()
|
||||
{
|
||||
if (!CurrentPhase.IsBusy())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||
_stateStore.PendingManifest = null;
|
||||
AppLogger.Info("UpdateOrchestrator", "Update operation cancelled.");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task AutoCheckIfEnabledAsync(CancellationToken ct)
|
||||
{
|
||||
var settings = _stateStore.GetSettings();
|
||||
var mode = UpdateSettingsValues.NormalizeMode(settings.UpdateMode);
|
||||
|
||||
if (string.Equals(mode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await CheckAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateOrchestrator", "Automatic update check failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryApplyOnExit()
|
||||
{
|
||||
var settings = _stateStore.GetSettings();
|
||||
var mode = UpdateSettingsValues.NormalizeMode(settings.UpdateMode);
|
||||
|
||||
if (!string.Equals(mode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var manifest = _stateStore.PendingManifest;
|
||||
if (manifest is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
|
||||
if (manifest.IsDelta)
|
||||
{
|
||||
AppLogger.Info("UpdateOrchestrator", "Delta update pending. Launching Launcher to apply on exit.");
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resolvedRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{resolvedRoot}\" --launch-source apply-update",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = resolvedRoot
|
||||
};
|
||||
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateOrchestrator", $"Failed to launch Launcher on exit: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var installerPath = settings.PendingUpdateInstallerPath?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(installerPath) || !File.Exists(installerPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = installerPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(installerPath)!,
|
||||
UseShellExecute = true,
|
||||
Verb = System.OperatingSystem.IsWindows() ? "runas" : string.Empty,
|
||||
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
|
||||
};
|
||||
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateOrchestrator", $"Failed to launch installer on exit: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPhaseChanged(UpdatePhase phase)
|
||||
{
|
||||
PhaseChanged?.Invoke(phase);
|
||||
}
|
||||
|
||||
private void OnProgressChanged(UpdateProgressReport report)
|
||||
{
|
||||
ProgressChanged?.Invoke(report);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_stateStore.PhaseChanged -= OnPhaseChanged;
|
||||
_stateStore.ProgressChanged -= OnProgressChanged;
|
||||
_operationGate.Dispose();
|
||||
}
|
||||
}
|
||||
105
LanMountainDesktop/Services/Update/UpdateProgressSubject.cs
Normal file
105
LanMountainDesktop/Services/Update/UpdateProgressSubject.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class UpdateProgressSubject : IObservable<InstallProgressReport>, IObserver<InstallProgressReport>
|
||||
{
|
||||
private readonly List<IObserver<InstallProgressReport>> _observers = [];
|
||||
private readonly object _gate = new();
|
||||
private bool _completed;
|
||||
|
||||
public IDisposable Subscribe(IObserver<InstallProgressReport> observer)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_completed)
|
||||
{
|
||||
observer.OnCompleted();
|
||||
return EmptyDisposable.Instance;
|
||||
}
|
||||
|
||||
_observers.Add(observer);
|
||||
}
|
||||
|
||||
return new Subscription(this, observer);
|
||||
}
|
||||
|
||||
public void OnNext(InstallProgressReport value)
|
||||
{
|
||||
IObserver<InstallProgressReport>[] snapshot;
|
||||
lock (_gate)
|
||||
{
|
||||
snapshot = _observers.ToArray();
|
||||
}
|
||||
|
||||
foreach (var observer in snapshot)
|
||||
{
|
||||
observer.OnNext(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnError(Exception error)
|
||||
{
|
||||
IObserver<InstallProgressReport>[] snapshot;
|
||||
lock (_gate)
|
||||
{
|
||||
_completed = true;
|
||||
snapshot = _observers.ToArray();
|
||||
_observers.Clear();
|
||||
}
|
||||
|
||||
foreach (var observer in snapshot)
|
||||
{
|
||||
observer.OnError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnCompleted()
|
||||
{
|
||||
IObserver<InstallProgressReport>[] snapshot;
|
||||
lock (_gate)
|
||||
{
|
||||
_completed = true;
|
||||
snapshot = _observers.ToArray();
|
||||
_observers.Clear();
|
||||
}
|
||||
|
||||
foreach (var observer in snapshot)
|
||||
{
|
||||
observer.OnCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private readonly UpdateProgressSubject _subject;
|
||||
private IObserver<InstallProgressReport>? _observer;
|
||||
|
||||
public Subscription(UpdateProgressSubject subject, IObserver<InstallProgressReport> observer)
|
||||
{
|
||||
_subject = subject;
|
||||
_observer = observer;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_observer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_subject._gate)
|
||||
{
|
||||
_subject._observers.Remove(_observer);
|
||||
}
|
||||
|
||||
_observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
79
LanMountainDesktop/Services/Update/UpdateStateStore.cs
Normal file
79
LanMountainDesktop/Services/Update/UpdateStateStore.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateSettingsState;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class UpdateStateStore
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private const int AutoDowngradeThreshold = 3;
|
||||
private int _consecutiveFailCount;
|
||||
|
||||
public UpdateStateStore(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
CurrentPhase = UpdatePhase.Idle;
|
||||
}
|
||||
|
||||
public UpdatePhase CurrentPhase { get; private set; }
|
||||
|
||||
public event Action<UpdatePhase>? PhaseChanged;
|
||||
public event Action<UpdateProgressReport>? ProgressChanged;
|
||||
|
||||
public void TransitionTo(UpdatePhase newPhase)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (CurrentPhase == newPhase)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentPhase = newPhase;
|
||||
}
|
||||
|
||||
PhaseChanged?.Invoke(newPhase);
|
||||
ProgressChanged?.Invoke(new UpdateProgressReport(
|
||||
newPhase,
|
||||
$"Phase changed to {newPhase}",
|
||||
0,
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
public SettingsUpdateSettingsState GetSettings()
|
||||
{
|
||||
return _settingsFacade.Update.Get();
|
||||
}
|
||||
|
||||
public void SaveSettings(SettingsUpdateSettingsState state)
|
||||
{
|
||||
_settingsFacade.Update.Save(state);
|
||||
}
|
||||
|
||||
public UpdateManifest? PendingManifest { get; set; }
|
||||
|
||||
public void RecordFailure(string errorMessage)
|
||||
{
|
||||
Interlocked.Increment(ref _consecutiveFailCount);
|
||||
AppLogger.Warn("UpdateStateStore", $"Update failure recorded (consecutive: {_consecutiveFailCount}): {errorMessage}");
|
||||
}
|
||||
|
||||
public void RecordSuccess(string appliedVersion)
|
||||
{
|
||||
Interlocked.Exchange(ref _consecutiveFailCount, 0);
|
||||
|
||||
var state = GetSettings();
|
||||
SaveSettings(state with
|
||||
{
|
||||
PendingUpdateVersion = appliedVersion,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
});
|
||||
}
|
||||
|
||||
public bool ShouldAutoDowngrade => Volatile.Read(ref _consecutiveFailCount) >= AutoDowngradeThreshold;
|
||||
}
|
||||
@@ -12,11 +12,12 @@ public static class UpdateSettingsValues
|
||||
public const string ModeSilentOnExit = "silent_on_exit";
|
||||
|
||||
// NOTE: keep constant name for compatibility with existing call sites.
|
||||
public const string DownloadSourcePlonds = "stcn";
|
||||
public const string DownloadSourcePlonds = "plonds-api";
|
||||
public const string DownloadSourcePdc = DownloadSourcePlonds;
|
||||
public const string DownloadSourceStcn = DownloadSourcePlonds;
|
||||
public const string LegacyDownloadSourcePlonds = "pdc";
|
||||
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
|
||||
public const string LegacyDownloadSourceStcn = "stcn";
|
||||
public const string DownloadSourceGitHub = "github";
|
||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||
|
||||
@@ -59,7 +60,12 @@ public static class UpdateSettingsValues
|
||||
{
|
||||
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceStcn;
|
||||
return DownloadSourcePlonds;
|
||||
}
|
||||
|
||||
if (string.Equals(value, LegacyDownloadSourceStcn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourcePlonds;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -77,8 +83,7 @@ public static class UpdateSettingsValues
|
||||
return DownloadSourceGitHub;
|
||||
}
|
||||
|
||||
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
|
||||
return DownloadSourceStcn;
|
||||
return DownloadSourcePlonds;
|
||||
}
|
||||
|
||||
public static int NormalizeDownloadThreads(int value)
|
||||
|
||||
@@ -171,7 +171,9 @@ public sealed class UpdateWorkflowService
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var downloadSource = state.UpdateDownloadSource;
|
||||
var downloadSource = state.UseGhProxyMirror
|
||||
? UpdateSettingsValues.DownloadSourceGhProxy
|
||||
: UpdateSettingsValues.DownloadSourceGitHub;
|
||||
var downloadThreads = state.UpdateDownloadThreads;
|
||||
|
||||
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
||||
@@ -312,7 +314,9 @@ public sealed class UpdateWorkflowService
|
||||
payload,
|
||||
incomingDir,
|
||||
objectsDir,
|
||||
state.UpdateDownloadSource,
|
||||
state.UseGhProxyMirror
|
||||
? UpdateSettingsValues.DownloadSourceGhProxy
|
||||
: UpdateSettingsValues.DownloadSourceGitHub,
|
||||
downloadThreads,
|
||||
progress,
|
||||
cancellationToken);
|
||||
@@ -502,7 +506,9 @@ public sealed class UpdateWorkflowService
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
checkResult.PreferredAsset,
|
||||
destinationPath,
|
||||
state.UpdateDownloadSource,
|
||||
state.UseGhProxyMirror
|
||||
? UpdateSettingsValues.DownloadSourceGhProxy
|
||||
: UpdateSettingsValues.DownloadSourceGitHub,
|
||||
state.UpdateDownloadThreads,
|
||||
progress,
|
||||
cancellationToken);
|
||||
@@ -1431,26 +1437,15 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherExeName = OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.Launcher.exe"
|
||||
: "LanMountainDesktop.Launcher";
|
||||
|
||||
// The Launcher is in the parent directory of the app's base directory
|
||||
// (app runs from app-{version}/ subdirectory, Launcher is at root)
|
||||
var appBaseDir = AppContext.BaseDirectory;
|
||||
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.IsNullOrWhiteSpace(launcherRoot))
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
launcherRoot = appBaseDir;
|
||||
}
|
||||
|
||||
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
|
||||
if (!File.Exists(launcherPath))
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Launcher executable not found at '{launcherPath}'. Falling back to next-startup apply.");
|
||||
AppLogger.Warn("UpdateWorkflow", "Launcher executable not found. Falling back to next-startup apply.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class WebView2RuntimeProbe
|
||||
|
||||
public static WebView2RuntimeAvailability GetAvailability()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: true,
|
||||
@@ -27,6 +27,14 @@ public static class WebView2RuntimeProbe
|
||||
Message: string.Empty);
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: false,
|
||||
Version: null,
|
||||
Message: "Embedded browser is currently unavailable on this platform.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var version = TryGetVersionFromWebView2Api();
|
||||
|
||||
@@ -70,11 +70,25 @@
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NumberBox">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Style Selector="ui|FANumberBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|FANumberBox /template/ Button#PART_SpinUp">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="4,4,0,0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|FANumberBox /template/ Button#PART_SpinDown">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0,0,4,4" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="CheckBox">
|
||||
@@ -125,7 +139,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=".settings-scope ui|NumberBox">
|
||||
<Style Selector=".settings-scope ui|FANumberBox">
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
</Style>
|
||||
@@ -152,7 +166,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=".settings-scope ui|NavigationView, .settings-scope ui|NavigationViewItem, .settings-scope ui|SettingsExpander, .settings-scope ui|InfoBar, .settings-scope ListBoxItem">
|
||||
<Style Selector=".settings-scope ui|FANavigationView, .settings-scope ui|FANavigationViewItem, .settings-scope ui|FASettingsExpander, .settings-scope ui|FAInfoBar, .settings-scope ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
@@ -169,9 +183,9 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
半透明表面样式类
|
||||
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
|
||||
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
|
||||
鍗婇€忔槑琛ㄩ潰鏍峰紡绫?
|
||||
娉ㄦ剰锛氳繖浜涙牱寮忎娇鐢ㄧ函鑹插崐閫忔槑鐢诲埛妯℃嫙鐜荤拑鏁堟灉锛屽苟闈炵湡姝g殑 Mica/Acrylic 妯$硦鏉愯川銆?
|
||||
鐪熸鐨?Mica/Acrylic 鏁堟灉浠呴€氳繃 WindowTransparencyLevel 鍦ㄧ嫭绔嬬獥鍙d笂搴旂敤銆?
|
||||
-->
|
||||
|
||||
<Style Selector="Border.surface-translucent-panel">
|
||||
@@ -221,7 +235,7 @@
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||
</Style>
|
||||
|
||||
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||
<!-- 鍚戝悗鍏煎鐨勬棫鏍峰紡绫伙紙宸插純鐢級 -->
|
||||
<Style Selector="Border.glass-panel">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
|
||||
xmlns:fi="using:FluentIcons.Avalonia">
|
||||
|
||||
<Styles.Resources>
|
||||
<x:Double x:Key="PaneToggleButtonWidth">40</x:Double>
|
||||
@@ -115,7 +115,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationView.settings-navigation-view">
|
||||
<Style Selector="ui|FANavigationView.settings-navigation-view">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.2" Easing="0.05,0.75,0.10,1.00" />
|
||||
@@ -123,7 +123,7 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationView.settings-navigation-view /template/ Border#NavigationViewBorder">
|
||||
<Style Selector="ui|FANavigationView.settings-navigation-view /template/ Border#NavigationViewBorder">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.167" Easing="0.05,0.75,0.10,1.00" />
|
||||
@@ -131,7 +131,7 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.settings-nav-item">
|
||||
<Style Selector="ui|FANavigationViewItem.settings-nav-item">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||
@@ -140,11 +140,11 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.settings-nav-item:pointerover">
|
||||
<Style Selector="ui|FANavigationViewItem.settings-nav-item:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.01)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.settings-nav-item:pressed">
|
||||
<Style Selector="ui|FANavigationViewItem.settings-nav-item:pressed">
|
||||
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||
</Style>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
||||
|
||||
<Style Selector="StackPanel.settings-page-container">
|
||||
@@ -162,59 +162,59 @@
|
||||
<Setter Property="ColumnSpacing" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander">
|
||||
<Style Selector="ui|FASettingsExpander">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander ComboBox, ui|SettingsExpander TextBox, ui|SettingsExpander NumericUpDown">
|
||||
<Style Selector="ui|FASettingsExpander ComboBox, ui|FASettingsExpander TextBox, ui|FASettingsExpander NumericUpDown">
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpanderItem ComboBox, ui|SettingsExpanderItem TextBox, ui|SettingsExpanderItem NumericUpDown">
|
||||
<Style Selector="ui|FASettingsExpanderItem ComboBox, ui|FASettingsExpanderItem TextBox, ui|FASettingsExpanderItem NumericUpDown">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander ToggleSwitch, ui|SettingsExpanderItem ToggleSwitch">
|
||||
<Style Selector="ui|FASettingsExpander ToggleSwitch, ui|FASettingsExpanderItem ToggleSwitch">
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card">
|
||||
<Setter Property="Margin" Value="0,0,0,14" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card /template/ ContentPresenter#FooterContentPresenter">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card /template/ ContentPresenter#FooterContentPresenter">
|
||||
<Setter Property="Margin" Value="0,6,0,2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card /template/ ContentPresenter#ContentPresenter">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card /template/ ContentPresenter#ContentPresenter">
|
||||
<Setter Property="Margin" Value="0,14,0,0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card ComboBox, .settings-section-card ComboBox, .settings-option-card ComboBox">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card ComboBox, .settings-section-card ComboBox, .settings-option-card ComboBox">
|
||||
<Setter Property="MinWidth" Value="220" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card TextBox, .settings-section-card TextBox, .settings-option-card TextBox">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card TextBox, .settings-section-card TextBox, .settings-option-card TextBox">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card NumericUpDown, .settings-section-card NumericUpDown, .settings-option-card NumericUpDown">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card NumericUpDown, .settings-section-card NumericUpDown, .settings-option-card NumericUpDown">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card ToggleSwitch, .settings-option-card ToggleSwitch, .settings-list-item ToggleSwitch">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card ToggleSwitch, .settings-option-card ToggleSwitch, .settings-list-item ToggleSwitch">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="OnContent" Value="{x:Null}" />
|
||||
<Setter Property="OffContent" Value="{x:Null}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=".settings-section-card Button, .settings-option-card Button, .settings-list-item Button, ui|SettingsExpander.settings-expander-card Button">
|
||||
<Style Selector=".settings-section-card Button, .settings-option-card Button, .settings-list-item Button, ui|FASettingsExpander.settings-expander-card Button">
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
</Style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using LanMountainDesktop.Services;
|
||||
using Avalonia.Controls;
|
||||
using FluentIcons.Common;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
@@ -55,33 +54,20 @@ public sealed class ComponentLibraryCategoryViewModel
|
||||
public sealed class ComponentLibraryItemViewModel
|
||||
: ObservableObject
|
||||
{
|
||||
private readonly string _loadingPreviewText;
|
||||
private readonly string _previewUnavailableText;
|
||||
private string _displayName;
|
||||
private string? _description;
|
||||
private ComponentPreviewKey _previewKey;
|
||||
private ComponentPreviewImageEntry? _previewImageEntry;
|
||||
private ComponentPreviewImageState _previewState;
|
||||
private string? _previewErrorMessage;
|
||||
private string _previewStatusText;
|
||||
private Control? _previewControl;
|
||||
|
||||
public ComponentLibraryItemViewModel(
|
||||
string componentId,
|
||||
string displayName,
|
||||
ComponentPreviewKey previewKey,
|
||||
string? description = null,
|
||||
string loadingPreviewText = "Loading preview...",
|
||||
string previewUnavailableText = "Preview unavailable",
|
||||
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||
Control? previewControl = null)
|
||||
{
|
||||
ComponentId = componentId;
|
||||
_displayName = displayName;
|
||||
_description = description;
|
||||
_previewKey = previewKey;
|
||||
_loadingPreviewText = loadingPreviewText;
|
||||
_previewUnavailableText = previewUnavailableText;
|
||||
_previewStatusText = loadingPreviewText;
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||
_previewControl = previewControl;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
@@ -98,98 +84,10 @@ public sealed class ComponentLibraryItemViewModel
|
||||
set => SetProperty(ref _description, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey PreviewKey
|
||||
public Control? PreviewControl
|
||||
{
|
||||
get => _previewKey;
|
||||
set => SetProperty(ref _previewKey, value);
|
||||
get => _previewControl;
|
||||
set => SetProperty(ref _previewControl, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
|
||||
|
||||
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
|
||||
|
||||
public ComponentPreviewImageState PreviewState => _previewState;
|
||||
|
||||
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
|
||||
|
||||
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
|
||||
|
||||
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
|
||||
|
||||
public string? PreviewErrorMessage => _previewErrorMessage;
|
||||
|
||||
public string PreviewStatusText => _previewStatusText;
|
||||
|
||||
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
|
||||
{
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
|
||||
}
|
||||
|
||||
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
|
||||
{
|
||||
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
_previewImageEntry = previewImageEntry;
|
||||
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = previewImageEntry?.ErrorMessage;
|
||||
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
|
||||
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
|
||||
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
|
||||
nameof(ComponentPreviewImageEntry.State) or
|
||||
nameof(ComponentPreviewImageEntry.ErrorMessage))
|
||||
{
|
||||
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void RaisePreviewDependentProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewImageEntry));
|
||||
OnPropertyChanged(nameof(PreviewBitmap));
|
||||
OnPropertyChanged(nameof(PreviewState));
|
||||
OnPropertyChanged(nameof(IsPreviewPending));
|
||||
OnPropertyChanged(nameof(IsPreviewReady));
|
||||
OnPropertyChanged(nameof(IsPreviewFailed));
|
||||
OnPropertyChanged(nameof(PreviewErrorMessage));
|
||||
OnPropertyChanged(nameof(PreviewStatusText));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
@@ -1609,8 +1610,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public IReadOnlyList<SelectionOption> UpdateChannelOptions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> UpdateSourceOptions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> UpdateModeOptions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> DownloadThreadOptions { get; }
|
||||
@@ -1624,7 +1623,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
RefreshLocalizedText();
|
||||
UpdateChannelOptions = CreateUpdateChannelOptions();
|
||||
UpdateSourceOptions = CreateUpdateSourceOptions();
|
||||
UpdateModeOptions = CreateUpdateModeOptions();
|
||||
DownloadThreadOptions = CreateDownloadThreadOptions();
|
||||
|
||||
@@ -1640,9 +1638,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||
|
||||
@@ -1667,6 +1662,18 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _downloadProgressText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _updatePhaseText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _phaseProgressValue;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _updateTypeText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _useGhProxyMirror;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pageTitle = string.Empty;
|
||||
|
||||
@@ -1688,9 +1695,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _updateChannelLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _updateSourceLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _updateModeLabel = string.Empty;
|
||||
|
||||
@@ -1754,9 +1758,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateModeDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateSourceDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _downloadThreadsLabel = string.Empty;
|
||||
|
||||
@@ -1769,21 +1770,24 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _forceCheckUpdateDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _forceFullUpdateLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _forceFullUpdateDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkAccelerationLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkAccelerationDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _stableChannelText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _previewChannelText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pdcSourceText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _gitHubSourceText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _ghProxySourceText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _manualModeText = string.Empty;
|
||||
|
||||
@@ -1796,9 +1800,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedUpdateChannelOption;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedUpdateSourceOption;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedUpdateModeOption;
|
||||
|
||||
@@ -1814,15 +1815,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
public bool IsPreviewChannelSelected =>
|
||||
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPdcSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGitHubSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGhProxySourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsManualModeSelected =>
|
||||
string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1840,6 +1832,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
|
||||
|
||||
public bool IsUpdateTypeVisible => !string.IsNullOrEmpty(UpdateTypeText) && !HasPendingInstaller;
|
||||
|
||||
public string DownloadThreadsValueText =>
|
||||
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
@@ -1854,15 +1848,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateSourceOptionChanged(SelectionOption? value)
|
||||
{
|
||||
if (value is not null &&
|
||||
!string.Equals(SelectedUpdateSourceValue, value.Value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SelectedUpdateSourceValue = value.Value;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateModeOptionChanged(SelectionOption? value)
|
||||
{
|
||||
if (value is not null &&
|
||||
@@ -1910,19 +1895,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
RefreshActionState();
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateSourceValueChanged(string value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveUpdateSettings();
|
||||
SelectedUpdateSourceDescription = BuildUpdateSourceDescription(value);
|
||||
UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved.");
|
||||
SyncSelectedOptions();
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateModeValueChanged(string value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
@@ -1988,6 +1960,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
||||
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
|
||||
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
|
||||
ForceFullUpdateCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnIsDownloadingChanged(bool value)
|
||||
@@ -1995,6 +1968,18 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
||||
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
|
||||
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
|
||||
ForceFullUpdateCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnUseGhProxyMirrorChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveUpdateSettings();
|
||||
UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved.");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -2009,24 +1994,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectPdcSource()
|
||||
{
|
||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectGitHubSource()
|
||||
{
|
||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectGhProxySource()
|
||||
{
|
||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGhProxy;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectManualMode()
|
||||
{
|
||||
@@ -2056,7 +2023,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
StringComparison.OrdinalIgnoreCase),
|
||||
UpdateChannel = SelectedUpdateChannelValue,
|
||||
UpdateMode = SelectedUpdateModeValue,
|
||||
UpdateDownloadSource = SelectedUpdateSourceValue,
|
||||
UseGhProxyMirror = UseGhProxyMirror,
|
||||
UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue))
|
||||
});
|
||||
}
|
||||
@@ -2077,6 +2044,86 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
await CheckForUpdatesCoreAsync(isForce: true);
|
||||
}
|
||||
|
||||
private bool CanForceFullUpdate() => !IsBusy;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanForceFullUpdate))]
|
||||
private async Task ForceFullUpdateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsCheckingForUpdates = true;
|
||||
IsDownloadProgressVisible = true;
|
||||
UpdatePhaseText = L("settings.update.phase_force_full", "Forcing full update...");
|
||||
PhaseProgressValue = 0;
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = L("settings.update.status_force_full_checking", "Checking for full installer...");
|
||||
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce: true);
|
||||
_lastCheckResult = result.Success ? result : null;
|
||||
|
||||
if (!result.Success || result.PreferredAsset is null)
|
||||
{
|
||||
UpdateStatus = L("settings.update.status_force_full_failed", "No full installer available.");
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateTypeText = L("settings.update.type_full", "Full Update");
|
||||
await DownloadFullInstallerCoreAsync(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsCheckingForUpdates = false;
|
||||
IsDownloadProgressVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadFullInstallerCoreAsync(UpdateCheckResult result)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsDownloading = true;
|
||||
IsDownloadProgressVisible = true;
|
||||
UpdatePhaseText = L("settings.update.phase_downloading_full", "Downloading full installer...");
|
||||
DownloadProgressValue = 0;
|
||||
PhaseProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = L("settings.update.status_downloading_full", "Downloading full installer...");
|
||||
|
||||
var progress = new Progress<double>(value =>
|
||||
{
|
||||
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
||||
PhaseProgressValue = DownloadProgressValue;
|
||||
DownloadProgressText = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
||||
DownloadProgressValue);
|
||||
});
|
||||
|
||||
var downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress, CancellationToken.None);
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
UpdateStatus = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_download_failed_format", "Download failed: {0}"),
|
||||
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyPendingState(_settingsFacade.Update.Get());
|
||||
UpdateStatus = downloadResult.HashVerified
|
||||
? BuildPendingReadyStatus()
|
||||
: string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
|
||||
downloadResult.ActualHash ?? "N/A");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesCoreAsync(bool isForce)
|
||||
{
|
||||
try
|
||||
@@ -2085,6 +2132,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
IsDownloadProgressVisible = false;
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdatePhaseText = isForce
|
||||
? L("settings.update.phase_force_scanning", "Force scanning update source...")
|
||||
: L("settings.update.phase_scanning", "Scanning update source...");
|
||||
PhaseProgressValue = 0;
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_checking", "Force checking update source...")
|
||||
: L("settings.update.status_checking", "Checking update source...");
|
||||
@@ -2093,6 +2144,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
_lastCheckResult = result.Success ? result : null;
|
||||
RefreshLastCheckedFromSettings();
|
||||
|
||||
UpdatePhaseText = L("settings.update.phase_locating_resources", "Locating update resources...");
|
||||
PhaseProgressValue = 10;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
UpdateStatus = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||
@@ -2105,6 +2159,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
ApplyCheckResultDisplay(result);
|
||||
UpdateTypeText = UpdateWorkflowService.IsDeltaUpdateAvailable(result)
|
||||
? L("settings.update.type_delta", "Incremental Update")
|
||||
: L("settings.update.type_full", "Full Update");
|
||||
if (!result.IsUpdateAvailable && !isForce)
|
||||
{
|
||||
return;
|
||||
@@ -2255,12 +2312,15 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
|
||||
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
|
||||
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
|
||||
UpdateSourceLabel = L("settings.update.source_label", "Download Source");
|
||||
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
|
||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
|
||||
ForceFullUpdateLabel = L("settings.update.force_full_label", "Force Full Update");
|
||||
ForceFullUpdateDescription = L("settings.update.force_full_desc", "Skip incremental update and force download the full installer. Use this if incremental update fails repeatedly.");
|
||||
NetworkAccelerationLabel = L("settings.update.network_accel_label", "Network Acceleration");
|
||||
NetworkAccelerationDescription = L("settings.update.network_accel_desc", "Use gh-proxy mirror to accelerate GitHub downloads. Only applies when falling back to GitHub for full updates.");
|
||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||
@@ -2272,15 +2332,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
||||
StableChannelText = L("settings.update.channel_stable", "Stable");
|
||||
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
||||
PdcSourceText = L("settings.update.source_pdc", "PDC");
|
||||
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
||||
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
||||
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
||||
DownloadThenConfirmModeText = L("settings.update.mode_download_then_confirm", "Silent Download");
|
||||
SilentOnExitModeText = L("settings.update.mode_silent_on_exit", "Silent Install");
|
||||
SelectedUpdateChannelDescription = BuildUpdateChannelDescription(SelectedUpdateChannelValue);
|
||||
SelectedUpdateModeDescription = BuildUpdateModeDescription(SelectedUpdateModeValue);
|
||||
SelectedUpdateSourceDescription = BuildUpdateSourceDescription(SelectedUpdateSourceValue);
|
||||
}
|
||||
|
||||
private void LoadStateFromSettings()
|
||||
@@ -2288,7 +2344,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
var update = _settingsFacade.Update.Get();
|
||||
_isInitializing = true;
|
||||
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
|
||||
SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource);
|
||||
UseGhProxyMirror = update.UseGhProxyMirror;
|
||||
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);
|
||||
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(update.UpdateDownloadThreads);
|
||||
DownloadThreadsText = ((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
||||
@@ -2368,10 +2424,14 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
IsDownloadProgressVisible = true;
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdatePhaseText = UpdateWorkflowService.IsDeltaUpdateAvailable(result)
|
||||
? L("settings.update.phase_downloading_delta", "Downloading incremental update...")
|
||||
: L("settings.update.phase_downloading_full", "Downloading full installer...");
|
||||
|
||||
var progress = new Progress<double>(value =>
|
||||
{
|
||||
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
||||
PhaseProgressValue = DownloadProgressValue;
|
||||
DownloadProgressText = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
||||
@@ -2466,22 +2526,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildUpdateSourceDescription(string? value)
|
||||
{
|
||||
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
|
||||
{
|
||||
UpdateSettingsValues.DownloadSourcePdc => L(
|
||||
"settings.update.source_pdc_desc",
|
||||
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
|
||||
UpdateSettingsValues.DownloadSourceGhProxy => L(
|
||||
"settings.update.source_ghproxy_desc",
|
||||
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
||||
_ => L(
|
||||
"settings.update.source_github_desc",
|
||||
"Download release assets directly from GitHub.")
|
||||
};
|
||||
}
|
||||
|
||||
private string FormatTimestamp(long? utcMs)
|
||||
{
|
||||
if (utcMs is not > 0)
|
||||
@@ -2509,6 +2553,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
|
||||
OnPropertyChanged(nameof(DownloadThreadsValueText));
|
||||
RedownloadUpdateCommand.NotifyCanExecuteChanged();
|
||||
ForceFullUpdateCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
|
||||
@@ -2520,16 +2565,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateUpdateSourceOptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateUpdateModeOptions()
|
||||
{
|
||||
return
|
||||
@@ -2554,8 +2589,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
SelectedUpdateChannelOption = UpdateChannelOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, SelectedUpdateChannelValue, StringComparison.OrdinalIgnoreCase));
|
||||
SelectedUpdateSourceOption = UpdateSourceOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, SelectedUpdateSourceValue, StringComparison.OrdinalIgnoreCase));
|
||||
SelectedUpdateModeOption = UpdateModeOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, SelectedUpdateModeValue, StringComparison.OrdinalIgnoreCase));
|
||||
SelectedDownloadThreadsOption = DownloadThreadOptions.FirstOrDefault(option =>
|
||||
|
||||
94
LanMountainDesktop/ViewModels/UpdateProgressViewModel.cs
Normal file
94
LanMountainDesktop/ViewModels/UpdateProgressViewModel.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed partial class UpdateProgressViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDisposable _subscription;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private bool _disposed;
|
||||
|
||||
public UpdateProgressViewModel(IObservable<InstallProgressReport> progressStream)
|
||||
{
|
||||
_subscription = progressStream.Subscribe(new ActionObserver<InstallProgressReport>(OnNext));
|
||||
}
|
||||
|
||||
[ObservableProperty] private string _stageText = string.Empty;
|
||||
[ObservableProperty] private double _progressFraction;
|
||||
[ObservableProperty] private string _currentFile = string.Empty;
|
||||
[ObservableProperty] private int _filesCompleted;
|
||||
[ObservableProperty] private int _filesTotal;
|
||||
[ObservableProperty] private bool _isCompleted;
|
||||
[ObservableProperty] private bool _isSuccess;
|
||||
[ObservableProperty] private string _errorMessage = string.Empty;
|
||||
|
||||
public int ProgressPercent => (int)Math.Clamp(ProgressFraction * 100, 0, 100);
|
||||
|
||||
partial void OnProgressFractionChanged(double value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ProgressPercent));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
_cts.Cancel();
|
||||
IsCompleted = true;
|
||||
IsSuccess = false;
|
||||
ErrorMessage = "Cancelled by user.";
|
||||
}
|
||||
|
||||
public CancellationToken CancellationToken => _cts.Token;
|
||||
|
||||
private void OnNext(InstallProgressReport report)
|
||||
{
|
||||
StageText = report.Message;
|
||||
ProgressFraction = report.FilesTotal > 0
|
||||
? (double)report.FilesCompleted / report.FilesTotal
|
||||
: report.ProgressPercent / 100.0;
|
||||
CurrentFile = report.CurrentFile ?? string.Empty;
|
||||
FilesCompleted = report.FilesCompleted;
|
||||
FilesTotal = report.FilesTotal;
|
||||
|
||||
if (report.Stage is InstallStage.Completed)
|
||||
{
|
||||
IsCompleted = true;
|
||||
IsSuccess = true;
|
||||
}
|
||||
else if (report.Stage is InstallStage.Failed)
|
||||
{
|
||||
IsCompleted = true;
|
||||
IsSuccess = false;
|
||||
ErrorMessage = report.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnError(Exception ex)
|
||||
{
|
||||
IsCompleted = true;
|
||||
IsSuccess = false;
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
|
||||
private void OnCompleted()
|
||||
{
|
||||
IsCompleted = true;
|
||||
IsSuccess = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_subscription.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
208
LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs
Normal file
208
LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
using UpdateSettingsValues = LanMountainDesktop.Services.UpdateSettingsValues;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly UpdateOrchestrator _orchestrator;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private bool _disposed;
|
||||
|
||||
public UpdateSettingsViewModel(UpdateOrchestrator orchestrator, ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
|
||||
CurrentPhase = _orchestrator.CurrentPhase;
|
||||
CurrentVersionText = _settingsFacade.ApplicationInfo.GetAppVersionText();
|
||||
LoadPreferenceState();
|
||||
|
||||
_orchestrator.PhaseChanged += OnOrchestratorPhaseChanged;
|
||||
_orchestrator.ProgressChanged += OnOrchestratorProgressChanged;
|
||||
}
|
||||
|
||||
[ObservableProperty] private UpdatePhase _currentPhase = UpdatePhase.Idle;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private double _progressFraction;
|
||||
[ObservableProperty] private string _progressDetail = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _currentVersionText = string.Empty;
|
||||
[ObservableProperty] private string _latestVersionText = string.Empty;
|
||||
[ObservableProperty] private string _publishedAtText = string.Empty;
|
||||
[ObservableProperty] private string _lastCheckedText = string.Empty;
|
||||
[ObservableProperty] private string _updateTypeText = string.Empty;
|
||||
[ObservableProperty] private bool _isUpdateAvailable;
|
||||
[ObservableProperty] private bool _isDeltaUpdate;
|
||||
|
||||
[ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
[ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
|
||||
|
||||
public bool IsBusy => CurrentPhase.IsBusy();
|
||||
public bool CanCheck => CurrentPhase.CanCheck();
|
||||
public bool CanDownload => CurrentPhase.CanDownload();
|
||||
public bool CanInstall => CurrentPhase.CanInstall();
|
||||
public bool CanRollback => CurrentPhase.CanRollback();
|
||||
public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack;
|
||||
|
||||
partial void OnCurrentPhaseChanged(UpdatePhase value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsBusy));
|
||||
OnPropertyChanged(nameof(CanCheck));
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanInstall));
|
||||
OnPropertyChanged(nameof(CanRollback));
|
||||
OnPropertyChanged(nameof(IsProgressVisible));
|
||||
CheckCommand.NotifyCanExecuteChanged();
|
||||
DownloadCommand.NotifyCanExecuteChanged();
|
||||
InstallCommand.NotifyCanExecuteChanged();
|
||||
RollbackCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateChannelValueChanged(string value)
|
||||
{
|
||||
SavePreferenceState();
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateSourceValueChanged(string value)
|
||||
{
|
||||
SavePreferenceState();
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateModeValueChanged(string value)
|
||||
{
|
||||
SavePreferenceState();
|
||||
}
|
||||
|
||||
partial void OnDownloadThreadsSliderValueChanged(double value)
|
||||
{
|
||||
SavePreferenceState();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCheck))]
|
||||
private async Task CheckAsync()
|
||||
{
|
||||
var report = await _orchestrator.CheckAsync(CancellationToken.None);
|
||||
if (report.IsUpdateAvailable)
|
||||
{
|
||||
IsUpdateAvailable = true;
|
||||
LatestVersionText = report.LatestVersion ?? string.Empty;
|
||||
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g") ?? string.Empty;
|
||||
UpdateTypeText = report.PayloadKind?.ToString() ?? string.Empty;
|
||||
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
|
||||
StatusMessage = $"New version {report.LatestVersion} is available.";
|
||||
}
|
||||
else
|
||||
{
|
||||
IsUpdateAvailable = false;
|
||||
LatestVersionText = string.Empty;
|
||||
PublishedAtText = string.Empty;
|
||||
UpdateTypeText = string.Empty;
|
||||
IsDeltaUpdate = false;
|
||||
StatusMessage = report.ErrorMessage ?? "You are up to date.";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDownload))]
|
||||
private async Task DownloadAsync()
|
||||
{
|
||||
StatusMessage = "Downloading update...";
|
||||
var result = await _orchestrator.DownloadAsync(CancellationToken.None);
|
||||
if (result.Success)
|
||||
{
|
||||
StatusMessage = "Download complete. Ready to install.";
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = result.ErrorMessage ?? "Download failed.";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanInstall))]
|
||||
private async Task InstallAsync()
|
||||
{
|
||||
StatusMessage = "Installing update...";
|
||||
var result = await _orchestrator.InstallAsync(CancellationToken.None);
|
||||
if (result.Success)
|
||||
{
|
||||
StatusMessage = "Update installed successfully.";
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = result.ErrorMessage ?? "Install failed.";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRollback))]
|
||||
private async Task RollbackAsync()
|
||||
{
|
||||
StatusMessage = "Rolling back...";
|
||||
await _orchestrator.RollbackAsync(CancellationToken.None);
|
||||
StatusMessage = "Rollback complete.";
|
||||
}
|
||||
|
||||
private void OnOrchestratorPhaseChanged(UpdatePhase phase)
|
||||
{
|
||||
CurrentPhase = phase;
|
||||
}
|
||||
|
||||
private void OnOrchestratorProgressChanged(UpdateProgressReport report)
|
||||
{
|
||||
ProgressFraction = report.ProgressFraction;
|
||||
StatusMessage = report.Message;
|
||||
if (report.DownloadDetail is not null)
|
||||
{
|
||||
ProgressDetail = $"{report.DownloadDetail.CurrentFile} ({report.DownloadDetail.OverallPercent}%)";
|
||||
}
|
||||
else if (report.InstallDetail is not null)
|
||||
{
|
||||
ProgressDetail = report.InstallDetail.CurrentFile ?? report.InstallDetail.Message;
|
||||
}
|
||||
else
|
||||
{
|
||||
ProgressDetail = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadPreferenceState()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
SelectedUpdateChannelValue = state.UpdateChannel;
|
||||
SelectedUpdateSourceValue = state.UpdateDownloadSource;
|
||||
SelectedUpdateModeValue = state.UpdateMode;
|
||||
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
|
||||
}
|
||||
|
||||
private void SavePreferenceState()
|
||||
{
|
||||
var current = _settingsFacade.Update.Get();
|
||||
_settingsFacade.Update.Save(current with
|
||||
{
|
||||
UpdateChannel = SelectedUpdateChannelValue,
|
||||
UpdateDownloadSource = SelectedUpdateSourceValue,
|
||||
UpdateMode = SelectedUpdateModeValue,
|
||||
UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue))
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_orchestrator.PhaseChanged -= OnOrchestratorPhaseChanged;
|
||||
_orchestrator.ProgressChanged -= OnOrchestratorProgressChanged;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:fa="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent"
|
||||
xmlns:fa="clr-namespace:FluentIcons.Avalonia;assembly=FluentIcons.Avalonia"
|
||||
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:themes="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
||||
mc:Ignorable="d"
|
||||
@@ -16,7 +16,7 @@
|
||||
CanResize="True"
|
||||
SizeToContent="Manual"
|
||||
ShowInTaskbar="False"
|
||||
SystemDecorations="BorderOnly"
|
||||
WindowDecorations="BorderOnly"
|
||||
Background="Transparent"
|
||||
Title="Component Editor">
|
||||
<Window.Resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user