Compare commits

...

5 Commits

Author SHA1 Message Date
lincube
458494d131 Add update contracts, IPC progress & providers
Introduce a new update subsystem: shared contracts for manifests, messages, paths and state (LanMountainDesktop.Shared.Contracts.Update). Add IPC and reporting infrastructure for installer progress (IUpdateProgressReporter, LauncherUpdateProgressIpcServer, NullUpdateProgressReporter) and integrate progress/complete reporting into UpdateEngineService. Add multiple update service components and providers (CompositeManifestProvider, GithubReleaseManifestProvider, PlondsApiManifestProvider, UpdateDownloadEngine, UpdateOrchestrator, UpdateInstallGateway, CLI launcher bridge, launcher bridge interfaces, observable helper, state store, progress subject, JSON context). Update settings and models to support UseGhProxyMirror and PLONDS/GitHub fallback logic, plus localization strings and UI/viewmodel files for update settings and progress. Misc: installer script tweak and a small change in Plonds generator. This adds end-to-end support for checking, downloading and reporting update progress and results.
2026-05-03 19:31:04 +08:00
lincube
01670147f6 Bump packages; fix resume flag & Sentry attach
Update workspace settings and dependency versions, plus small service fixes. .arts/settings.json adds editor clawMode and activityBar location. Directory.Packages.props upgrades many packages (e.g. Avalonia to 12.0.2, CommunityToolkit.Mvvm, Downloader, Sentry to 6.4.1, Microsoft.* previews, and more). ResumableDownloadService renamed ResumeDownloadIfCan to EnableAutoResumeDownload. SentryCrashTelemetryService now adds the log-tail as an attachment via scope.AddAttachment(byte[], name, contentType) instead of creating an Attachment object.
2026-05-01 13:59:52 +08:00
lincube
0348324fa3 Add LauncherPathResolver and refactor data paths
Introduce LauncherPathResolver to centralize resolving the launcher executable and .Launcher data directory (with write-permission fallback). Refactor DataLocationResolver to use a fixed ".Launcher" launcher data folder, expose explicit ResolveLauncherDataPath/ResolveDesktopDataPath/ResolveConfigPath/ResolveLauncherLogsPath/ResolveLauncherStatePath methods, and fix config load/save and directory creation to avoid dependency cycles. Update LauncherClient, UpdateWorkflowService and PluginMarketInstallService to use the new resolver (remove duplicated ResolveLauncherPath logic) and improve error messages when the launcher is missing.
2026-05-01 11:31:40 +08:00
lincube
fc4d0c4cd8 Support .laapp/plugin.json and improve market models
Add support for the new plugin package contract (.laapp + plugin.json) while keeping backward compatibility with legacy .lmdp/manifest.json, and improve market metadata resolution and launcher handling.

Key changes:
- LanMountainDesktop.Launcher: PluginInstallerService now recognizes plugin.json and .laapp, preserves legacy manifest/package names, searches for manifests with a helper, and removes existing packages matching either extension.
- LanMountainDesktop.PluginTemplate: README updated to document .laapp, plugin.json, runtime contract and packaging expectations.
- Tests: New and extended tests for PluginInstallerService and a PluginMarketIndexDocumentTests covering nested index parsing and metadata enrichment.
- LauncherClient & PluginMarketInstallService: ResolveLauncherPath now probes multiple candidate locations (useful for dev and packaged layouts); LauncherClient also adjusted launcher arguments to use the updated CLI form.
- SettingsDomainServices: Added BuildCapabilities to safely build capability lists from entries (null checks, projection, de-dup via DistinctBy).
- AirAppMarketMetadataResolverService & PluginMarketModels: Prefer existing manifest/publication/compatibility values when enriching entries, add ApiVersion/Path fields, normalize compatibility logic and package source URL/path handling; handle Sha256/size/publication dates more robustly.
- Misc: Added localization spec/checklist/tasks under .trae for a localization fix initiative.

These changes enable the new plugin packaging format, improve robustness of market data enrichment, make launcher discovery more flexible for different environments, and add tests and docs to cover the new behaviors.
2026-04-30 00:02:52 +08:00
lincube
eb066b53f1 Introduce render mode & static component previews
Add DesktopComponentRenderMode and thread the render mode through runtime context and creation APIs so controls can be created for library previews. Replace image-based preview system with static preview Controls: viewmodels and ComponentLibraryWindow now use PreviewControl, and ComponentPreviewImageService/related types and tests were removed. Add ComponentPreviewRuntimeQuiescer to attach/detach preview controls (stop timers, disable input) for safe static previews. Simplify component-library collapse state/presenter by removing transient expanded opacity handling. Update runtime registry, services, views and tests to support the new flow.
2026-04-29 19:43:29 +08:00
83 changed files with 5012 additions and 2378 deletions

View File

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

View 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]'` 等检查)

View 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 显示"自定义颜色"

View 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 检查是否有遗漏的硬编码英文(通过正则搜索)

View File

@@ -3,40 +3,40 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="12.0.1" />
<PackageVersion Include="Avalonia" Version="12.0.2" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.1" />
<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.2.1" />
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<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="4.1.1" />
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview1" />
<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.0" />
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.8" />
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
<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.4.0" />
<PackageVersion Include="Sentry" Version="4.0.0" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.0" />
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
<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="2.8.2" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="log4net" Version="3.3.0" />
<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>

View File

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

View File

