Compare commits

...

4 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
59 changed files with 4335 additions and 352 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

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

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

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

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

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

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