@@ -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-* 部署目录同级。此目录不随数据位置模式改变。
///
/// DesktopHost数据目录则根据用户选择可位于系统目录或便携目录。
/// </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,13 +47,49 @@ 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);
private string ResolveBootstrapLauncherDataPath()
/// <summary>
/// Launcher 数据目录,固定位于应用安装根目录下的 .Launcher 文件夹。
/// 该目录与 app-* 部署目录同级,不随数据位置模式改变。
/// </summary>
public string ResolveLauncherDataPath()
{
return Path.Combine(_defaultSystemDataPath, LauncherFolderName);
return Path.Combine(_appRoot, LauncherDataFolderName);
}
/// <summary>
/// 桌面应用数据目录(组件、设置、插件等)
/// </summary>
public string ResolveDesktopDataPath()
{
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
}
/// <summary>
/// 数据位置配置文件路径(保存在 Launcher 数据目录下)
/// </summary>
public string ResolveConfigPath()
{
return Path.Combine(ResolveLauncherDataPath(), ConfigFileName);
}
/// <summary>
/// 启动器日志目录
/// </summary>
public string ResolveLauncherLogsPath()
{
return Path.Combine(ResolveLauncherDataPath(), "logs");
}
/// <summary>
/// 启动器状态目录
/// </summary>
public string ResolveLauncherStatePath()
{
return Path.Combine(ResolveLauncherDataPath(), "state");
}
/// <summary>
@@ -55,6 +110,19 @@ internal sealed class DataLocationResolver
}
}
public DataLocationMode ResolveMode()
{
var config = LoadConfig();
if (config is null)
{
return DataLocationMode.System;
}
return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)
? DataLocationMode.Portable
: DataLocationMode.System;
}
/// <summary>
/// 解析数据根目录(用户选择的位置)
/// </summary>
@@ -84,66 +152,11 @@ internal sealed class DataLocationResolver
: _defaultSystemDataPath;
}
/// <summary>
/// 启动器数据目录(日志、配置、状态等)
/// </summary>
public string ResolveLauncherDataPath()
{
return Path.Combine(ResolveDataRoot(), LauncherFolderName);
}
/// <summary>
/// 桌面应用数据目录(组件、设置、插件等)
/// </summary>
public string ResolveDesktopDataPath()
{
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
}
/// <summary>
/// 数据位置配置文件路径(保存在 Launcher 目录下)
/// </summary>
public string ResolveConfigPath()
{
return Path.Combine(ResolveBootstrapLauncherDataPath(), ConfigFileName);
}
/// <summary>
/// 启动器日志目录
/// </summary>
public string ResolveLauncherLogsPath()
{
return Path.Combine(ResolveLauncherDataPath(), "logs");
}
/// <summary>
/// 启动器状态目录
/// </summary>
public string ResolveLauncherStatePath()
{
return Path.Combine(ResolveLauncherDataPath(), "state");
}
public DataLocationMode ResolveMode()
{
var config = LoadConfig();
if (config is null)
{
return DataLocationMode.System;
}
return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)
? DataLocationMode.Portable
: DataLocationMode.System;
}
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;
@@ -163,8 +176,8 @@ internal sealed class DataLocationResolver
{
try
{
var launcherPath = ResolveBootstrapLauncherDataPath();
Directory.CreateDirectory(launcherPath);
var launcherDataPath = ResolveLauncherDataPath();
Directory.CreateDirectory(launcherDataPath);
var configPath = ResolveConfigPath();
var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig);
@@ -194,9 +207,8 @@ internal sealed class DataLocationResolver
// 先创建目录结构
try
{
var resolvedDataRoot = ResolveDataRoot(config);
Directory.CreateDirectory(Path.Combine(resolvedDataRoot, LauncherFolderName));
Directory.CreateDirectory(Path.Combine(resolvedDataRoot, DesktopFolderName));
Directory.CreateDirectory(ResolveLauncherDataPath());
Directory.CreateDirectory(Path.Combine(ResolveDataRoot(config), DesktopFolderName));
}
catch (Exception ex)
{

View File

@@ -0,0 +1,9 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
public interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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);

View 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}}"
}
""";
}
}

View 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;
}

View File

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

View File

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

View 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;
}
}
}

View File

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

View 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("{}")
});
}
}
}

View File

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

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.ComponentSystem;
public enum DesktopComponentRenderMode
{
Live = 0,
LibraryPreview = 1
}

View File

@@ -13,4 +13,5 @@ public sealed record DesktopComponentRuntimeContext(
IAppearanceThemeService AppearanceTheme,
ComponentChromeContext Chrome,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore);
IComponentInstanceSettingsStore ComponentSettingsStore,
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);

View File

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

View File

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

View File

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

View File

@@ -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": "更新",

View File

@@ -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": "새로고침",

View File

@@ -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": "刷新",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}
}

View File

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

View File

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

View File

@@ -88,6 +88,7 @@ public sealed record UpdateSettingsState(
string UpdateMode,
string UpdateDownloadSource,
int UpdateDownloadThreads,
bool UseGhProxyMirror,
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs,

View File

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

View File

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

View File

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

View File

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

View 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();
}

View File

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

View 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();
}
}

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

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

View 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();
}
}

View 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;
}
}

View 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;

View 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;
}
}

View 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();
}
}

View 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() { }
}
}

View 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;
}

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

View 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;
}
}

View File

@@ -99,48 +99,11 @@
BorderThickness="1"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Padding="8">
<Grid>
<Image Source="{Binding PreviewBitmap}"
Stretch="Uniform"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RenderOptions.BitmapInterpolationMode="HighQuality"
IsVisible="{Binding IsPreviewReady}" />
<Border IsVisible="{Binding IsPreviewPending}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<ProgressBar Width="96"
IsIndeterminate="True" />
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding PreviewStatusText}" />
</StackPanel>
</Border>
<Border IsVisible="{Binding IsPreviewFailed}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding PreviewStatusText}" />
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding PreviewErrorMessage}" />
</StackPanel>
</Border>
</Grid>
<ContentControl Content="{Binding PreviewControl}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Focusable="False" />
</Border>
<TextBlock Grid.Row="1"

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using FluentIcons.Common;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
@@ -14,10 +15,6 @@ public partial class ComponentLibraryWindow : Window
private IComponentLibraryService? _componentLibraryService;
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
private Func<string, string, string>? _localize;
private Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? _previewKeyResolver;
private Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? _previewEntryResolver;
private Action<ComponentPreviewKey>? _warmPreviewRequested;
private Action<ComponentPreviewKey>? _renderPreviewRequested;
private readonly ComponentLibraryWindowViewModel _viewModel = new();
public ComponentLibraryWindow()
@@ -29,20 +26,12 @@ public partial class ComponentLibraryWindow : Window
public ComponentLibraryWindow(
IComponentLibraryService componentLibraryService,
Func<double, ComponentLibraryCreateContext> createContextFactory,
Func<string, string, string> localize,
Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? previewKeyResolver = null,
Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? previewEntryResolver = null,
Action<ComponentPreviewKey>? warmPreviewRequested = null,
Action<ComponentPreviewKey>? renderPreviewRequested = null)
Func<string, string, string> localize)
: this()
{
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
_previewKeyResolver = previewKeyResolver;
_previewEntryResolver = previewEntryResolver;
_warmPreviewRequested = warmPreviewRequested;
_renderPreviewRequested = renderPreviewRequested;
Reload();
}
@@ -56,6 +45,7 @@ public partial class ComponentLibraryWindow : Window
}
_viewModel.Title = _localize("component_library.title", "Widgets");
DisposePreviewControls(_viewModel.Categories.SelectMany(static category => category.Components));
_viewModel.Categories.Clear();
_viewModel.Components.Clear();
@@ -88,24 +78,12 @@ public partial class ComponentLibraryWindow : Window
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
? entry.DisplayName
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
var previewKey = ResolvePreviewKey(entry);
var previewEntry = _previewEntryResolver?.Invoke(previewKey);
var item = new ComponentLibraryItemViewModel(
var previewControl = CreateStaticPreviewControl(entry);
return new ComponentLibraryItemViewModel(
entry.ComponentId,
displayName,
previewKey,
description: null,
_localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...",
_localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable",
previewEntry);
if (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending)
{
_warmPreviewRequested?.Invoke(previewKey);
_renderPreviewRequested?.Invoke(previewKey);
}
return item;
previewControl);
}
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -124,7 +102,7 @@ public partial class ComponentLibraryWindow : Window
_viewModel.Components.Add(component);
}
RequestPreviewWarmup(selectedCategory.Components);
ComponentPreviewRuntimeQuiescer.Quiesce(this);
}
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
@@ -147,48 +125,54 @@ public partial class ComponentLibraryWindow : Window
Hide();
}
public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry)
private Control? CreateStaticPreviewControl(ComponentLibraryComponentEntry entry)
{
ArgumentNullException.ThrowIfNull(previewImageEntry);
foreach (var category in _viewModel.Categories)
if (_componentLibraryService is null || _createContextFactory is null)
{
foreach (var component in category.Components)
{
if (component.PreviewKey.Equals(previewImageEntry.Key))
{
component.UpdatePreviewImageEntry(previewImageEntry);
}
}
return null;
}
var cellSize = ResolvePreviewCellSize(entry);
var context = _createContextFactory(cellSize) with
{
PlacementId = null,
RenderMode = DesktopComponentRenderMode.LibraryPreview
};
if (!_componentLibraryService.TryCreateControl(entry.ComponentId, context, out var control, out _))
{
return null;
}
if (control is not null)
{
ComponentPreviewRuntimeQuiescer.Attach(control);
}
return control;
}
private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry)
private static double ResolvePreviewCellSize(ComponentLibraryComponentEntry entry)
{
if (_previewKeyResolver is not null)
{
return _previewKeyResolver(entry);
}
return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
var maxWidth = 180d;
var maxHeight = 120d;
return Math.Clamp(
Math.Min(
maxWidth / Math.Max(1, entry.MinWidthCells),
maxHeight / Math.Max(1, entry.MinHeightCells)),
24d,
72d);
}
private void RequestPreviewWarmup(IEnumerable<ComponentLibraryItemViewModel> components)
private static void DisposePreviewControls(IEnumerable<ComponentLibraryItemViewModel> components)
{
if (_warmPreviewRequested is null && _renderPreviewRequested is null)
foreach (var control in components.Select(static component => component.PreviewControl).OfType<Control>())
{
return;
}
foreach (var component in components)
{
if (!component.IsPreviewPending)
ComponentPreviewRuntimeQuiescer.Detach(control);
if (control is IDisposable disposable)
{
continue;
disposable.Dispose();
}
_warmPreviewRequested?.Invoke(component.PreviewKey);
_renderPreviewRequested?.Invoke(component.PreviewKey);
}
}

View File

@@ -24,7 +24,8 @@ public sealed record DesktopComponentControlFactoryContext(
ISettingsService SettingsService,
IComponentInstanceSettingsStore ComponentSettingsStore,
IComponentSettingsAccessor ComponentSettingsAccessor,
string? PlacementId = null);
string? PlacementId = null,
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
public sealed class DesktopComponentRuntimeRegistration
{
@@ -115,7 +116,8 @@ public sealed class DesktopComponentRuntimeDescriptor
IRecommendationInfoService recommendationInfoService,
ICalculatorDataService calculatorDataService,
ISettingsFacadeService settingsFacade,
string? placementId = null)
string? placementId = null,
DesktopComponentRenderMode renderMode = DesktopComponentRenderMode.Live)
{
ArgumentNullException.ThrowIfNull(settingsFacade);
@@ -141,7 +143,8 @@ public sealed class DesktopComponentRuntimeDescriptor
settingsService,
componentSettingsStore,
componentAccessor,
placementId));
placementId,
renderMode));
var runtimeContext = new DesktopComponentRuntimeContext(
Definition.Id,
placementId,
@@ -150,7 +153,8 @@ public sealed class DesktopComponentRuntimeDescriptor
appearanceTheme,
chromeContext,
componentAccessor,
componentSettingsStore);
componentSettingsStore,
renderMode);
ApplySettingsDependencies(control, settingsService, componentSettingsStore);

View File

@@ -1,24 +1,16 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:converters="using:Avalonia.Data.Converters"
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
x:DataType="vm:ComponentLibraryWindowViewModel">
<UserControl.Styles>
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍褜鍓欓崯顖炲Φ閸曨厽鍠嗛柛鏇ㄥ幖椤ュ酣鎮?- 闂傚倷绶¢崜鐔奉焽瑜旈獮?Fluent NavigationView 濠碉紕鍋涢鍛偓娑掓櫊閹?-->
<Style Selector="ListBoxItem.category-item">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00"/>
</Transitions>
</Setter>
</Style>
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
@@ -26,18 +18,6 @@
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}"/>
</Style>
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绲绘禍婊堟煟閻斿搫顣肩紒鍌氱墦閺屸€愁吋閸涱喗鎮欓梺纭呮腹閸楀啿顕i鍕倞鐟滃繘骞?-->
<Style Selector="ListBoxItem.category-item fi|FluentIcon.category-icon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:selected fi|FluentIcon.category-icon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
@@ -47,14 +27,9 @@
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="0"
Margin="0">
<!-- 闁诲骸缍婂鑽ょ磽濮樿泛鐤鹃柛鎾茶閸嬫挻鎷呴崘顭戞闂佺硶鏅涢幊妯虹暦?- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍?+ 闂佸湱鍘ч悺銊ッ洪悢鐓庣??闂備礁鎼悮顐﹀磿閸欏鐝舵慨妞诲亾鐎殿喗鎸冲鍫曞箣椤撶啿鏌ょ紓鍌氬€风粈浣衡偓姘间簻閳? -->
<Border Width="280"
Background="Transparent">
<Grid ColumnDefinitions="Auto,*">
<Border Width="280" Background="Transparent">
<Grid RowDefinitions="*,Auto">
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍?-->
<ListBox x:Name="CategoryListBox"
Grid.Row="0"
Background="Transparent"
@@ -64,13 +39,10 @@
ItemsSource="{Binding Categories}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12"
Margin="12,10">
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12" Margin="12,10">
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Regular"
FontSize="18"
Classes="category-icon"/>
FontSize="18"/>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontSize="14"
@@ -81,9 +53,7 @@
</ListBox.ItemTemplate>
</ListBox>
<!-- 闂佸湱鍘ч悺銊ッ洪悢鐓庣??闂備礁鎼悮顐﹀磿閸欏鐝舵慨妞诲亾鐎殿喗鎸冲鍫曞箣椤撶啿鏌ょ紓鍌氬€风粈浣衡偓姘间簻閳? - 闂備線娼荤拹鐔煎礉鐏炲墽鈻曢煫鍥ㄦ⒒閻熷湱鎲稿澶樻晪闂侇剙绉甸崵瀣亜韫囨挸顏╅柣蹇旂懇楠炴牜鈧稒蓱缁€瀣煕?-->
<StackPanel Grid.Row="1"
Margin="12,8,8,12">
<StackPanel Grid.Row="1" Margin="12,8,8,12">
<Border Height="1"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.4"
@@ -100,35 +70,26 @@
</Grid>
</Border>
<!-- 闂備礁鎲¢悷銉╁储閺嶎厼鐤鹃柛顐f礀缁€鍐煕濞戝崬寮鹃柛鐔锋喘閺屾盯寮介浣碘偓鍐磼濡も偓閼活垶顢欒箛娑欐櫆闁圭瀛╅悵鐑芥⒑濮瑰洤濡奸悗姘煎墴瀹曡鎯旈妸锔规寗闂佸搫鍟崐绋库枔?-->
<Border Grid.Column="1"
Width="1"
HorizontalAlignment="Left"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.5"/>
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€閳藉懐鐭楅梺鍛婃处閸n喖顭囬弮鍫熺厱?(闂備礁鎲¢悷銉╁储閺嶎厼鐤? -->
<ScrollViewer Grid.Column="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,8,12,8"
Spacing="0">
<!-- 闂備礁鎼悧鍡浰囬悽绋跨劦妞ゆ巻鍋撴い锔诲櫍閹虫瑩骞嬮悩鐢碉紲闂佸憡娲︽禍婵嬵敃娴犲鐓涢柛鎰╁妼椤h櫕绻涢崼鐔风伌鐎殿喕鍗冲畷婊嗩槹濞?-->
<StackPanel Margin="16,8,12,8">
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€椤厽鍕冮梺鍝勬川婵増绂掑☉銏♀拻闁割偅绋戦悘顏呯節?- 闂備礁鎼悧鍡浰囨潏鈹惧亾濮樼厧骞樼紒顔规櫇閳ь剨缍嗛崢濂稿礈瑜版帗鐓涢柛婊€绀侀悘銉ヮ熆閻熷府韬柡浣哥Ф娴狅箓鎳栭埡鍐╁枦缂傚倷鐒﹂崝鏍€冮崨鑸汗婵炴垯鍨洪崵鍕倶閻愰潧浜鹃柣婵愬灣閹叉悂鎳滈鈧悘顏堟煕閵婏附鐨戝ù鐙呯畵瀹曟帒顭ㄩ崼銏犵闂備礁鎲$敮鎺懳涘☉銏犵柧?-->
<Border Classes="surface-translucent-panel"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="20">
<StackPanel Spacing="16">
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€閵堝懎鍞ㄩ梺鎼炲労閸擄箓寮?-->
<TextBlock FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.DisplayName}"/>
<!-- 闂備焦鎮堕崕閬嶅箹椤愶附鍋╅柣鎰靛墮缁剁偟鎲稿澶嬪剭妞ゆ帒瀚崕宥夋煕閺囥劌鐏遍柡鍡樻礋閹嘲鈻庤箛鏇烆暫閻庤娲熸禍鍫曞箖?-->
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
@@ -136,59 +97,14 @@
Width="420"
Height="300"
HorizontalAlignment="Center">
<Grid Margin="16">
<!-- 濠碘槅鍋呭妯尖偓姘煎灦閿濈偛顓兼径濠勫€為梺鍛婃寙閸愮偓姣?-->
<Image Source="{Binding SelectedComponent.PreviewBitmap}"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderOptions.BitmapInterpolationMode="HighQuality"
IsVisible="{Binding SelectedComponent.IsPreviewReady}"/>
<!-- 闂備礁鎲″缁樻叏閹灐褰掑炊閵娧€鏋栧銈嗘尵婵鐟ч梻?-->
<Border IsVisible="{Binding SelectedComponent.IsPreviewPending}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ProgressBar Width="120"
IsIndeterminate="True"/>
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding SelectedComponent.PreviewStatusText}"/>
</StackPanel>
</Border>
<!-- 濠电姰鍨洪崕鑲╁垝閸撗勫枂闁挎洖鍊归崑鎰版煠閸濄儺鏆柛?-->
<Border IsVisible="{Binding SelectedComponent.IsPreviewFailed}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<fi:FluentIcon Icon="ImageOff"
IconVariant="Regular"
FontSize="48"
Opacity="0.5"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.PreviewStatusText}"/>
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding SelectedComponent.PreviewErrorMessage}"/>
</StackPanel>
</Border>
</Grid>
<ContentControl x:Name="SelectedComponentPreviewHost"
Margin="16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Focusable="False"/>
</Border>
<!-- "婵犵數鍎戠紞鈧い鏇嗗嫭鍙忛柣鎰悁閻掑﹪鐓崶銊︾闁活厼顑呴湁?闂備礁婀遍…鍫ニ囬悽绋跨?- 闂備線娼荤拹鐔煎礉閹存繍鐒藉ù鍏兼綑缁狙囨煕椤垵鏋涢柡浣哥埣閹﹢鎮欓崣澶婃闂佺厧鐏氶崹鍧楀极瀹ュ洣娌柣鎾崇岸閺嬪繘姊哄ú缁樺▏闁告柨顑囬埀顒勬涧閺堫剟鏁嶉幇顑╃喖宕崟顓犵暢闂佽崵濮撮鍛村疮閾忣偆鐝?-->
<Button HorizontalAlignment="Center"
Classes="accent"
Padding="24,10"
@@ -203,12 +119,12 @@
</Border>
</Panel>
<!-- 缂傚倷绀侀惌浣糕枍閿濆棙鍙忛柟闂寸缁?-->
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MinHeight="400">
<StackPanel Spacing="16" HorizontalAlignment="Center"
<StackPanel Spacing="16"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<fi:FluentIcon Icon="Apps"
IconVariant="Regular"

View File

@@ -11,7 +11,6 @@ using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views.Components;
using Avalonia.Controls.ApplicationLifetimes;
namespace LanMountainDesktop.Views;
@@ -19,18 +18,19 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
{
public event EventHandler<string>? AddComponentRequested;
private readonly ComponentLibraryWindowViewModel _viewModel = new();
private List<DesktopComponentDefinition> _allDefinitions = new();
private static readonly LocalizationService LocalizationService = new();
private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private readonly ComponentLibraryWindowViewModel _viewModel = new();
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
private readonly IWeatherInfoService _weatherDataService;
private readonly TimeZoneService _timeZoneService;
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private static readonly LocalizationService _localizationService = new();
private List<DesktopComponentDefinition> _allDefinitions = new();
private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private Control? _selectedPreviewControl;
public FusedDesktopComponentLibraryControl()
{
@@ -43,10 +43,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
LoadRegistry();
LoadCategories();
// 为 ListBoxItem 添加 category-item 样式类
CategoryListBox.ContainerPrepared += OnCategoryListBoxContainerPrepared;
// 默认选择第一个分类
if (_viewModel.Categories.Count > 0)
{
CategoryListBox.SelectedIndex = 0;
@@ -55,6 +52,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void OnCategoryListBoxContainerPrepared(object? sender, ContainerPreparedEventArgs e)
{
_ = sender;
if (e.Container is ListBoxItem listBoxItem)
{
listBoxItem.Classes.Add("category-item");
@@ -71,7 +69,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
_settingsFacade);
_allDefinitions = _componentRegistry.GetAll()
.Where(d => d.AllowDesktopPlacement)
.Where(static definition => definition.AllowDesktopPlacement)
.ToList();
}
@@ -80,8 +78,6 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
_viewModel.Categories.Clear();
var languageCode = _settingsFacade.Region.Get().LanguageCode;
// 添加"全部组件"分类
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
"all",
L(languageCode, "component_category.all", "All"),
@@ -89,32 +85,26 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
Array.Empty<ComponentLibraryItemViewModel>()));
var usedCategories = _allDefinitions
.Select(d => d.Category)
.Distinct()
.Where(c => !string.IsNullOrEmpty(c));
.Select(static definition => definition.Category)
.Where(static category => !string.IsNullOrWhiteSpace(category))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var cat in usedCategories)
foreach (var category in usedCategories)
{
var icon = ResolveCategoryIcon(cat);
var title = GetLocalizedCategoryTitle(languageCode, cat);
var categoryComponents = _allDefinitions
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
.OrderBy(d => d.DisplayName)
.Select(d => CreateComponentItem(d))
.Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase))
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
.Select(CreateComponentItem)
.ToArray();
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
cat,
title,
icon,
category,
GetLocalizedCategoryTitle(languageCode, category),
ResolveCategoryIcon(category),
categoryComponents));
}
}
/// <summary>
/// 分类图标映射 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
/// </summary>
private static Symbol ResolveCategoryIcon(string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock;
@@ -129,9 +119,6 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
return Symbol.Apps;
}
/// <summary>
/// 分类本地化标题 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
/// </summary>
private string GetLocalizedCategoryTitle(string languageCode, string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock");
@@ -148,101 +135,123 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private string L(string languageCode, string key, string fallback)
{
return _localizationService.GetString(languageCode, key, fallback);
return LocalizationService.GetString(languageCode, key, fallback);
}
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
private static ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
{
var previewKey = ComponentPreviewKey.ForComponentType(
definition.Id,
definition.MinWidthCells,
definition.MinHeightCells);
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
ComponentPreviewImageEntry? previewEntry = null;
if (mainWindow is not null)
{
previewEntry = mainWindow.GetPreviewEntry(previewKey);
}
var item = new ComponentLibraryItemViewModel(
definition.Id,
definition.DisplayName,
previewKey,
description: null,
"正在加载预览...",
"预览不可用",
previewEntry);
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
{
mainWindow.RequestDetachedLibraryPreview(previewKey);
}
return item;
}
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
{
foreach (var category in _viewModel.Categories)
{
foreach (var component in category.Components)
{
if (component.PreviewKey.Equals(entry.Key))
{
component.UpdatePreviewImageEntry(entry);
}
}
}
return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName);
}
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
UpdateSelectedComponent();
}
private void UpdateSelectedComponent()
{
var selectedCategory = CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel;
if (selectedCategory is null)
if (CategoryListBox.SelectedItem is not ComponentLibraryCategoryViewModel selectedCategory)
{
_viewModel.SelectedComponent = null;
SetSelectedPreviewControl(null);
return;
}
// 获取该分类下的组件列表
IEnumerable<DesktopComponentDefinition> filtered;
if (selectedCategory.Id == "all")
{
filtered = _allDefinitions.OrderBy(d => d.DisplayName);
}
else
{
filtered = _allDefinitions
.Where(d => string.Equals(d.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
.OrderBy(d => d.DisplayName);
}
var filtered = selectedCategory.Id == "all"
? _allDefinitions.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
: _allDefinitions
.Where(definition => string.Equals(definition.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase);
// 选择该分类下的第一个组件作为默认选中
var firstComponent = filtered.FirstOrDefault();
if (firstComponent is not null)
{
// 查找或创建对应的 ViewModel
var existingComponent = selectedCategory.Components.FirstOrDefault(c => c.ComponentId == firstComponent.Id);
if (existingComponent is not null)
{
_viewModel.SelectedComponent = existingComponent;
}
else
{
_viewModel.SelectedComponent = CreateComponentItem(firstComponent);
}
}
else
if (firstComponent is null)
{
_viewModel.SelectedComponent = null;
SetSelectedPreviewControl(null);
return;
}
_viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id)
?? CreateComponentItem(firstComponent);
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent));
}
private Control? CreateStaticPreviewControl(DesktopComponentDefinition definition)
{
if (_componentRuntimeRegistry is null ||
!_componentRuntimeRegistry.TryGetDescriptor(definition.Id, out var descriptor))
{
return null;
}
try
{
var control = descriptor.CreateControl(
ResolvePreviewCellSize(definition),
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
_calculatorDataService,
_settingsFacade,
placementId: null,
renderMode: DesktopComponentRenderMode.LibraryPreview);
ComponentPreviewRuntimeQuiescer.Attach(control);
return control;
}
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
{
AppLogger.Warn(
"ComponentLibrary",
$"Failed to create static fused preview for component '{definition.Id}'.",
ex);
return null;
}
}
private static double ResolvePreviewCellSize(DesktopComponentDefinition definition)
{
const double maxWidth = 360d;
const double maxHeight = 240d;
return Math.Clamp(
Math.Min(
maxWidth / Math.Max(1, definition.MinWidthCells),
maxHeight / Math.Max(1, definition.MinHeightCells)),
32d,
96d);
}
private void SetSelectedPreviewControl(Control? control)
{
DisposeSelectedPreviewControl();
_selectedPreviewControl = control;
if (SelectedComponentPreviewHost is not null)
{
SelectedComponentPreviewHost.Content = control;
}
}
private void DisposeSelectedPreviewControl()
{
if (_selectedPreviewControl is null)
{
return;
}
ComponentPreviewRuntimeQuiescer.Detach(_selectedPreviewControl);
if (_selectedPreviewControl is IDisposable disposable)
{
disposable.Dispose();
}
_selectedPreviewControl = null;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
DisposeSelectedPreviewControl();
base.OnDetachedFromVisualTree(e);
}
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
@@ -255,15 +264,11 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
{
// 打开设置窗口并导航到插件目录页面
if (Application.Current is App app)
{
app.OpenIndependentSettingsModule("FusedDesktopComponentLibrary", "plugin-catalog");
}
// 关闭所在窗口
var window = this.FindAncestorOfType<Window>();
var componentLibraryWindow = this.FindAncestorOfType<Window>();
componentLibraryWindow?.Close();
this.FindAncestorOfType<Window>()?.Close();
}
}

View File

@@ -118,9 +118,4 @@ public partial class FusedDesktopComponentLibraryWindow : Window
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
mainWindow?.UnregisterFusedLibraryWindow(this);
}
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
{
LibraryControl.UpdatePreviewImage(entry);
}
}

View File

@@ -1,15 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -18,412 +11,117 @@ namespace LanMountainDesktop.Views;
public partial class MainWindow : Window
{
private const double PreviewRenderCellSizeMin = 42;
private const double PreviewRenderCellSizeMax = 112;
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
private bool _componentLibraryPreviewWarmupStarted;
private FusedDesktopComponentLibraryWindow? _fusedLibraryWindow;
private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback);
private void EnsureComponentLibraryPreviewWarmup()
{
if (_componentLibraryCategories.Count == 0)
{
return;
}
var activeCategoryId = _componentLibraryActiveCategoryId ??
_componentLibraryCategories[Math.Clamp(_componentLibraryCategoryIndex, 0, _componentLibraryCategories.Count - 1)].Id;
if (!_componentLibraryPreviewWarmupStarted)
{
_componentLibraryPreviewWarmupStarted = true;
_ = WarmComponentLibraryPreviewsSeriallyAsync(activeCategoryId);
return;
}
var activeCategory = _componentLibraryCategories.FirstOrDefault(category =>
string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase));
if (activeCategory is not null)
{
_ = WarmComponentLibraryCategoryPreviewsAsync(activeCategory);
}
}
private async Task WarmComponentLibraryPreviewsSeriallyAsync(string activeCategoryId)
{
var prioritized = _componentLibraryCategories
.OrderBy(category => string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ToList();
foreach (var category in prioritized)
{
await WarmComponentLibraryCategoryPreviewsAsync(category);
}
}
private async Task WarmComponentLibraryCategoryPreviewsAsync(ComponentLibraryCategory category)
{
foreach (var component in category.Components)
{
var span = NormalizeComponentCellSpan(
component.ComponentId,
(component.MinWidthCells, component.MinHeightCells));
await EnsureComponentTypePreviewImageAsync(component.ComponentId, span.WidthCells, span.HeightCells);
}
}
private async Task<IImage?> EnsureComponentTypePreviewImageAsync(string componentId, int widthCells, int heightCells)
private Control CreateStaticComponentLibraryPreview(
string componentId,
double cellSize,
double previewWidth,
double previewHeight)
{
if (string.IsNullOrWhiteSpace(componentId))
{
return null;
return CreateStaticComponentPreviewFallback(previewWidth, previewHeight);
}
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
var cached = ResolvePreviewImageFromService(key);
if (cached is not null)
{
ApplyPreviewEntryToEmbeddedVisuals(key);
return cached;
}
var context = new ComponentLibraryCreateContext(
cellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
_calculatorDataService,
_settingsFacade,
PlacementId: null,
RenderMode: DesktopComponentRenderMode.LibraryPreview);
var entry = await QueuePreviewGenerationAsync(
key,
pageIndex: null,
action: "ComponentTypePreview",
forceRefresh: false);
return entry.Bitmap;
}
private async Task<IImage?> RefreshPlacementPreviewImageAsync(DesktopComponentPlacementSnapshot? placement, bool forceRefresh)
{
if (placement is null ||
string.IsNullOrWhiteSpace(placement.ComponentId) ||
string.IsNullOrWhiteSpace(placement.PlacementId))
if (!_componentLibraryService.TryCreateControl(componentId, context, out var control, out var exception) ||
control is null)
{
return null;
}
if (!IsPlacementPresent(placement.PlacementId))
{
return null;
}
var snapshot = ClonePlacementSnapshot(placement);
var key = CreatePlacementPreviewKey(
snapshot.ComponentId,
snapshot.PlacementId,
snapshot.WidthCells,
snapshot.HeightCells);
if (!forceRefresh)
{
var cached = ResolvePreviewImageFromService(key);
if (cached is not null)
if (exception is not null)
{
return cached;
AppLogger.Warn(
"ComponentLibrary",
$"Failed to create static preview for component '{componentId}'.",
exception);
}
}
else
{
_componentPreviewImageService.RemovePlacementPreviews(snapshot.PlacementId);
return CreateStaticComponentPreviewFallback(previewWidth, previewHeight);
}
var entry = await QueuePreviewGenerationAsync(
key,
snapshot.PageIndex,
action: "PlacementPreview",
forceRefresh: false);
if (!IsPlacementPresent(snapshot.PlacementId))
{
RemovePlacementPreviewImage(snapshot.PlacementId);
return null;
}
return entry.Bitmap;
control.Width = previewWidth;
control.Height = previewHeight;
ComponentPreviewRuntimeQuiescer.Attach(control);
return control;
}
private async Task<ComponentPreviewImageEntry> QueuePreviewGenerationAsync(
ComponentPreviewKey key,
int? pageIndex,
string action,
bool forceRefresh,
CancellationToken cancellationToken = default)
private Control CreateStaticComponentPreviewFallback(double previewWidth, double previewHeight)
{
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
var visualSignature = BuildPreviewVisualSignature(key, renderCellSize);
if (forceRefresh)
{
_componentPreviewImageService.Invalidate(key, visualSignature);
}
var entry = await _componentPreviewImageService.QueueGenerationAsync(
key,
visualSignature,
async ct =>
{
_ = ct;
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
!IsPlacementPresent(key.PlacementId))
{
return null;
}
var bitmap = await CapturePreviewImageAsync(
key.ComponentTypeId,
key.PlacementId,
pageIndex,
key.WidthCells,
key.HeightCells,
renderCellSize,
action);
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
!IsPlacementPresent(key.PlacementId))
{
DisposeImageIfNeeded(bitmap);
return null;
}
return bitmap;
},
cancellationToken);
NotifyPreviewEntryUpdated(entry);
return entry;
}
private async Task<IImage?> CapturePreviewImageAsync(
string componentId,
string? placementId,
int? pageIndex,
int widthCells,
int heightCells,
double renderCellSize,
string action)
{
if (ComponentPreviewStagingHost is null)
{
return null;
}
var safeWidthCells = Math.Max(1, widthCells);
var safeHeightCells = Math.Max(1, heightCells);
var safeCellSize = Math.Clamp(renderCellSize, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
var previewWidth = safeWidthCells * safeCellSize;
var previewHeight = safeHeightCells * safeCellSize;
var previewControl = CreateDesktopComponentControl(
componentId,
safeCellSize,
placementId,
pageIndex,
action);
if (previewControl is null)
{
return null;
}
previewControl.IsHitTestVisible = false;
previewControl.Focusable = false;
var stage = new Border
return new Border
{
Width = previewWidth,
Height = previewHeight,
Background = Brushes.Transparent,
ClipToBounds = true,
Child = previewControl
Background = GetThemeBrush("AdaptiveCardBackgroundBrush"),
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
BorderThickness = new Avalonia.Thickness(1),
CornerRadius = new Avalonia.CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)),
IsHitTestVisible = false,
Child = new TextBlock
{
Text = L("component_library.preview_unavailable", "Preview unavailable"),
FontSize = 11,
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
}
};
}
Canvas.SetLeft(stage, -20000);
Canvas.SetTop(stage, -20000);
ComponentPreviewStagingHost.Children.Add(stage);
try
private static void DisposeStaticComponentLibraryPreviews(IEnumerable<Control> roots)
{
foreach (var control in roots.SelectMany(EnumerateControls))
{
stage.Measure(new Size(previewWidth, previewHeight));
stage.Arrange(new Rect(0, 0, previewWidth, previewHeight));
stage.UpdateLayout();
await WaitForPreviewRenderPassAsync();
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
var pixelSize = new PixelSize(
Math.Max(1, (int)Math.Ceiling(previewWidth * renderScale)),
Math.Max(1, (int)Math.Ceiling(previewHeight * renderScale)));
var bitmap = new RenderTargetBitmap(pixelSize, new Vector(96 * renderScale, 96 * renderScale));
bitmap.Render(stage);
return bitmap;
}
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
{
AppLogger.Warn(
"ComponentPreview",
$"Action={action}; ComponentId={componentId}; PlacementId={placementId ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false",
ex);
return null;
}
finally
{
ComponentPreviewStagingHost.Children.Remove(stage);
ClearTimeZoneServiceBindings(stage);
if (previewControl is IDisposable disposableControl)
ComponentPreviewRuntimeQuiescer.Detach(control);
if (control is IDisposable disposable)
{
disposableControl.Dispose();
disposable.Dispose();
}
}
}
private static async Task WaitForPreviewRenderPassAsync()
private static IEnumerable<Control> EnumerateControls(Control root)
{
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background);
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render);
}
yield return root;
private double ResolvePreviewRenderCellSize(int widthCells, int heightCells)
{
var baseCellSize = _currentDesktopCellSize > 0
? _currentDesktopCellSize * 1.10
: 74;
var densityBoost = Math.Max(widthCells, heightCells) >= 4 ? 8 : 0;
return Math.Clamp(baseCellSize + densityBoost, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
}
private string BuildPreviewVisualSignature(ComponentPreviewKey key, double renderCellSize)
{
var appearance = _appearanceThemeService.GetCurrent();
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
return string.Create(
CultureInfo.InvariantCulture,
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.CornerRadiusStyle}|Accent={FormatSignatureColor(appearance.AccentColor)}");
}
private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)
{
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
return ComponentPreviewKey.ForComponentType(componentId, span.WidthCells, span.HeightCells);
}
private ComponentPreviewKey CreatePlacementPreviewKey(string componentId, string placementId, int widthCells, int heightCells)
{
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
return ComponentPreviewKey.ForPlacementInstance(componentId, placementId, span.WidthCells, span.HeightCells);
}
private bool IsPlacementPresent(string? placementId)
{
return !string.IsNullOrWhiteSpace(placementId) &&
_desktopComponentPlacements.Any(candidate =>
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
}
private string BuildCurrentVisualSignature(ComponentPreviewKey key)
{
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
return BuildPreviewVisualSignature(key, renderCellSize);
}
private bool TryGetReusablePreviewEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
{
if (!_componentPreviewImageService.TryGetEntry(key, out entry) ||
entry is null ||
entry.State != ComponentPreviewImageState.Ready ||
entry.Bitmap is null)
if (root is Panel panel)
{
entry = null;
return false;
}
var expectedSignature = BuildCurrentVisualSignature(key);
if (!string.Equals(entry.VisualSignature, expectedSignature, StringComparison.Ordinal))
{
entry = null;
return false;
}
return true;
}
private IImage? ResolvePreviewImageFromService(ComponentPreviewKey key)
{
if (!TryGetReusablePreviewEntry(key, out var entry) || entry is null)
{
return null;
}
return entry.Bitmap;
}
private ComponentPreviewImageEntry? ResolvePreviewEntry(ComponentPreviewKey key)
{
if (!_componentPreviewImageService.TryGetEntry(key, out var entry) || entry is null)
{
return null;
}
if (entry.State != ComponentPreviewImageState.Ready)
{
return entry;
}
return TryGetReusablePreviewEntry(key, out var reusable) ? reusable : null;
}
private IImage? ResolveComponentTypePreviewImage(string componentId, int widthCells, int heightCells)
{
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
return ResolvePreviewImageFromService(key);
}
private IImage? ResolveDesktopEditPreviewImage(string componentId, string? placementId, int widthCells, int heightCells)
{
if (!string.IsNullOrWhiteSpace(placementId))
{
var placementKey = CreatePlacementPreviewKey(componentId, placementId, widthCells, heightCells);
var placementImage = ResolvePreviewImageFromService(placementKey);
if (placementImage is not null)
foreach (var child in panel.Children.OfType<Control>())
{
return placementImage;
foreach (var descendant in EnumerateControls(child))
{
yield return descendant;
}
}
}
var componentTypeKey = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
return ResolvePreviewImageFromService(componentTypeKey);
}
private (int WidthCells, int HeightCells) ResolveOverlayPreviewSpan(
string componentId,
string? placementId,
int? widthCells,
int? heightCells)
{
if (widthCells is > 0 && heightCells is > 0)
if (root is ContentControl { Content: Control content })
{
return NormalizeComponentCellSpan(componentId, (widthCells.Value, heightCells.Value));
foreach (var descendant in EnumerateControls(content))
{
yield return descendant;
}
}
if (!string.IsNullOrWhiteSpace(placementId) &&
TryGetDesktopPlacementById(placementId, out var placement))
if (root is Decorator { Child: Control decoratorChild })
{
return NormalizeComponentCellSpan(componentId, (placement.WidthCells, placement.HeightCells));
foreach (var descendant in EnumerateControls(decoratorChild))
{
yield return descendant;
}
}
if (!string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) &&
string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase) &&
_desktopEditSession.WidthCells > 0 &&
_desktopEditSession.HeightCells > 0)
{
return NormalizeComponentCellSpan(componentId, (_desktopEditSession.WidthCells, _desktopEditSession.HeightCells));
}
if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
{
return NormalizeComponentCellSpan(
componentId,
(descriptor.Definition.MinWidthCells, descriptor.Definition.MinHeightCells));
}
return (1, 1);
}
private void ApplyDesktopEditOverlayPreviewImage(
@@ -432,9 +130,12 @@ public partial class MainWindow : Window
int? widthCells = null,
int? heightCells = null)
{
var span = ResolveOverlayPreviewSpan(componentId, placementId, widthCells, heightCells);
_ = componentId;
_ = placementId;
_ = widthCells;
_ = heightCells;
EnsureDesktopEditOverlayPresenter();
_desktopEditOverlayPresenter?.SetPreviewImage(ResolveDesktopEditPreviewImage(componentId, placementId, span.WidthCells, span.HeightCells));
_desktopEditOverlayPresenter?.SetPreviewImage(null);
}
private void PrimeDesktopEditPreviewImage(
@@ -444,164 +145,28 @@ public partial class MainWindow : Window
int widthCells,
int heightCells)
{
_ = componentId;
_ = placementId;
_ = pageIndex;
var normalized = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
_ = EnsureComponentTypePreviewImageAsync(componentId, normalized.WidthCells, normalized.HeightCells);
if (!string.IsNullOrWhiteSpace(placementId) &&
TryGetDesktopPlacementById(placementId, out var placement))
{
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: false);
}
_ = widthCells;
_ = heightCells;
}
private void QueuePlacementPreviewRefresh(DesktopComponentPlacementSnapshot? placement)
{
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: true);
_ = placement;
}
private void RemovePlacementPreviewImage(string? placementId)
{
if (string.IsNullOrWhiteSpace(placementId))
{
return;
}
_componentPreviewImageService.RemovePlacementPreviews(placementId);
_ = placementId;
}
private void RemovePlacementPreviewImages(IEnumerable<DesktopComponentPlacementSnapshot> placements)
{
foreach (var placementId in placements
.Select(placement => placement.PlacementId)
.Where(static id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
RemovePlacementPreviewImage(placementId);
}
_ = placements;
}
private void RegisterComponentLibraryPreviewVisual(ComponentPreviewKey key, Image image, Control fallback)
{
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var visuals))
{
visuals = [];
_componentLibraryPreviewVisualTargets[key] = visuals;
}
visuals.Add(new ComponentLibraryPreviewVisualTarget(image, fallback));
}
private void ClearComponentLibraryPreviewVisualTargets()
{
_componentLibraryPreviewVisualTargets.Clear();
}
private void ApplyPreviewEntryToEmbeddedVisuals(ComponentPreviewKey key)
{
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var targets))
{
return;
}
var previewImage = ResolvePreviewImageFromService(key);
foreach (var target in targets)
{
target.Image.Source = previewImage;
target.Image.IsVisible = previewImage is not null;
target.Fallback.IsVisible = previewImage is null;
}
}
private void NotifyPreviewEntryUpdated(ComponentPreviewImageEntry entry)
{
Dispatcher.UIThread.Post(
() =>
{
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
_fusedLibraryWindow?.UpdatePreviewImage(entry);
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
{
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, entry.Key.PlacementId);
}
else
{
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, placementId: null);
}
},
DispatcherPriority.Background);
}
private static void DisposeImageIfNeeded(IImage? image)
{
if (image is IDisposable disposable)
{
disposable.Dispose();
}
}
private static string FormatSignatureColor(Color color)
{
return string.Create(
CultureInfo.InvariantCulture,
$"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}");
}
private void RefreshDesktopEditOverlayPreviewIfActive(string componentId, string? placementId)
{
if (_desktopEditOverlayPresenter is null ||
(!_desktopEditSession.IsActive && !_isDesktopEditCommitPending) ||
string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) ||
!string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!string.IsNullOrWhiteSpace(placementId) &&
!string.Equals(_desktopEditSession.PlacementId, placementId, StringComparison.OrdinalIgnoreCase))
{
return;
}
ApplyDesktopEditOverlayPreviewImage(
_desktopEditSession.ComponentId,
_desktopEditSession.PlacementId,
_desktopEditSession.WidthCells,
_desktopEditSession.HeightCells);
}
private ComponentPreviewKey ResolveDetachedLibraryPreviewKey(ComponentLibraryComponentEntry entry)
{
return CreateComponentTypePreviewKey(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
}
private ComponentPreviewImageEntry? ResolveDetachedLibraryPreviewEntry(ComponentPreviewKey key)
{
return ResolvePreviewEntry(key);
}
private void RequestDetachedLibraryPreviewWarm(ComponentPreviewKey key)
{
_ = QueuePreviewGenerationAsync(
key,
pageIndex: null,
action: "DetachedLibraryWarm",
forceRefresh: false);
}
private void RequestDetachedLibraryPreviewRender(ComponentPreviewKey key)
{
_ = QueuePreviewGenerationAsync(
key,
pageIndex: null,
action: "DetachedLibraryRender",
forceRefresh: false);
}
// FusedDesktop 支持
public void RegisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
{
_fusedLibraryWindow = window;
@@ -614,15 +179,4 @@ public partial class MainWindow : Window
_fusedLibraryWindow = null;
}
}
public ComponentPreviewImageEntry? GetPreviewEntry(ComponentPreviewKey key)
{
return ResolvePreviewEntry(key);
}
public void RequestDetachedLibraryPreview(ComponentPreviewKey key)
{
RequestDetachedLibraryPreviewWarm(key);
RequestDetachedLibraryPreviewRender(key);
}
}

View File

@@ -1480,13 +1480,11 @@ public partial class MainWindow : Window
_weatherDataService,
_recommendationInfoService,
_calculatorDataService,
_settingsFacade);
_settingsFacade,
PlacementId: null,
RenderMode: DesktopComponentRenderMode.LibraryPreview);
},
L,
previewKeyResolver: ResolveDetachedLibraryPreviewKey,
previewEntryResolver: ResolveDetachedLibraryPreviewEntry,
warmPreviewRequested: RequestDetachedLibraryPreviewWarm,
renderPreviewRequested: RequestDetachedLibraryPreviewRender);
L);
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
window.Closed += OnDetachedComponentLibraryClosed;
return window;
@@ -3620,7 +3618,6 @@ public partial class MainWindow : Window
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
_componentLibraryActiveCategoryId = category.Id;
_componentLibraryComponentIndex = 0;
_ = WarmComponentLibraryCategoryPreviewsAsync(category);
BuildComponentLibraryComponentPages(category);
ShowComponentLibraryComponentsView();
}
@@ -3638,10 +3635,10 @@ public partial class MainWindow : Window
var componentCount = _componentLibraryActiveComponents.Count;
ClearTimeZoneServiceBindings(ComponentLibraryComponentPagesContainer.Children.OfType<Control>().ToList());
DisposeStaticComponentLibraryPreviews(ComponentLibraryComponentPagesContainer.Children.OfType<Control>());
ComponentLibraryComponentPagesContainer.Children.Clear();
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
ClearComponentLibraryPreviewVisualTargets();
if (componentCount == 0)
{
_componentLibraryComponentIndex = 0;
@@ -3715,51 +3712,22 @@ public partial class MainWindow : Window
var previewWidth = previewSpan.WidthCells * previewCellSize;
var previewHeight = previewSpan.HeightCells * previewCellSize;
var previewKey = CreateComponentTypePreviewKey(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
var cachedPreviewImage = ResolveComponentTypePreviewImage(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
var previewControl = CreateStaticComponentLibraryPreview(
component.ComponentId,
previewCellSize,
previewWidth,
previewHeight);
var previewImage = new Image
var previewSurface = new Border
{
Width = previewWidth,
Height = previewHeight,
Stretch = Stretch.Uniform,
Source = cachedPreviewImage,
IsVisible = cachedPreviewImage is not null,
Background = Brushes.Transparent,
ClipToBounds = false,
Child = previewControl,
IsHitTestVisible = false
};
var previewFallback = new Border
{
Width = previewWidth,
Height = previewHeight,
Background = GetThemeBrush("AdaptiveCardBackgroundBrush"),
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)),
IsVisible = cachedPreviewImage is null,
Child = new TextBlock
{
Text = L("component_library.preview_loading", "Preparing preview"),
FontSize = 11,
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
RegisterComponentLibraryPreviewVisual(previewKey, previewImage, previewFallback);
var previewSurface = new Grid
{
Width = previewWidth,
Height = previewHeight,
IsHitTestVisible = false,
Children =
{
previewImage,
previewFallback
}
};
var previewBorder = new Border
{
Width = previewWidth,
@@ -3807,15 +3775,6 @@ public partial class MainWindow : Window
Grid.SetRow(page, 0);
Grid.SetColumn(page, i);
ComponentLibraryComponentPagesContainer.Children.Add(page);
if (cachedPreviewImage is null)
{
_ = EnsureComponentTypePreviewImageAsync(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
}
else
{
ApplyPreviewEntryToEmbeddedVisuals(previewKey);
}
}
_componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform;
@@ -3837,10 +3796,10 @@ public partial class MainWindow : Window
}
ClearTimeZoneServiceBindings(ComponentLibraryComponentPagesContainer.Children.OfType<Control>().ToList());
DisposeStaticComponentLibraryPreviews(ComponentLibraryComponentPagesContainer.Children.OfType<Control>());
ComponentLibraryComponentPagesContainer.Children.Clear();
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
ClearComponentLibraryPreviewVisualTargets();
}
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)

View File

@@ -123,7 +123,7 @@ public partial class MainWindow : Window
return;
}
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin, ComponentLibraryWindow.Opacity);
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin);
}
private void CollapseComponentLibraryForDesktopEdit(string? title)

View File

@@ -76,6 +76,7 @@ public partial class MainWindow : Window
string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UseGhProxyMirror), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
@@ -661,6 +662,7 @@ public partial class MainWindow : Window
UpdateMode = latestUpdateState.UpdateMode,
UpdateDownloadSource = latestUpdateState.UpdateDownloadSource,
UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads,
UseGhProxyMirror = latestUpdateState.UseGhProxyMirror,
PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath,
PendingUpdateVersion = latestUpdateState.PendingUpdateVersion,
PendingUpdatePublishedAtUtcMs = latestUpdateState.PendingUpdatePublishedAtUtcMs,

View File

@@ -225,14 +225,6 @@
<Canvas x:Name="DesktopEditDragLayer"
IsHitTestVisible="False" />
<Canvas x:Name="ComponentPreviewStagingHost"
Width="1"
Height="1"
Opacity="0"
ClipToBounds="True"
HorizontalAlignment="Left"
VerticalAlignment="Top"
IsHitTestVisible="False" />
</Grid>
</Border>
@@ -627,7 +619,7 @@
<Border x:Name="ComponentLibraryWindow"
IsVisible="False"
Opacity="0"
Classes="surface-translucent-strong"
Background="Transparent"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="620"
@@ -636,8 +628,6 @@
Height="320"
MinHeight="260"
Margin="24,24,24,100"
CornerRadius="36"
Padding="14"
PointerPressed="OnComponentLibraryWindowPointerPressed"
PointerMoved="OnComponentLibraryWindowPointerMoved"
PointerReleased="OnComponentLibraryWindowPointerReleased">
@@ -647,142 +637,146 @@
</Transitions>
</Border.Transitions>
<Grid RowDefinitions="Auto,*"
RowSpacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="ComponentLibraryTitleTextBlock"
VerticalAlignment="Center"
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Widgets" />
<Button x:Name="CloseComponentLibraryButton"
Grid.Column="1"
Padding="8"
Width="32"
Height="32"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnCloseComponentLibraryClick">
<fi:SymbolIcon Classes="icon-s"
Symbol="Dismiss"
IconVariant="Regular" />
</Button>
</Grid>
<Border Grid.Row="1"
Classes="surface-translucent-panel"
CornerRadius="12"
Padding="14">
<Grid>
<!-- Category picker (outer) -->
<Grid x:Name="ComponentLibraryCategoriesView">
<Grid RowDefinitions="*">
<Border x:Name="ComponentLibraryCategoryViewport"
Background="Transparent"
ClipToBounds="True">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid x:Name="ComponentLibraryCategoryPagesHost"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
</Grid>
</ScrollViewer>
</Border>
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No components." />
</Grid>
</Grid>
<!-- Component picker (inner) -->
<Grid x:Name="ComponentLibraryComponentsView"
IsVisible="False"
RowDefinitions="Auto,*"
RowSpacing="10">
<Button x:Name="ComponentLibraryBackButton"
Grid.Row="0"
HorizontalAlignment="Left"
Padding="8,6"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnComponentLibraryBackClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Classes="icon-s" Symbol="ArrowLeft" IconVariant="Regular" />
<TextBlock x:Name="ComponentLibraryBackTextBlock"
VerticalAlignment="Center"
Text="Back" />
</StackPanel>
</Button>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<Button x:Name="ComponentLibraryPrevComponentButton"
Grid.Column="0"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryPrevComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronLeft"
IconVariant="Regular" />
</Button>
<Border x:Name="ComponentLibraryComponentViewport"
Grid.Column="1"
Background="Transparent"
ClipToBounds="True"
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
<Grid>
<Grid x:Name="ComponentLibraryComponentPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
</Grid>
</Grid>
</Border>
<Button x:Name="ComponentLibraryNextComponentButton"
Grid.Column="2"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryNextComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronRight"
IconVariant="Regular" />
</Button>
</Grid>
</Grid>
<Border Classes="surface-translucent-strong"
CornerRadius="36"
Padding="14">
<Grid RowDefinitions="Auto,*"
RowSpacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="ComponentLibraryTitleTextBlock"
VerticalAlignment="Center"
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Widgets" />
<Button x:Name="CloseComponentLibraryButton"
Grid.Column="1"
Padding="8"
Width="32"
Height="32"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnCloseComponentLibraryClick">
<fi:SymbolIcon Classes="icon-s"
Symbol="Dismiss"
IconVariant="Regular" />
</Button>
</Grid>
</Border>
</Grid>
<Border Grid.Row="1"
Classes="surface-translucent-panel"
CornerRadius="12"
Padding="14">
<Grid>
<!-- Category picker (outer) -->
<Grid x:Name="ComponentLibraryCategoriesView">
<Grid RowDefinitions="*">
<Border x:Name="ComponentLibraryCategoryViewport"
Background="Transparent"
ClipToBounds="True">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid x:Name="ComponentLibraryCategoryPagesHost"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
</Grid>
</ScrollViewer>
</Border>
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No components." />
</Grid>
</Grid>
<!-- Component picker (inner) -->
<Grid x:Name="ComponentLibraryComponentsView"
IsVisible="False"
RowDefinitions="Auto,*"
RowSpacing="10">
<Button x:Name="ComponentLibraryBackButton"
Grid.Row="0"
HorizontalAlignment="Left"
Padding="8,6"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnComponentLibraryBackClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Classes="icon-s" Symbol="ArrowLeft" IconVariant="Regular" />
<TextBlock x:Name="ComponentLibraryBackTextBlock"
VerticalAlignment="Center"
Text="Back" />
</StackPanel>
</Button>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<Button x:Name="ComponentLibraryPrevComponentButton"
Grid.Column="0"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryPrevComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronLeft"
IconVariant="Regular" />
</Button>
<Border x:Name="ComponentLibraryComponentViewport"
Grid.Column="1"
Background="Transparent"
ClipToBounds="True"
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
<Grid>
<Grid x:Name="ComponentLibraryComponentPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
</Grid>
</Grid>
</Border>
<Button x:Name="ComponentLibraryNextComponentButton"
Grid.Column="2"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryNextComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronRight"
IconVariant="Regular" />
</Button>
</Grid>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</Border>
<Border x:Name="ComponentLibraryCollapsedChipHost"

View File

@@ -33,6 +33,13 @@
<Setter Property="MaxWidth" Value="200" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="TextBlock.update-phase-text">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</UserControl.Styles>
<ScrollViewer VerticalScrollBarVisibility="Auto">
@@ -65,7 +72,7 @@
</Grid>
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto"
RowDefinitions="Auto,Auto,Auto,Auto"
ColumnSpacing="20"
RowSpacing="16">
<StackPanel Grid.Row="0"
@@ -116,9 +123,19 @@
<TextBlock Classes="update-kv-value"
Text="{Binding PendingUpdateTypeText}" />
</StackPanel>
<StackPanel Grid.Row="2"
Grid.Column="1"
Spacing="4"
IsVisible="{Binding IsUpdateTypeVisible}">
<TextBlock Classes="update-kv-label"
Text="{Binding UpdateTypeLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding UpdateTypeText}" />
</StackPanel>
</Grid>
<StackPanel Spacing="12"
<StackPanel Spacing="8"
HorizontalAlignment="Left">
<TextBlock Classes="settings-item-description"
Text="{Binding UpdateStatus}"
@@ -126,12 +143,19 @@
HorizontalAlignment="Left"
MaxWidth="500" />
<TextBlock Classes="update-phase-text"
IsVisible="{Binding IsDownloadProgressVisible}"
Text="{Binding UpdatePhaseText}"
TextWrapping="Wrap"
HorizontalAlignment="Left" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding DownloadProgressValue}"
Value="{Binding PhaseProgressValue}"
IsVisible="{Binding IsDownloadProgressVisible}"
HorizontalAlignment="Stretch"
Margin="0,4,0,4" />
Margin="0,4,0,4"
ShowProgressText="True" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsDownloadProgressVisible}"
@@ -144,15 +168,27 @@
<StackPanel Orientation="Horizontal"
Spacing="10">
<Button Command="{Binding DownloadLatestReleaseCommand}"
Content="{Binding DownloadButtonText}"
IsVisible="{Binding IsDownloadButtonVisible}" />
IsVisible="{Binding IsDownloadButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowDownload" FontSize="14" />
<TextBlock Text="{Binding DownloadButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Command="{Binding RedownloadUpdateCommand}"
Content="{Binding RedownloadButtonText}"
IsVisible="{Binding IsRedownloadButtonVisible}" />
IsVisible="{Binding IsRedownloadButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowUndo" FontSize="14" />
<TextBlock Text="{Binding RedownloadButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Classes="settings-accent-button"
Command="{Binding InstallPendingUpdateCommand}"
Content="{Binding InstallNowButtonText}"
IsVisible="{Binding IsInstallButtonVisible}" />
IsVisible="{Binding IsInstallButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="Play" FontSize="14" />
<TextBlock Text="{Binding InstallNowButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
@@ -188,25 +224,14 @@
<ui:FAFontIconSource Glyph="&#xF0FD4;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpanderItem.IconSource>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding UpdateSourceLabel}"
Description="{Binding SelectedUpdateSourceDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF182C;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="220"
ItemsSource="{Binding UpdateSourceOptions}"
SelectedItem="{Binding SelectedUpdateSourceOption}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpanderItem Content="{Binding ForceFullUpdateLabel}"
Description="{Binding ForceFullUpdateDescription}"
IsClickEnabled="True"
Command="{Binding ForceFullUpdateCommand}">
<ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0E9F;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpanderItem.IconSource>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
@@ -228,6 +253,17 @@
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding NetworkAccelerationLabel}"
Description="{Binding NetworkAccelerationDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF01BB;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding UseGhProxyMirror}" />
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding DownloadThreadsLabel}"
Description="{Binding DownloadThreadsDescription}">

View File

@@ -0,0 +1,87 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
x:Class="LanMountainDesktop.Views.UpdateProgressDialog"
x:DataType="vm:UpdateProgressViewModel"
Title="阑山桌面 - Installing Update"
Width="480"
Height="320"
CanResize="False"
WindowStartupLocation="CenterScreen"
WindowDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None">
<Grid>
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
<TextBlock Text="阑山桌面"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="4"
Padding="6,2"
VerticalAlignment="Center">
<TextBlock Text="Update"
FontSize="11"
FontWeight="SemiBold"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
</Border>
</StackPanel>
</Grid>
<Grid VerticalAlignment="Center" Margin="24,0,24,0">
<StackPanel Spacing="12" HorizontalAlignment="Stretch">
<TextBlock Text="{Binding StageText}"
FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"
HorizontalAlignment="Center" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding ProgressPercent}"
Height="4"
IsIndeterminate="False"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}"
HorizontalAlignment="Stretch" />
<TextBlock Text="{Binding CurrentFile}"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Bottom">
<TextBlock FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
VerticalAlignment="Bottom">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} / {1} files">
<Binding Path="FilesCompleted" />
<Binding Path="FilesTotal" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<Button Grid.Column="1"
Classes="settings-accent-button"
Content="Cancel"
Command="{Binding CancelCommand}"
IsVisible="{Binding !IsCompleted}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,26 @@
using Avalonia;
using Avalonia.Controls;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views;
public partial class UpdateProgressDialog : Window
{
public UpdateProgressDialog()
{
InitializeComponent();
}
public UpdateProgressDialog(UpdateProgressViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
viewModel.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(UpdateProgressViewModel.IsCompleted) && viewModel.IsCompleted)
{
Close(viewModel.IsSuccess);
}
};
}
}

View File

@@ -36,8 +36,12 @@ AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DisableDirPage=no
UsePreviousAppDir=no
ShowLanguageDialog=yes
UsePreviousLanguage=no
; 语言对话框行为:
; - 全新安装:显示语言选择对话框
; - 升级安装:自动沿用之前选择的语言,不弹出对话框
; - 用户可以在欢迎页面点击语言按钮手动切换
ShowLanguageDialog=auto
UsePreviousLanguage=yes
LanguageDetectionMethod=uilanguage
DefaultGroupName={cm:AppShortcutName}
UninstallDisplayIcon={app}\{#MyAppExeName}
@@ -112,6 +116,10 @@ english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automati
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
chinesesimplified.DotNetRuntimeOpenFailedAction=请手动打开以下链接:
english.LanguageButtonCaption=Language
chinesesimplified.LanguageButtonCaption=语言
english.LanguageButtonHint=Click to change the installation language
chinesesimplified.LanguageButtonHint=点击更改安装语言
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
@@ -158,6 +166,7 @@ var
ExistingInstallWas64Bit: Boolean;
ExistingInstallIsPerUser: Boolean;
ExistingInstallRemoved: Boolean;
LanguageButton: TNewButton;
function NormalizePathValue(const Value: String): String;
begin
@@ -341,6 +350,59 @@ begin
TryLoadExistingInstallation(HKCU32, False, True);
end;
{ 语言切换按钮点击处理 }
{ 注意Inno Setup 不支持运行时切换语言,所以我们显示一个简单对话框,
让用户选择语言,然后重启安装程序以应用新语言 }
procedure LanguageButtonClick(Sender: TObject);
var
NewLanguage: String;
Params: String;
ResultCode: Integer;
begin
{ 根据当前语言显示对应的提示 }
if ActiveLanguage = 'chinesesimplified' then
begin
{ 当前是中文,询问是否切换到英文 }
if MsgBox('当前语言:简体中文' + #13#10#13#10 + '是否切换到 English' + #13#10 + '(安装程序将重新启动以应用新语言)', mbConfirmation, MB_YESNO) = IDYES then
begin
NewLanguage := 'english';
end
else
begin
exit;
end;
end
else
begin
{ 当前是英文,询问是否切换到中文 }
if MsgBox('Current language: English' + #13#10#13#10 + 'Switch to 简体中文?' + #13#10 + '(The setup will restart to apply the new language)', mbConfirmation, MB_YESNO) = IDYES then
begin
NewLanguage := 'chinesesimplified';
end
else
begin
exit;
end;
end;
{ 构建重启参数,带上新语言设置 }
Params := '/LANG="' + NewLanguage + '"';
if WizardSilent then
begin
Params := Params + ' /SILENT';
end;
if WizardVerySilent then
begin
Params := Params + ' /VERYSILENT';
end;
{ 重启安装程序并退出当前实例 }
if Exec(ExpandConstant('{srcexe}'), Params, '', SW_SHOWNORMAL, ewNoWait, ResultCode) then
begin
WizardForm.Close;
end;
end;
function SelectedUpgradeChoice(): Integer;
begin
if UpgradeModePage <> nil then
@@ -589,6 +651,16 @@ var
begin
DetectExistingInstallation;
{ 在欢迎页面添加语言切换按钮 }
LanguageButton := TNewButton.Create(WizardForm);
LanguageButton.Parent := WizardForm.WelcomePage;
LanguageButton.Caption := CustomMessage('LanguageButtonCaption');
LanguageButton.Hint := CustomMessage('LanguageButtonHint');
LanguageButton.ShowHint := True;
LanguageButton.Left := WizardForm.WelcomePage.ClientWidth - LanguageButton.Width - 20;
LanguageButton.Top := 12;
LanguageButton.OnClick := @LanguageButtonClick;
if not ExistingInstallFound then
begin
exit;

View File

@@ -112,6 +112,9 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
? entry.PackageSources
: entry.Publication?.PackageSources ?? [];
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
var existingManifest = entry.Manifest;
var existingCompatibility = entry.Compatibility;
var existingPublication = entry.Publication;
return new AirAppMarketPluginEntry
{
@@ -142,9 +145,13 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
{
MinHostVersion = FirstNonEmpty(
template?.MinHostVersion,
existingCompatibility?.MinHostVersion,
entry.MinHostVersion),
PluginApiVersion = FirstNonEmpty(
resolvedManifest?.ApiVersion,
existingCompatibility?.PluginApiVersion,
existingCompatibility?.ApiVersion,
existingManifest?.ApiVersion,
entry.ApiVersion)
?? string.Empty
}
@@ -162,19 +169,24 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
},
Publication = entry.Publication,
Capabilities = entry.Capabilities,
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
Id = FirstNonEmpty(resolvedManifest?.Id, existingManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
Name = FirstNonEmpty(resolvedManifest?.Name, existingManifest?.Name, entry.Name) ?? string.Empty,
Description = FirstNonEmpty(resolvedManifest?.Description, existingManifest?.Description, entry.Description) ?? string.Empty,
Author = FirstNonEmpty(resolvedManifest?.Author, existingManifest?.Author, entry.Author) ?? string.Empty,
Version = FirstNonEmpty(resolvedManifest?.Version, existingManifest?.Version, entry.Version) ?? string.Empty,
ApiVersion = FirstNonEmpty(
resolvedManifest?.ApiVersion,
existingCompatibility?.PluginApiVersion,
existingCompatibility?.ApiVersion,
existingManifest?.ApiVersion,
entry.ApiVersion) ?? string.Empty,
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, existingCompatibility?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
Sha256 = entry.Sha256,
PackageSizeBytes = entry.PackageSizeBytes,
Sha256 = FirstNonEmpty(existingPublication?.Sha256, entry.Sha256) ?? string.Empty,
PackageSizeBytes = existingPublication?.PackageSizeBytes > 0 ? existingPublication.PackageSizeBytes : entry.PackageSizeBytes,
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
ReleaseTag = entry.ReleaseTag,
ReleaseAssetName = entry.ReleaseAssetName,
ReleaseTag = FirstNonEmpty(existingPublication?.ReleaseTag, entry.ReleaseTag) ?? string.Empty,
ReleaseAssetName = FirstNonEmpty(existingPublication?.ReleaseAssetName, entry.ReleaseAssetName) ?? string.Empty,
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
@@ -191,9 +203,9 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
.ToList()
?? entry.SharedContracts,
PackageSources = resolvedPackageSources,
Md5 = entry.Md5,
PublishedAt = entry.PublishedAt,
UpdatedAt = entry.UpdatedAt,
Md5 = FirstNonEmpty(existingPublication?.Md5, entry.Md5) ?? string.Empty,
PublishedAt = existingPublication?.PublishedAt ?? entry.PublishedAt,
UpdatedAt = existingPublication?.UpdatedAt ?? entry.UpdatedAt,
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
};
}

View File

@@ -14,8 +14,6 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketInstallService : IDisposable
{
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
private readonly PluginRuntimeService _runtime;
private readonly LauncherClient _launcherClient = new();
private readonly HttpClient _httpClient;
@@ -83,13 +81,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
{
if (OperatingSystem.IsWindows())
{
var launcherPath = ResolveLauncherPath();
if (!File.Exists(launcherPath))
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new AirAppMarketInstallResult(
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).");
}
}
@@ -364,11 +362,6 @@ internal sealed class AirAppMarketInstallService : IDisposable
return new AirAppMarketVerificationResult(true, null);
}
private static string ResolveLauncherPath()
{
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
}
private static void TryDeleteFile(string path)
{
try

View File

@@ -646,6 +646,8 @@ internal sealed class AirAppMarketPluginCompatibilityEntry
{
public string MinHostVersion { get; init; } = string.Empty;
public string ApiVersion { get; init; } = string.Empty;
public string PluginApiVersion { get; init; } = string.Empty;
public AirAppMarketPluginCompatibilityEntry ValidateAndNormalize(string sourceName)
@@ -656,9 +658,13 @@ internal sealed class AirAppMarketPluginCompatibilityEntry
MinHostVersion,
nameof(MinHostVersion),
sourceName),
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion,
nameof(ApiVersion),
sourceName),
PluginApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
PluginApiVersion,
nameof(PluginApiVersion),
AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion,
nameof(ApiVersion),
sourceName)
};
}
@@ -742,6 +748,8 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
public string Url { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public PluginPackageSourceKind SourceKind { get; init; } = PluginPackageSourceKind.ReleaseAsset;
public AirAppMarketPluginPackageSourceEntry ValidateAndNormalize(string sourceName, string pluginId)
@@ -755,9 +763,11 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
$"Market index '{sourceName}' declares invalid package source kind '{normalizedKind}' for plugin '{pluginId}'.");
}
var normalizedPath = AirAppMarketIndexDocument.NormalizeValue(Path);
var normalizedUrl = AirAppMarketIndexDocument.NormalizeValue(Url)
?? normalizedPath
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing package source url for plugin '{pluginId}'.");
$"Market index '{sourceName}' is missing package source url/path for plugin '{pluginId}'.");
EnsurePackageSourceUrl(normalizedUrl, sourceName, pluginId);
return new AirAppMarketPluginPackageSourceEntry
@@ -770,6 +780,7 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
_ => normalizedKind
},
Url = normalizedUrl,
Path = normalizedPath ?? string.Empty,
SourceKind = sourceKind
};
}
@@ -1240,6 +1251,7 @@ internal sealed class AirAppMarketPluginEntry
{
return compatibility is not null &&
(!string.IsNullOrWhiteSpace(compatibility.MinHostVersion) ||
!string.IsNullOrWhiteSpace(compatibility.ApiVersion) ||
!string.IsNullOrWhiteSpace(compatibility.PluginApiVersion));
}

View File

@@ -1,3 +1,4 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -49,19 +50,25 @@ public sealed class PlondsGenerator
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
var publishedAt = DateTimeOffset.UtcNow;
var generatedAt = DateTimeOffset.UtcNow;
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var arch = ResolveArch(options.Platform);
var fileMap = new FileMapDocument(
FormatVersion: "1.0",
FormatVersion: "2.0",
DistributionId: distributionId,
FromVersion: options.PreviousVersion,
ToVersion: options.CurrentVersion,
Version: options.CurrentVersion,
Platform: options.Platform,
Arch: arch,
Channel: options.Channel,
PublishedAt: publishedAt,
Capabilities: ["file-object"],
GeneratedAt: generatedAt,
BaselineVersion: baselineVersion,
Capabilities: ["file-object", "compressed-object"],
Components:
[
new ComponentDocument(
@@ -89,12 +96,13 @@ public sealed class PlondsGenerator
Version: options.CurrentVersion,
Channel: options.Channel,
Platform: options.Platform,
Arch: arch,
PublishedAt: publishedAt,
FileMapUrl: options.FileMapUrl,
FileMapSignatureUrl: options.FileMapSignatureUrl,
Components: fileMap.Components,
InstallerMirrors: installerMirrors,
Capabilities: ["file-object"],
Capabilities: ["file-object", "compressed-object"],
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
@@ -135,6 +143,12 @@ public sealed class PlondsGenerator
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
}
public static void WriteBundle(string fileMapPath, string signatureBase64)
{
var fileMapJson = File.ReadAllText(fileMapPath);
WriteBundle(fileMapPath, fileMapJson, signatureBase64);
}
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
{
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
@@ -181,12 +195,14 @@ public sealed class PlondsGenerator
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
Metadata: null));
ArchiveSha256: null,
Metadata: new Dictionary<string, string> { ["reuseVerified"] = "true" }));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectKey = CopyContentObject(current.FullPath, repoRoot, current.Sha256);
var (objectKey, archiveSha256, mode) = CopyContentObjectWithCompression(
current.FullPath, repoRoot, current.Sha256, current.Size);
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
? null
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
@@ -196,10 +212,11 @@ public sealed class PlondsGenerator
Action: action,
Sha256: current.Sha256,
Size: current.Size,
Mode: "file-object",
Mode: mode,
ObjectKey: objectKey,
ObjectUrl: objectUrl,
Metadata: new Dictionary<string, string> { ["mode"] = "file-object" }));
ArchiveSha256: string.IsNullOrEmpty(archiveSha256) ? null : archiveSha256,
Metadata: new Dictionary<string, string> { ["mode"] = mode }));
}
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
@@ -214,6 +231,7 @@ public sealed class PlondsGenerator
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
ArchiveSha256: null,
Metadata: null));
}
}
@@ -301,6 +319,56 @@ public sealed class PlondsGenerator
return relativeKey.Replace('\\', '/');
}
private static (string ObjectKey, string ArchiveSha256, string Mode) CopyContentObjectWithCompression(
string sourcePath, string repoRoot, string sha256, long fileSize)
{
if (fileSize > 65536)
{
var compressedBytes = CompressGzip(sourcePath);
var archiveSha256 = ComputeSha256FromBytes(compressedBytes);
var archiveKey = CopyBytesToObjectStore(compressedBytes, repoRoot, archiveSha256);
return (archiveKey, archiveSha256, "compressed-object");
}
var key = CopyContentObject(sourcePath, repoRoot, sha256);
return (key, string.Empty, "file-object");
}
private static byte[] CompressGzip(string filePath)
{
using var input = File.OpenRead(filePath);
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true))
{
input.CopyTo(gzip);
}
return output.ToArray();
}
private static string ComputeSha256FromBytes(byte[] data)
{
return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}
private static string CopyBytesToObjectStore(byte[] data, string repoRoot, string sha256)
{
var prefix = sha256[..Math.Min(2, sha256.Length)];
var relativeKey = $"{prefix}/{sha256}";
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.WriteAllBytes(destinationPath, data);
}
return relativeKey.Replace('\\', '/');
}
private static void WriteBundle(string fileMapPath, string fileMapJson, string signatureBase64)
{
var bundle = new BundleDocument(fileMapJson, signatureBase64);
WriteJson(fileMapPath + ".bundle.json", bundle);
}
private static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
@@ -320,9 +388,13 @@ public sealed class PlondsGenerator
string DistributionId,
string FromVersion,
string ToVersion,
string Version,
string Platform,
string Arch,
string Channel,
DateTimeOffset PublishedAt,
DateTimeOffset GeneratedAt,
string? BaselineVersion,
IReadOnlyList<string> Capabilities,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyDictionary<string, string>? Metadata);
@@ -332,6 +404,7 @@ public sealed class PlondsGenerator
string Version,
string Channel,
string Platform,
string Arch,
DateTimeOffset PublishedAt,
string? FileMapUrl,
string? FileMapSignatureUrl,
@@ -362,6 +435,7 @@ public sealed class PlondsGenerator
string Mode,
string? ObjectKey,
string? ObjectUrl,
string? ArchiveSha256,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record InstallerMirrorDocument(
@@ -372,4 +446,6 @@ public sealed class PlondsGenerator
string? FileName,
string? Sha256,
long Size);
private sealed record BundleDocument(string Manifest, string Signature);
}