mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
激进的更新
This commit is contained in:
111
.github/workflows/release.yml
vendored
111
.github/workflows/release.yml
vendored
@@ -140,6 +140,48 @@ jobs:
|
||||
Write-Host "Self-contained: $selfContained"
|
||||
shell: pwsh
|
||||
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$appDir = "app-$version"
|
||||
|
||||
Write-Host "重组目录结构为 Launcher 模式..."
|
||||
Write-Host "版本: $version"
|
||||
Write-Host "发布目录: $publishDir"
|
||||
|
||||
# 创建新的目录结构
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||
|
||||
# 移动主程序到 app-{version} 子目录
|
||||
$appPath = Join-Path $newStructure $appDir
|
||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||
|
||||
# Launcher 应该在根目录
|
||||
# 注意: Launcher 已经通过 CopyLauncherToPublish 目标复制到了 Launcher/ 子目录
|
||||
$launcherSource = Join-Path $appPath "Launcher"
|
||||
if (Test-Path $launcherSource) {
|
||||
Write-Host "移动 Launcher 到根目录..."
|
||||
Get-ChildItem -Path $launcherSource | Move-Item -Destination $newStructure -Force
|
||||
Remove-Item -Path $launcherSource -Recurse -Force
|
||||
} else {
|
||||
Write-Warning "Launcher 目录不存在: $launcherSource"
|
||||
}
|
||||
|
||||
# 创建 .current 标记
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||
|
||||
Write-Host "新目录结构:"
|
||||
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
|
||||
|
||||
# 替换原发布目录
|
||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup -y --no-progress
|
||||
shell: pwsh
|
||||
@@ -242,6 +284,75 @@ jobs:
|
||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||
shell: pwsh
|
||||
|
||||
- name: Generate Delta Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$publishDir = "publish/windows-${{ matrix.arch }}"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
|
||||
Write-Host "生成增量更新包..."
|
||||
Write-Host "当前版本: $version"
|
||||
|
||||
# TODO: 从上一个 Release 下载并解压以生成增量包
|
||||
# 这里先生成完整的 files.json
|
||||
|
||||
$outputDir = "delta-output"
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
# 生成 files.json (完整文件清单)
|
||||
$files = Get-ChildItem -Path $currentAppPath -Recurse -File
|
||||
$fileEntries = @()
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
|
||||
$relativePath = $relativePath.Replace('\', '/')
|
||||
|
||||
# 跳过标记文件
|
||||
if ($relativePath -match '^\.(current|partial|destroy)$') {
|
||||
continue
|
||||
}
|
||||
|
||||
$hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
$fileEntries += @{
|
||||
Path = $relativePath
|
||||
Action = "add"
|
||||
Sha256 = $hash
|
||||
Size = $file.Length
|
||||
ArchivePath = $relativePath
|
||||
}
|
||||
}
|
||||
|
||||
$filesJson = @{
|
||||
FromVersion = $null
|
||||
ToVersion = $version
|
||||
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Files = $fileEntries
|
||||
} | ConvertTo-Json -Depth 10
|
||||
|
||||
$filesJsonPath = Join-Path $outputDir "files-$version.json"
|
||||
$filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8
|
||||
|
||||
Write-Host "生成文件清单: $filesJsonPath"
|
||||
Write-Host "文件数量: $($fileEntries.Count)"
|
||||
|
||||
# 创建完整应用包 (app-{version}.zip)
|
||||
$appZipPath = Join-Path $outputDir "app-$version.zip"
|
||||
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal
|
||||
|
||||
Write-Host "创建应用包: $appZipPath"
|
||||
Write-Host "包大小: $([Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)) MB"
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Delta Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: delta-package-windows-${{ matrix.arch }}
|
||||
path: delta-output/*
|
||||
|
||||
- name: Upload Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
8
.trae/specs/launcher-upgrade/checklist.md
Normal file
8
.trae/specs/launcher-upgrade/checklist.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Launcher Upgrade Checklist
|
||||
|
||||
- [x] Build passes for `LanMountainDesktop.Launcher`.
|
||||
- [x] `update check` command returns structured JSON result.
|
||||
- [x] `plugin update` command returns structured JSON result.
|
||||
- [x] Legacy plugin install arguments still execute.
|
||||
- [x] OOBE and splash are implemented as separate windows.
|
||||
- [x] Update and rollback logic use version directory markers.
|
||||
54
.trae/specs/launcher-upgrade/spec.md
Normal file
54
.trae/specs/launcher-upgrade/spec.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Launcher Upgrade Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
|
||||
|
||||
- OOBE first-run entry
|
||||
- startup splash window
|
||||
- silent/incremental/rollback update
|
||||
- plugin install/update
|
||||
|
||||
## Scope (Phase 1)
|
||||
|
||||
- Avalonia GUI launcher with two windows:
|
||||
- `OOBEWindow` (first run only)
|
||||
- `SplashWindow` (every launch)
|
||||
- Default command `launch`
|
||||
- CLI commands:
|
||||
- `update check|download|apply|rollback`
|
||||
- `plugin install|update`
|
||||
- Legacy compatibility:
|
||||
- `--source --plugins-dir --result` still works for plugin install
|
||||
|
||||
## Update Behavior
|
||||
|
||||
- ClassIsland-style deployment folders:
|
||||
- `app-<version>-<number>/`
|
||||
- marker files `.current`, `.partial`, `.destroy`
|
||||
- Signed file map:
|
||||
- `files.json`
|
||||
- `files.json.sig`
|
||||
- `public-key.pem`
|
||||
- Incremental update:
|
||||
- `replace` from archive
|
||||
- `reuse` from current deployment
|
||||
- `delete` skip file in target deployment
|
||||
- Rollback:
|
||||
- snapshot metadata is written before apply
|
||||
- automatic rollback on apply failure
|
||||
- manual rollback via command
|
||||
|
||||
## OOBE and Splash
|
||||
|
||||
- OOBE is independent from splash.
|
||||
- OOBE shows only:
|
||||
- welcome text: `欢迎使用阑山桌面`
|
||||
- arrow button for continue
|
||||
- Splash shows only:
|
||||
- app name: `阑山桌面`
|
||||
|
||||
## Extensibility
|
||||
|
||||
- `IOobeStep` for future multi-step OOBE
|
||||
- `ISplashStageReporter` for future startup progress visualization
|
||||
12
.trae/specs/launcher-upgrade/tasks.md
Normal file
12
.trae/specs/launcher-upgrade/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Launcher Upgrade Tasks
|
||||
|
||||
- [x] Convert `LanMountainDesktop.Launcher` to Avalonia launcher entry.
|
||||
- [x] Add OOBE window with first-run marker handling.
|
||||
- [x] Add splash window for every startup.
|
||||
- [x] Implement unified command parsing with default `launch`.
|
||||
- [x] Keep legacy plugin install args compatibility.
|
||||
- [x] Add plugin pending upgrade queue processing.
|
||||
- [x] Implement incremental update apply with signed file map.
|
||||
- [x] Implement snapshot-based rollback and manual rollback command.
|
||||
- [x] Add update check/download/apply/rollback CLI commands.
|
||||
- [x] Add launcher spec files under `.trae/specs/launcher-upgrade/`.
|
||||
8
LanMountainDesktop.Launcher/App.axaml
Normal file
8
LanMountainDesktop.Launcher/App.axaml
Normal file
@@ -0,0 +1,8 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Launcher.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
50
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
50
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
||||
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
_ = RunCoordinatorAsync(desktop, coordinator);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static async Task RunCoordinatorAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
LauncherFlowCoordinator coordinator)
|
||||
{
|
||||
var result = await coordinator.RunAsync().ConfigureAwait(false);
|
||||
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
79
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
79
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal sealed class CommandContext
|
||||
{
|
||||
public string Command { get; }
|
||||
|
||||
public string SubCommand { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Options { get; }
|
||||
|
||||
public bool IsLegacyPluginInstall =>
|
||||
Options.ContainsKey("source") &&
|
||||
Options.ContainsKey("plugins-dir") &&
|
||||
Options.ContainsKey("result");
|
||||
|
||||
private CommandContext(string command, string subCommand, Dictionary<string, string> options)
|
||||
{
|
||||
Command = command;
|
||||
SubCommand = subCommand;
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public static CommandContext FromArgs(string[] args)
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
|
||||
? args[0]
|
||||
: "launch";
|
||||
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
|
||||
? args[1]
|
||||
: string.Empty;
|
||||
|
||||
return new CommandContext(command, subCommand, options);
|
||||
}
|
||||
|
||||
public string? GetOption(string key)
|
||||
{
|
||||
return Options.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public int GetIntOption(string key, int fallback)
|
||||
{
|
||||
var raw = GetOption(key);
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = "true";
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class LauncherRuntimeContext
|
||||
{
|
||||
public static CommandContext Current { get; set; } = CommandContext.FromArgs([]);
|
||||
}
|
||||
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class LauncherResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public string Stage { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = "ok";
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("currentVersion")]
|
||||
public string? CurrentVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public string? TargetVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("rolledBackTo")]
|
||||
public string? RolledBackTo { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Dictionary<string, string> Details { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("installedPackagePath")]
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestName")]
|
||||
public string? ManifestName { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// GitHub Release 信息
|
||||
/// </summary>
|
||||
public sealed class ReleaseInfo
|
||||
{
|
||||
public required string TagName { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required bool Prerelease { get; init; }
|
||||
public required DateTime PublishedAt { get; init; }
|
||||
public required List<ReleaseAsset> Assets { get; init; }
|
||||
public string? Body { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release 资源文件
|
||||
/// </summary>
|
||||
public sealed class ReleaseAsset
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string BrowserDownloadUrl { get; init; }
|
||||
public required long Size { get; init; }
|
||||
}
|
||||
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新频道
|
||||
/// </summary>
|
||||
public enum UpdateChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 正式版 - 只检查 prerelease=false 的版本
|
||||
/// </summary>
|
||||
Stable,
|
||||
|
||||
/// <summary>
|
||||
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
||||
/// </summary>
|
||||
Preview
|
||||
}
|
||||
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查结果
|
||||
/// </summary>
|
||||
public sealed class UpdateCheckResult
|
||||
{
|
||||
public bool HasUpdate { get; init; }
|
||||
public string? LatestVersion { get; init; }
|
||||
public string? CurrentVersion { get; init; }
|
||||
public ReleaseInfo? Release { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
55
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
55
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class SignedFileMap
|
||||
{
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class UpdateFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string Action { get; set; } = "replace";
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SnapshotMetadata
|
||||
{
|
||||
public string SnapshotId { get; set; } = string.Empty;
|
||||
|
||||
public string SourceVersion { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetVersion { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public string SourceDirectory { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetDirectory { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
}
|
||||
|
||||
internal sealed class UpdateApplyResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public string? FromVersion { get; init; }
|
||||
|
||||
public string? ToVersion { get; init; }
|
||||
|
||||
public string? RolledBackTo { get; init; }
|
||||
}
|
||||
1
LanMountainDesktop.Launcher/NativeMethods.txt
Normal file
1
LanMountainDesktop.Launcher/NativeMethods.txt
Normal file
@@ -0,0 +1 @@
|
||||
MessageBox
|
||||
191
LanMountainDesktop.Launcher/Program.cs
Normal file
191
LanMountainDesktop.Launcher/Program.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
#if WINDOWS
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
#endif
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
|
||||
// 处理遗留插件安装命令
|
||||
if (commandContext.IsLegacyPluginInstall)
|
||||
{
|
||||
var installer = new PluginInstallerService();
|
||||
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 处理其他 CLI 命令 (update, plugin, rollback 等)
|
||||
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
|
||||
private static int LaunchMainApplication(string[] args)
|
||||
{
|
||||
// 获取可执行文件名
|
||||
string executableName = OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.exe"
|
||||
: "LanMountainDesktop";
|
||||
|
||||
// 获取安装根目录
|
||||
var rootDir = Path.GetFullPath(Path.GetDirectoryName(Environment.ProcessPath) ?? "");
|
||||
|
||||
// 查找最佳版本
|
||||
var installation = FindBestVersion(rootDir, executableName);
|
||||
|
||||
if (installation == null)
|
||||
{
|
||||
ShowError("找不到有效的 LanMountainDesktop 版本,可能是安装已损坏。\n请访问 https://github.com/ClassIsland/LanMountainDesktop 重新下载并安装。");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var exePath = Path.Combine(installation, executableName);
|
||||
|
||||
// Linux/macOS: 自动添加可执行权限
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
try
|
||||
{
|
||||
var chmod = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "chmod",
|
||||
Arguments = $"+x \"{exePath}\"",
|
||||
CreateNoWindow = true
|
||||
});
|
||||
chmod?.WaitForExit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"无法设置可执行权限: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 清理待删除的旧版本
|
||||
CleanupDestroyedVersions(rootDir);
|
||||
|
||||
// 启动主程序
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = exePath,
|
||||
WorkingDirectory = rootDir,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
// 传递包根目录环境变量
|
||||
startInfo.EnvironmentVariables["LanMountainDesktop_PackageRoot"] = rootDir;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(startInfo);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError($"启动主程序失败: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindBestVersion(string rootDir, string executableName)
|
||||
{
|
||||
return Directory.GetDirectories(rootDir)
|
||||
.Where(x =>
|
||||
{
|
||||
var dirName = Path.GetFileName(x);
|
||||
return dirName.StartsWith("app-") &&
|
||||
!File.Exists(Path.Combine(x, ".destroy")) &&
|
||||
!File.Exists(Path.Combine(x, ".partial")) &&
|
||||
File.Exists(Path.Combine(x, executableName));
|
||||
})
|
||||
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1) // .current 优先
|
||||
.ThenByDescending(x => ParseVersion(Path.GetFileName(x))) // 版本号降序
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string dirName)
|
||||
{
|
||||
// 从 "app-1.0.0" 格式解析版本号
|
||||
var parts = dirName.Split('-');
|
||||
if (parts.Length >= 2 && Version.TryParse(parts[1], out var version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static void CleanupDestroyedVersions(string rootDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var destroyedDirs = Directory.GetDirectories(rootDir)
|
||||
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
|
||||
|
||||
foreach (var dir in destroyedDirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败(可能文件被占用),下次启动再试
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略清理失败
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowError(string message)
|
||||
{
|
||||
#if WINDOWS
|
||||
try
|
||||
{
|
||||
PInvoke.MessageBox(
|
||||
HWND.Null,
|
||||
message,
|
||||
"LanMountainDesktop Launcher",
|
||||
MESSAGEBOX_STYLE.MB_ICONERROR | MESSAGEBOX_STYLE.MB_OK
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
#else
|
||||
Console.Error.WriteLine(message);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"Launcher (Launch Mode)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "launch",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Update Check)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "update check",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Plugin Install)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
174
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal static class Commands
|
||||
{
|
||||
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
LauncherResult result;
|
||||
try
|
||||
{
|
||||
var source = context.GetOption("source") ?? string.Empty;
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||
result = installer.InstallPackage(source, pluginsDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin.install",
|
||||
Code = "failed",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
||||
{
|
||||
var appRoot = ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
||||
var pluginInstaller = new PluginInstallerService();
|
||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||
|
||||
LauncherResult result;
|
||||
try
|
||||
{
|
||||
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "command",
|
||||
Code = "exception",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> ExecuteCoreAsync(
|
||||
CommandContext context,
|
||||
UpdateEngineService updateEngine,
|
||||
PluginInstallerService pluginInstaller,
|
||||
PluginUpgradeQueueService pluginUpgrades)
|
||||
{
|
||||
switch (context.Command.ToLowerInvariant())
|
||||
{
|
||||
case "update":
|
||||
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
|
||||
case "plugin":
|
||||
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
||||
default:
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "command",
|
||||
Code = "unsupported_command",
|
||||
Message = $"Unsupported command '{context.Command}'."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||
{
|
||||
return context.SubCommand.ToLowerInvariant() switch
|
||||
{
|
||||
"check" => updateEngine.CheckPendingUpdate(),
|
||||
"apply" => updateEngine.ApplyPendingUpdate(),
|
||||
"rollback" => updateEngine.RollbackLatest(),
|
||||
"download" => await updateEngine.DownloadAsync(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
||||
CancellationToken.None).ConfigureAwait(false),
|
||||
_ => new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update",
|
||||
Code = "unsupported_subcommand",
|
||||
Message = $"Unsupported update sub-command '{context.SubCommand}'."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult ExecutePluginCommand(
|
||||
CommandContext context,
|
||||
PluginInstallerService pluginInstaller,
|
||||
PluginUpgradeQueueService pluginUpgrades)
|
||||
{
|
||||
switch (context.SubCommand.ToLowerInvariant())
|
||||
{
|
||||
case "install":
|
||||
{
|
||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
}
|
||||
default:
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin",
|
||||
Code = "unsupported_subcommand",
|
||||
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(resultPath);
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string ResolveAppRoot(CommandContext context)
|
||||
{
|
||||
var configured = context.GetOption("app-root");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
|
||||
var parentHost = OperatingSystem.IsWindows()
|
||||
? Path.Combine(parent, "LanMountainDesktop.exe")
|
||||
: Path.Combine(parent, "LanMountainDesktop");
|
||||
return File.Exists(parentHost) ? parent : baseDir;
|
||||
}
|
||||
}
|
||||
160
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
160
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DeploymentLocator
|
||||
{
|
||||
private readonly string _appRoot;
|
||||
|
||||
public DeploymentLocator(string appRoot)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
}
|
||||
|
||||
public string GetAppRoot() => _appRoot;
|
||||
|
||||
public string? FindCurrentDeploymentDirectory()
|
||||
{
|
||||
var candidates = Directory.Exists(_appRoot)
|
||||
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
: [];
|
||||
|
||||
// 过滤掉无效的部署目录
|
||||
var validCandidates = candidates
|
||||
.Where(path =>
|
||||
!File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除
|
||||
!File.Exists(Path.Combine(path, ".partial"))) // 排除未完成
|
||||
.ToList();
|
||||
|
||||
// 优先选择带 .current 标记的版本
|
||||
var withMarkers = validCandidates
|
||||
.Where(path => File.Exists(Path.Combine(path, ".current")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
if (withMarkers.Count > 0)
|
||||
{
|
||||
return withMarkers[0].Path;
|
||||
}
|
||||
|
||||
// 如果没有 .current 标记,选择最新版本
|
||||
var byVersion = validCandidates
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
return byVersion.Count > 0 ? byVersion[0].Path : null;
|
||||
}
|
||||
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
var inDeployment = Path.Combine(currentDeployment, executable);
|
||||
if (File.Exists(inDeployment))
|
||||
{
|
||||
return inDeployment;
|
||||
}
|
||||
}
|
||||
|
||||
var inRoot = Path.Combine(_appRoot, executable);
|
||||
if (File.Exists(inRoot))
|
||||
{
|
||||
return inRoot;
|
||||
}
|
||||
|
||||
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
var inParent = Path.Combine(parent, executable);
|
||||
return File.Exists(inParent) ? inParent : null;
|
||||
}
|
||||
|
||||
public string GetCurrentVersion()
|
||||
{
|
||||
var deployment = FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(deployment))
|
||||
{
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
|
||||
}
|
||||
|
||||
public string BuildNextDeploymentDirectory(string targetVersion)
|
||||
{
|
||||
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
|
||||
if (!Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Directory.Exists(_appRoot)
|
||||
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
: [];
|
||||
|
||||
var destroyedDirs = candidates
|
||||
.Where(path => File.Exists(Path.Combine(path, ".destroy")));
|
||||
|
||||
foreach (var dir in destroyedDirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败(可能文件被占用),下次启动再试
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略清理失败
|
||||
}
|
||||
}
|
||||
|
||||
public static Version ParseVersionFromDirectory(string path)
|
||||
{
|
||||
var text = ParseVersionTextFromDirectory(path);
|
||||
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static string? ParseVersionTextFromDirectory(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = fileName.Split('-');
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return segments[1];
|
||||
}
|
||||
}
|
||||
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface IOobeStep
|
||||
{
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface ISplashStageReporter
|
||||
{
|
||||
void Report(string stage, string message);
|
||||
}
|
||||
204
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
204
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
private readonly CommandContext _context;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly UpdateEngineService _updateEngine;
|
||||
private readonly UpdateCheckService _updateCheckService;
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly ISplashStageReporter _splashStageReporter;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
public LauncherFlowCoordinator(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
OobeStateService oobeStateService,
|
||||
UpdateEngineService updateEngine,
|
||||
UpdateCheckService updateCheckService,
|
||||
PluginInstallerService pluginInstallerService)
|
||||
{
|
||||
_context = context;
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_updateCheckService = updateCheckService;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_splashStageReporter = new NullSplashStageReporter();
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 清理待删除的旧版本
|
||||
_deploymentLocator.CleanupDestroyedDeployments();
|
||||
|
||||
_splashStageReporter.Report("bootstrap", "bootstrap");
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
foreach (var step in _oobeSteps)
|
||||
{
|
||||
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
window.Show();
|
||||
return window;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
_splashStageReporter.Report("silentUpdate", "update");
|
||||
var updateResult = _updateEngine.ApplyPendingUpdate();
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
_splashStageReporter.Report("pluginTasks", "plugins");
|
||||
var pluginsDir = _context.GetOption("plugins-dir")
|
||||
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success)
|
||||
{
|
||||
return queueResult;
|
||||
}
|
||||
|
||||
_splashStageReporter.Report("launchHost", "launch");
|
||||
var hostResult = LaunchHost();
|
||||
if (!hostResult.Success)
|
||||
{
|
||||
return hostResult;
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "exit",
|
||||
Code = "ok",
|
||||
Message = "Launcher completed successfully."
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private LauncherResult LaunchHost()
|
||||
{
|
||||
var hostPath = _deploymentLocator.ResolveHostExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(hostPath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launchHost",
|
||||
Code = "host_not_found",
|
||||
Message = "LanMountainDesktop host executable not found."
|
||||
};
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
EnsureExecutable(hostPath);
|
||||
}
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
|
||||
};
|
||||
|
||||
Process.Start(processStartInfo);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launchHost",
|
||||
Code = "ok",
|
||||
Message = "Host launched."
|
||||
};
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
||||
File.SetUnixFileMode(path, mode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly OobeStateService _stateService;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService stateService)
|
||||
{
|
||||
_stateService = stateService;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var window = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var oobeWindow = new OobeWindow();
|
||||
oobeWindow.Show();
|
||||
return oobeWindow;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var _ = cancellationToken.Register(() => window.Close());
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
_stateService.MarkCompleted();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Close());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullSplashStageReporter : ISplashStageReporter
|
||||
{
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
_ = stage;
|
||||
_ = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
29
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class OobeStateService
|
||||
{
|
||||
private readonly string _markerPath;
|
||||
|
||||
public OobeStateService(string appRoot)
|
||||
{
|
||||
var stateDir = Path.Combine(appRoot, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal static class Program
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class PluginInstallerService
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
@@ -13,103 +13,38 @@ internal static class Program
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
private static async Task<int> Main(string[] args)
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
||||
{
|
||||
var result = new HelperResult();
|
||||
string? resultPath = null;
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
|
||||
try
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
var parsedArgs = ParseArgs(args);
|
||||
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
|
||||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
|
||||
!parsedArgs.TryGetValue("result", out resultPath) ||
|
||||
string.IsNullOrWhiteSpace(sourcePath) ||
|
||||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
|
||||
string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
|
||||
}
|
||||
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
resultPath = Path.GetFullPath(resultPath);
|
||||
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = true,
|
||||
InstalledPackagePath = destinationPath,
|
||||
ManifestId = manifest.Id,
|
||||
ManifestName = manifest.Name
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resultPath))
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
var resultDirectory = Path.GetDirectoryName(resultPath);
|
||||
if (!string.IsNullOrWhiteSpace(resultDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(resultDirectory);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
resultPath,
|
||||
JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}),
|
||||
Encoding.UTF8);
|
||||
}
|
||||
|
||||
return result.Success ? 0 : 1;
|
||||
Success = true,
|
||||
Stage = "plugin.install",
|
||||
Code = "ok",
|
||||
Message = "Plugin installed.",
|
||||
InstalledPackagePath = destinationPath,
|
||||
ManifestId = manifest.Id,
|
||||
ManifestName = manifest.Name
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = args[++i];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
@@ -132,7 +67,7 @@ internal static class Program
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
||||
@@ -161,14 +96,13 @@ internal static class Program
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or malformed packages while replacing an install target.
|
||||
}
|
||||
}
|
||||
|
||||
CleanupPendingDeletions(pendingDeletionDir);
|
||||
}
|
||||
|
||||
private static void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
|
||||
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -178,16 +112,7 @@ internal static class Program
|
||||
{
|
||||
var fileName = Path.GetFileName(existingPackagePath);
|
||||
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
|
||||
try
|
||||
{
|
||||
File.Move(existingPackagePath, pendingPath);
|
||||
}
|
||||
catch (IOException moveEx)
|
||||
{
|
||||
throw new IOException(
|
||||
$"Cannot delete or move existing plugin package '{existingPackagePath}'. " +
|
||||
$"The file may be in use by another process. Error: {moveEx.Message}", moveEx);
|
||||
}
|
||||
File.Move(existingPackagePath, pendingPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +131,6 @@ internal static class Program
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures for pending deletions.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,7 +159,6 @@ internal static class Program
|
||||
private static void Retry(Action action)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
@@ -274,17 +197,4 @@ internal static class Program
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private sealed class HelperResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
public string? ManifestName { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class PluginUpgradeQueueService
|
||||
{
|
||||
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
|
||||
|
||||
private readonly PluginInstallerService _installerService;
|
||||
|
||||
public PluginUpgradeQueueService(PluginInstallerService installerService)
|
||||
{
|
||||
_installerService = installerService;
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||
{
|
||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||
if (!File.Exists(pendingPath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "plugin.update",
|
||||
Code = "noop",
|
||||
Message = "No pending plugin upgrades."
|
||||
};
|
||||
}
|
||||
|
||||
var text = File.ReadAllText(pendingPath);
|
||||
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
|
||||
var failures = new List<string>();
|
||||
var succeeded = new List<PendingUpgrade>();
|
||||
|
||||
foreach (var item in pending)
|
||||
{
|
||||
if (!item.IsValid())
|
||||
{
|
||||
failures.Add(item.PluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
||||
succeeded.Add(item);
|
||||
}
|
||||
catch
|
||||
{
|
||||
failures.Add(item.PluginId);
|
||||
}
|
||||
}
|
||||
|
||||
var remaining = pending
|
||||
.Except(succeeded)
|
||||
.Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
File.Delete(pendingPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = failures.Count == 0,
|
||||
Stage = "plugin.update",
|
||||
Code = failures.Count == 0 ? "ok" : "partial_failed",
|
||||
Message = failures.Count == 0
|
||||
? $"Applied {succeeded.Count} pending plugin upgrade(s)."
|
||||
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record PendingUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||
File.Exists(SourcePackagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
168
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查服务 - 基于 GitHub Release API
|
||||
/// </summary>
|
||||
internal sealed class UpdateCheckService
|
||||
{
|
||||
private const string GitHubApiBase = "https://api.github.com";
|
||||
private readonly string _repoOwner;
|
||||
private readonly string _repoName;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public UpdateCheckService(string repoOwner, string repoName)
|
||||
{
|
||||
_repoOwner = repoOwner;
|
||||
_repoName = repoName;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查更新
|
||||
/// </summary>
|
||||
public async Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||
string currentVersion,
|
||||
UpdateChannel channel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var releases = await FetchReleasesAsync(cancellationToken);
|
||||
|
||||
// 根据频道过滤版本
|
||||
var filteredReleases = channel == UpdateChannel.Stable
|
||||
? releases.Where(r => !r.Prerelease).ToList()
|
||||
: releases;
|
||||
|
||||
// 找到最新版本
|
||||
var latestRelease = filteredReleases
|
||||
.OrderByDescending(r => ParseVersion(r.TagName))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latestRelease == null)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = "No releases found"
|
||||
};
|
||||
}
|
||||
|
||||
var latestVersion = ParseVersionString(latestRelease.TagName);
|
||||
var current = ParseVersion(currentVersion);
|
||||
var latest = ParseVersion(latestVersion);
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = latest > current,
|
||||
LatestVersion = latestVersion,
|
||||
CurrentVersion = currentVersion,
|
||||
Release = latestRelease
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有 Release
|
||||
/// </summary>
|
||||
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
|
||||
|
||||
return releases?.Select(r => new ReleaseInfo
|
||||
{
|
||||
TagName = r.TagName ?? "",
|
||||
Name = r.Name ?? "",
|
||||
Prerelease = r.Prerelease,
|
||||
PublishedAt = r.PublishedAt,
|
||||
Body = r.Body,
|
||||
Assets = r.Assets?.Select(a => new ReleaseAsset
|
||||
{
|
||||
Name = a.Name ?? "",
|
||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||
Size = a.Size
|
||||
}).ToList() ?? []
|
||||
}).ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
|
||||
/// </summary>
|
||||
private static string ParseVersionString(string tag)
|
||||
{
|
||||
return tag.TrimStart('v', 'V');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析版本号
|
||||
/// </summary>
|
||||
private static Version ParseVersion(string versionString)
|
||||
{
|
||||
var cleaned = ParseVersionString(versionString);
|
||||
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
// GitHub API 响应模型
|
||||
private sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("prerelease")]
|
||||
public bool Prerelease { get; set; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("assets")]
|
||||
public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("browser_download_url")]
|
||||
public string? BrowserDownloadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
}
|
||||
512
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
512
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
@@ -0,0 +1,512 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class UpdateEngineService
|
||||
{
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string SnapshotsDirectoryName = "snapshots";
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignatureFileName = "files.json.sig";
|
||||
private const string ArchiveFileName = "update.zip";
|
||||
private const string PublicKeyFileName = "public-key.pem";
|
||||
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly string _appRoot;
|
||||
private readonly string _launcherRoot;
|
||||
private readonly string _incomingRoot;
|
||||
private readonly string _snapshotsRoot;
|
||||
|
||||
public UpdateEngineService(DeploymentLocator deploymentLocator)
|
||||
{
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_appRoot = deploymentLocator.GetAppRoot();
|
||||
_launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName);
|
||||
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
||||
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
|
||||
}
|
||||
|
||||
public LauncherResult CheckPendingUpdate()
|
||||
{
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "noop",
|
||||
Message = "No pending update."
|
||||
};
|
||||
}
|
||||
|
||||
var fileMapText = File.ReadAllText(fileMapPath);
|
||||
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
|
||||
if (fileMap is null)
|
||||
{
|
||||
return Failed("update.check", "invalid_manifest", "files.json is invalid.");
|
||||
}
|
||||
|
||||
var verified = VerifySignature(fileMapPath, signaturePath);
|
||||
if (!verified.Success)
|
||||
{
|
||||
return Failed("update.check", "signature_failed", verified.Message);
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "available",
|
||||
Message = "Pending update is available.",
|
||||
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
|
||||
TargetVersion = fileMap.ToVersion
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
using var client = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(manifestUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(manifestPath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(signatureUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(signaturePath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(archiveUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(archivePath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.download",
|
||||
Code = "ok",
|
||||
Message = "Update downloaded."
|
||||
};
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpdate()
|
||||
{
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
Directory.CreateDirectory(_snapshotsRoot);
|
||||
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
|
||||
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "noop",
|
||||
Message = "No update payload found."
|
||||
};
|
||||
}
|
||||
|
||||
var verifyResult = VerifySignature(fileMapPath, signaturePath);
|
||||
if (!verifyResult.Success)
|
||||
{
|
||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||
}
|
||||
|
||||
var fileMapText = File.ReadAllText(fileMapPath);
|
||||
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
|
||||
if (fileMap is null || fileMap.Files.Count == 0)
|
||||
{
|
||||
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
|
||||
}
|
||||
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
return Failed("update.apply", "no_current_deployment", "Current deployment directory not found.");
|
||||
}
|
||||
|
||||
var currentVersion = _deploymentLocator.GetCurrentVersion();
|
||||
if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
|
||||
!string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Failed(
|
||||
"update.apply",
|
||||
"version_mismatch",
|
||||
$"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
|
||||
}
|
||||
|
||||
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
|
||||
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
|
||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = currentVersion,
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = currentDeployment,
|
||||
TargetDirectory = targetDeployment,
|
||||
Status = "pending"
|
||||
};
|
||||
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
|
||||
|
||||
var extractRoot = Path.Combine(_incomingRoot, "extracted");
|
||||
try
|
||||
{
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(partialMarker, string.Empty);
|
||||
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
|
||||
}
|
||||
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
if (!NeedsVerification(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||||
var actualHash = ComputeSha256Hex(fullPath);
|
||||
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||
}
|
||||
}
|
||||
|
||||
ActivateDeployment(currentDeployment, targetDeployment);
|
||||
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
CleanupDestroyedDeployments();
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "ok",
|
||||
Message = $"Updated to {targetVersion}.",
|
||||
CurrentVersion = currentVersion,
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "apply_failed",
|
||||
Message = "Failed to apply update. Rolled back to previous version.",
|
||||
ErrorMessage = ex.Message,
|
||||
CurrentVersion = currentVersion,
|
||||
RolledBackTo = currentVersion
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LauncherResult RollbackLatest()
|
||||
{
|
||||
if (!Directory.Exists(_snapshotsRoot))
|
||||
{
|
||||
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
|
||||
}
|
||||
|
||||
var snapshotPath = Directory
|
||||
.EnumerateFiles(_snapshotsRoot, "*.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(File.GetCreationTimeUtc)
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(snapshotPath))
|
||||
{
|
||||
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
|
||||
}
|
||||
|
||||
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(File.ReadAllText(snapshotPath));
|
||||
if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
|
||||
{
|
||||
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
|
||||
}
|
||||
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
return Failed("update.rollback", "no_current_deployment", "Current deployment not found.");
|
||||
}
|
||||
|
||||
ActivateDeployment(currentDeployment, snapshot.SourceDirectory);
|
||||
snapshot.Status = "manual_rollback";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.rollback",
|
||||
Code = "ok",
|
||||
Message = $"Rolled back to {snapshot.SourceVersion}.",
|
||||
RolledBackTo = snapshot.SourceVersion
|
||||
};
|
||||
}
|
||||
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (!File.Exists(Path.Combine(dir, ".destroy")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot)
|
||||
{
|
||||
var normalizedPath = NormalizeRelativePath(file.Path);
|
||||
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||
var targetDir = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
}
|
||||
|
||||
if (string.Equals(file.Action, "reuse", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(sourcePath, currentDeployment);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
|
||||
var extractedPath = Path.Combine(extractRoot, archiveRelative);
|
||||
EnsurePathWithinRoot(extractedPath, extractRoot);
|
||||
if (!File.Exists(extractedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
|
||||
}
|
||||
|
||||
File.Copy(extractedPath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
private void ActivateDeployment(string fromDeployment, string toDeployment)
|
||||
{
|
||||
var toCurrent = Path.Combine(toDeployment, ".current");
|
||||
var fromCurrent = Path.Combine(fromDeployment, ".current");
|
||||
var fromDestroy = Path.Combine(fromDeployment, ".destroy");
|
||||
var toPartial = Path.Combine(toDeployment, ".partial");
|
||||
|
||||
File.WriteAllText(toCurrent, string.Empty);
|
||||
if (File.Exists(fromCurrent))
|
||||
{
|
||||
File.Delete(fromCurrent);
|
||||
}
|
||||
|
||||
File.WriteAllText(fromDestroy, string.Empty);
|
||||
if (File.Exists(toPartial))
|
||||
{
|
||||
File.Delete(toPartial);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory))
|
||||
{
|
||||
Directory.Delete(snapshot.TargetDirectory, true);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy")))
|
||||
{
|
||||
File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy"));
|
||||
}
|
||||
|
||||
if (!File.Exists(Path.Combine(snapshot.SourceDirectory, ".current")))
|
||||
{
|
||||
File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupIncomingArtifacts()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
Path.Combine(_incomingRoot, SignedFileMapName),
|
||||
Path.Combine(_incomingRoot, SignatureFileName),
|
||||
Path.Combine(_incomingRoot, ArchiveFileName)
|
||||
})
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath)
|
||||
{
|
||||
if (!File.Exists(signaturePath))
|
||||
{
|
||||
return (false, "Missing files.json.sig.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
return (false, $"Missing public key: {publicKeyPath}");
|
||||
}
|
||||
|
||||
var jsonBytes = File.ReadAllBytes(fileMapPath);
|
||||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
||||
if (string.IsNullOrWhiteSpace(signatureBase64))
|
||||
{
|
||||
return (false, "Signature is empty.");
|
||||
}
|
||||
|
||||
byte[] signature;
|
||||
try
|
||||
{
|
||||
signature = Convert.FromBase64String(signatureBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return (false, "Signature is not valid base64.");
|
||||
}
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(publicKeyPath));
|
||||
var isValid = rsa.VerifyData(jsonBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return isValid ? (true, "ok") : (false, "Signature verification failed.");
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string path)
|
||||
{
|
||||
var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
|
||||
return normalized.TrimStart(Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
private static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
{
|
||||
var fullTarget = Path.GetFullPath(targetPath);
|
||||
var fullRoot = Path.GetFullPath(rootPath);
|
||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool NeedsVerification(UpdateFileEntry file)
|
||||
{
|
||||
return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(file.Sha256);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var hash = SHA256.HashData(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
|
||||
{
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
}
|
||||
|
||||
private static LauncherResult Failed(string stage, string code, string message)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = stage,
|
||||
Code = code,
|
||||
Message = message,
|
||||
ErrorMessage = message
|
||||
};
|
||||
}
|
||||
}
|
||||
22
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
22
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
@@ -0,0 +1,22 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||
Title="阑山桌面"
|
||||
Width="420"
|
||||
Height="260"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid Margin="24" RowDefinitions="*,Auto">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
FontSize="26"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center" />
|
||||
<Button Grid.Row="1"
|
||||
x:Name="EnterButton"
|
||||
HorizontalAlignment="Right"
|
||||
Width="64"
|
||||
Height="40"
|
||||
Content="→"
|
||||
FontSize="18" />
|
||||
</Grid>
|
||||
</Window>
|
||||
27
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
27
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class OobeWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
var enterButton = this.FindControl<Button>("EnterButton");
|
||||
if (enterButton is not null)
|
||||
{
|
||||
enterButton.Click += OnEnterClick;
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
private void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
17
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
17
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||
Title="阑山桌面"
|
||||
Width="420"
|
||||
Height="220"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None">
|
||||
<Grid Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="阑山桌面"
|
||||
FontSize="34"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center" />
|
||||
</Grid>
|
||||
</Window>
|
||||
12
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
12
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class SplashWindow : Window
|
||||
{
|
||||
public SplashWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -7,7 +7,7 @@
|
||||
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -79,17 +79,17 @@
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||
<Target Name="CopyLauncherToOutput" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
<LauncherFiles Include="..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="@(LauncherFiles)" DestinationFiles="@(LauncherFiles->'$(OutDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||
<Target Name="CopyLauncherToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
<LauncherPublishFiles Include="..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="@(LauncherPublishFiles)" DestinationFiles="@(LauncherPublishFiles->'$(PublishDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
21
LanMountainDesktop/Properties/launchSettings.json
Normal file
21
LanMountainDesktop/Properties/launchSettings.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"LanMountainDesktop (Direct)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"LanMountainDesktop (via Launcher)": {
|
||||
"commandName": "Executable",
|
||||
"executablePath": "$(SolutionDir)LanMountainDesktop.Launcher\\bin\\$(Configuration)\\net10.0\\LanMountainDesktop.Launcher.exe",
|
||||
"commandLineArgs": "launch",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed class PluginsInstallHelperClient
|
||||
internal sealed class LauncherClient
|
||||
{
|
||||
private const int UserCanceledUacErrorCode = 1223;
|
||||
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
|
||||
|
||||
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
|
||||
public async Task<LauncherInstallResult> InstallPackageAsync(
|
||||
string packagePath,
|
||||
string pluginsDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -25,19 +25,19 @@ internal sealed class PluginsInstallHelperClient
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
"Elevated helper install is only supported on Windows.");
|
||||
}
|
||||
|
||||
var helperPath = ResolveHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
var launcherPath = ResolveLauncherPath();
|
||||
if (!File.Exists(launcherPath))
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"Plugins install helper was not found at '{helperPath}'.");
|
||||
$"Launcher executable was not found at '{launcherPath}'.");
|
||||
}
|
||||
|
||||
var resultPath = Path.Combine(
|
||||
@@ -50,38 +50,38 @@ internal sealed class PluginsInstallHelperClient
|
||||
|
||||
try
|
||||
{
|
||||
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
|
||||
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
|
||||
if (process is null)
|
||||
{
|
||||
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
|
||||
return new LauncherInstallResult(false, null, "Failed to start launcher process.");
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
var result = await ReadResultAsync(resultPath, cancellationToken);
|
||||
if (result is not null)
|
||||
{
|
||||
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
|
||||
return new LauncherInstallResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
|
||||
}
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
"Plugins install helper exited without producing a result file.");
|
||||
"Launcher exited without producing a result file.");
|
||||
}
|
||||
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Plugins install helper exited with code {0}.",
|
||||
"Launcher exited with code {0}.",
|
||||
process.ExitCode));
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
|
||||
{
|
||||
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
|
||||
return new LauncherInstallResult(false, null, "Administrator permission request was canceled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -89,18 +89,18 @@ internal sealed class PluginsInstallHelperClient
|
||||
}
|
||||
}
|
||||
|
||||
private static Process? StartHelperProcess(
|
||||
string helperPath,
|
||||
private static Process? StartLauncherProcess(
|
||||
string launcherPath,
|
||||
string packagePath,
|
||||
string pluginsDirectory,
|
||||
string resultPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = helperPath,
|
||||
FileName = launcherPath,
|
||||
Verb = "runas",
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
|
||||
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))}")
|
||||
@@ -120,9 +120,9 @@ internal sealed class PluginsInstallHelperClient
|
||||
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static string ResolveHelperPath()
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
@@ -180,7 +180,7 @@ internal sealed class PluginsInstallHelperClient
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PluginsInstallHelperResult(
|
||||
internal sealed record LauncherInstallResult(
|
||||
bool Success,
|
||||
string? InstalledPackagePath,
|
||||
string? ErrorMessage);
|
||||
@@ -1,6 +1,6 @@
|
||||
#define MyAppName "LanMountainDesktop"
|
||||
#define MyAppPublisher "LanMountainDesktop Team"
|
||||
#define MyAppExeName "LanMountainDesktop.exe"
|
||||
#define MyAppExeName "LanMountainDesktop.Launcher.exe"
|
||||
#define MyAppId "{{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
|
||||
#define MyAppRegistryId "{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
|
||||
|
||||
@@ -654,6 +654,9 @@ begin
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
LauncherPath: String;
|
||||
AppDirPath: String;
|
||||
begin
|
||||
if CurStep = ssInstall then
|
||||
begin
|
||||
@@ -662,4 +665,27 @@ begin
|
||||
Abort;
|
||||
end;
|
||||
end;
|
||||
|
||||
if CurStep = ssPostInstall then
|
||||
begin
|
||||
// 验证 Launcher 是否存在
|
||||
LauncherPath := ExpandConstant('{app}\{#MyAppExeName}');
|
||||
if not FileExists(LauncherPath) then
|
||||
begin
|
||||
MsgBox('安装验证失败: Launcher 可执行文件不存在。' + #13#10 +
|
||||
'预期路径: ' + LauncherPath + #13#10 + #13#10 +
|
||||
'请联系开发者报告此问题。', mbError, MB_OK);
|
||||
Abort;
|
||||
end;
|
||||
|
||||
// 验证至少存在一个 app-* 目录
|
||||
AppDirPath := ExpandConstant('{app}\app-{#MyAppVersion}');
|
||||
if not DirExists(AppDirPath) then
|
||||
begin
|
||||
MsgBox('安装验证失败: 应用版本目录不存在。' + #13#10 +
|
||||
'预期路径: ' + AppDirPath + #13#10 + #13#10 +
|
||||
'请联系开发者报告此问题。', mbError, MB_OK);
|
||||
Abort;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
@@ -14,10 +14,10 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
||||
|
||||
internal sealed class AirAppMarketInstallService : IDisposable
|
||||
{
|
||||
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
|
||||
|
||||
private readonly PluginRuntimeService _runtime;
|
||||
private readonly PluginsInstallHelperClient _helperClient = new();
|
||||
private readonly LauncherClient _launcherClient = new();
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ResumableDownloadService _downloadService;
|
||||
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
||||
@@ -83,13 +83,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var helperPath = ResolveHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
var launcherPath = ResolveLauncherPath();
|
||||
if (!File.Exists(launcherPath))
|
||||
{
|
||||
return new AirAppMarketInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"Plugins install helper was not found at '{helperPath}'.");
|
||||
$"Launcher executable was not found at '{launcherPath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,16 +234,16 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
PluginManifest manifest;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var helperResult = await _helperClient.InstallPackageAsync(
|
||||
var helperResult = await _launcherClient.InstallPackageAsync(
|
||||
attemptPath,
|
||||
_runtime.PluginsDirectory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
||||
{
|
||||
var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
|
||||
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
|
||||
$"Windows launcher install failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
|
||||
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
|
||||
}
|
||||
|
||||
@@ -363,9 +363,9 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
return new AirAppMarketVerificationResult(true, null);
|
||||
}
|
||||
|
||||
private static string ResolveHelperPath()
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
|
||||
12
README.md
12
README.md
@@ -58,6 +58,7 @@
|
||||
|
||||
### 构建与运行
|
||||
|
||||
**开发模式 (推荐):**
|
||||
```bash
|
||||
# 还原依赖
|
||||
dotnet restore
|
||||
@@ -65,10 +66,18 @@ dotnet restore
|
||||
# 构建项目
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
|
||||
# 运行桌面宿主
|
||||
# 直接运行主程序 (跳过 Launcher,快速开发)
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
**生产模式 (完整流程):**
|
||||
```bash
|
||||
# 通过 Launcher 启动 (包含 OOBE、Splash、版本管理)
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
详细说明请参考 [开发文档](docs/DEVELOPMENT.md)。
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
@@ -96,6 +105,7 @@ dotnet new lmd-plugin -n MyPlugin
|
||||
```
|
||||
LanMountainDesktop/
|
||||
├── LanMountainDesktop/ # 桌面宿主应用
|
||||
├── LanMountainDesktop.Launcher/ # 启动器 (OOBE、Splash、版本管理、更新)
|
||||
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
|
||||
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
|
||||
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
| 路径 | 角色 |
|
||||
| --- | --- |
|
||||
| `LanMountainDesktop/` | 主桌面宿主应用,包含 UI、服务、组件系统、插件运行时接入 |
|
||||
| **`LanMountainDesktop.Launcher/`** | **启动器 - 负责 OOBE、Splash、版本管理、增量更新、插件安装** |
|
||||
| `LanMountainDesktop.PluginSdk/` | 官方插件 SDK,定义插件可依赖的公开接口与打包行为 |
|
||||
| `LanMountainDesktop.Shared.Contracts/` | 宿主与插件共享的稳定契约类型 |
|
||||
| `LanMountainDesktop.Appearance/` | 主题、圆角、外观资源相关基础设施 |
|
||||
@@ -14,12 +15,24 @@
|
||||
| `LanMountainDesktop.DesktopHost/` | 桌面宿主流程与生命周期相关逻辑 |
|
||||
| `LanMountainDesktop.DesktopComponents.Runtime/` | 组件运行时支撑能力 |
|
||||
| `LanMountainDesktop.Host.Abstractions/` | 宿主侧抽象接口 |
|
||||
| `LanMountainDesktop.PluginsInstallHelper/` | 插件安装辅助程序与发布输出配套 |
|
||||
| `LanMountainDesktop.PluginTemplate/` | `dotnet new lmd-plugin` 官方模板 |
|
||||
| `LanMountainDesktop.Tests/` | 宿主与 SDK 的测试项目 |
|
||||
|
||||
### 宿主启动主线
|
||||
|
||||
**生产环境启动流程 (通过 Launcher):**
|
||||
|
||||
1. 用户启动 `LanMountainDesktop.Launcher.exe`
|
||||
2. Launcher 扫描 `app-*` 目录,选择最佳版本 (优先 `.current` 标记,然后按版本号降序)
|
||||
3. 首次启动显示 OOBE 引导 (`OobeWindow`)
|
||||
4. 显示 Splash 启动动画 (`SplashWindow`)
|
||||
5. 检查并应用待处理的更新 (`UpdateEngineService.ApplyPendingUpdate`)
|
||||
6. 处理插件升级队列 (`PluginUpgradeQueueService`)
|
||||
7. 启动主程序 `app-{version}/LanMountainDesktop.exe`
|
||||
8. 清理标记为 `.destroy` 的旧版本
|
||||
|
||||
**主程序启动流程 (LanMountainDesktop.exe):**
|
||||
|
||||
启动入口在 `LanMountainDesktop/Program.cs`:
|
||||
|
||||
1. 初始化日志、单实例锁和启动诊断
|
||||
@@ -60,17 +73,124 @@
|
||||
### 测试边界
|
||||
|
||||
`LanMountainDesktop.Tests/` 当前主要覆盖:
|
||||
|
||||
- 圆角与外观相关基线
|
||||
- 组件放置与编辑数学
|
||||
- 圆角与外观相关基础
|
||||
- 组件放置与编辑数据
|
||||
- 组件设置服务
|
||||
- UI 异常防护
|
||||
- 白板笔记持久化
|
||||
|
||||
涉及宿主行为、SDK 契约、布局计算或设置持久化的改动,应优先补对应测试。
|
||||
|
||||
### Launcher 架构详解
|
||||
|
||||
#### 职责范围
|
||||
|
||||
`LanMountainDesktop.Launcher/` 作为应用的唯一入口,负责:
|
||||
|
||||
1. **OOBE (首次体验)** - 首次启动引导和欢迎页面
|
||||
2. **Splash Screen** - 启动动画和加载进度显示
|
||||
3. **版本管理** - 多版本并存、版本选择、版本回退
|
||||
4. **应用更新** - 增量更新、静默更新、原子化更新
|
||||
5. **插件管理** - 插件安装、插件更新队列处理
|
||||
|
||||
#### 核心服务
|
||||
|
||||
| 服务 | 职责 |
|
||||
|------|------|
|
||||
| `DeploymentLocator` | 扫描和定位 `app-*` 版本目录,选择最佳版本 |
|
||||
| `UpdateCheckService` | 调用 GitHub Release API 检查更新,支持 Stable/Preview 频道 |
|
||||
| `UpdateEngineService` | 下载、验证、应用增量更新,支持原子化更新和回滚 |
|
||||
| `LauncherFlowCoordinator` | 协调 OOBE → Splash → 更新 → 插件 → 启动主程序的完整流程 |
|
||||
| `OobeStateService` | 管理首次运行状态 |
|
||||
| `PluginInstallerService` | 处理 `.laapp` 插件包安装 |
|
||||
| `PluginUpgradeQueueService` | 批量处理插件升级队列 |
|
||||
|
||||
#### 版本管理机制
|
||||
|
||||
**目录结构:**
|
||||
```
|
||||
安装根目录/
|
||||
├── LanMountainDesktop.Launcher.exe ← 唯一入口
|
||||
├── app-1.0.0/ ← 版本目录
|
||||
│ ├── .current ← 当前版本标记
|
||||
│ ├── LanMountainDesktop.exe
|
||||
│ └── ...
|
||||
├── app-1.0.1/ ← 新版本
|
||||
│ ├── .partial ← 下载中标记
|
||||
│ └── ...
|
||||
└── .launcher/ ← Launcher 数据
|
||||
├── state/ ← OOBE 状态
|
||||
├── update/incoming/ ← 更新缓存
|
||||
└── snapshots/ ← 更新快照
|
||||
```
|
||||
|
||||
**版本选择算法:**
|
||||
1. 扫描所有 `app-*` 目录
|
||||
2. 过滤掉带 `.destroy` 或 `.partial` 标记的目录
|
||||
3. 优先选择带 `.current` 标记的版本
|
||||
4. 如果没有 `.current`,选择版本号最高的
|
||||
|
||||
**版本标记文件:**
|
||||
- `.current` - 标记当前使用的版本
|
||||
- `.partial` - 标记下载未完成的版本 (更新失败时自动清理)
|
||||
- `.destroy` - 标记待删除的旧版本 (下次启动时清理)
|
||||
|
||||
#### 更新流程
|
||||
|
||||
**增量更新:**
|
||||
1. `UpdateCheckService` 调用 GitHub Release API
|
||||
2. 根据更新频道 (Stable/Preview) 过滤版本
|
||||
3. 下载 `delta-{old}-to-{new}.zip` 和 `files-{new}.json`
|
||||
4. 创建 `app-{new}/` 目录并标记 `.partial`
|
||||
5. 解压增量包,从旧版本复用未变更文件
|
||||
6. 验证所有文件 SHA256
|
||||
7. 删除 `.partial`,添加 `.current` 到新版本
|
||||
8. 标记旧版本 `.destroy`
|
||||
9. 保存更新快照到 `.launcher/snapshots/`
|
||||
|
||||
**原子化保证:**
|
||||
- 更新过程中保持 `.partial` 标记
|
||||
- 任何失败都会触发回滚
|
||||
- 旧版本保留直到新版本验证通过
|
||||
- 快照记录允许手动回退
|
||||
|
||||
**版本回退:**
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
```
|
||||
回退会:
|
||||
1. 读取最新的更新快照
|
||||
2. 移除当前版本的 `.current` 标记
|
||||
3. 添加 `.current` 到上一个版本
|
||||
4. 标记当前版本为 `.destroy`
|
||||
|
||||
#### CI/CD 集成
|
||||
|
||||
**发布产物结构:**
|
||||
```
|
||||
GitHub Release Assets:
|
||||
├── LanMountainDesktop-Setup-1.0.1-x64.exe (安装包)
|
||||
├── app-1.0.1.zip (完整应用包)
|
||||
├── delta-1.0.0-to-1.0.1.zip (增量包)
|
||||
├── files-1.0.1.json (文件清单)
|
||||
└── files-1.0.1.json.sig (RSA 签名)
|
||||
```
|
||||
|
||||
**增量包生成:**
|
||||
- `scripts/Generate-DeltaPackage.ps1` - 对比两个版本生成增量包
|
||||
- `scripts/Sign-FileMap.ps1` - 对 `files.json` 进行 RSA 签名
|
||||
- `.github/workflows/release.yml` - 自动生成并上传增量包
|
||||
|
||||
**安装器集成:**
|
||||
- Inno Setup 脚本修改为安装 Launcher 到根目录
|
||||
- 主程序安装到 `app-{version}/` 子目录
|
||||
- 快捷方式指向 `LanMountainDesktop.Launcher.exe`
|
||||
- 安装后验证 Launcher 和 app 目录存在
|
||||
|
||||
## English
|
||||
|
||||
This repository is organized around a desktop host app plus a host-side plugin ecosystem. `LanMountainDesktop/` contains the application entry points, UI, services, component system, and plugin runtime integration. The surrounding projects provide the public SDK, shared contracts, appearance infrastructure, settings primitives, host abstractions, runtime support, and tests.
|
||||
|
||||
The runtime flow starts in `Program.cs`, proceeds into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
|
||||
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
|
||||
|
||||
335
docs/BUILD_AND_DEPLOY.md
Normal file
335
docs/BUILD_AND_DEPLOY.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 构建和部署指南
|
||||
|
||||
> LanMountainDesktop 完整构建、打包和发布流程
|
||||
|
||||
## 目录
|
||||
|
||||
- [本地构建](#本地构建)
|
||||
- [发布构建](#发布构建)
|
||||
- [生成安装包](#生成安装包)
|
||||
- [CI/CD 流程](#cicd-流程)
|
||||
- [手动发布](#手动发布)
|
||||
|
||||
## 本地构建
|
||||
|
||||
### 环境要求
|
||||
|
||||
- .NET SDK 10.0 或更高版本
|
||||
- Windows 10/11 (推荐)
|
||||
- Inno Setup 6 (仅生成安装包时需要)
|
||||
|
||||
### 快速构建
|
||||
|
||||
```bash
|
||||
# 1. 还原依赖
|
||||
dotnet restore LanMountainDesktop.slnx
|
||||
|
||||
# 2. 构建 Debug 版本
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
|
||||
# 3. 运行主程序
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
### 构建 Release 版本
|
||||
|
||||
```bash
|
||||
dotnet build LanMountainDesktop.slnx -c Release
|
||||
```
|
||||
|
||||
## 发布构建
|
||||
|
||||
### Windows (x64, 自包含)
|
||||
|
||||
```bash
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||
-c Release `
|
||||
-o ./publish/windows-x64 `
|
||||
--self-contained `
|
||||
-r win-x64 `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false
|
||||
```
|
||||
|
||||
**发布后的目录结构:**
|
||||
```
|
||||
publish/windows-x64/
|
||||
├── LanMountainDesktop.Launcher.exe ← 入口
|
||||
├── app-{version}/ ← 主程序
|
||||
│ ├── .current
|
||||
│ ├── LanMountainDesktop.exe
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
### Linux (x64)
|
||||
|
||||
```bash
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||
-c Release `
|
||||
-o ./publish/linux-x64 `
|
||||
--self-contained `
|
||||
-r linux-x64
|
||||
```
|
||||
|
||||
### macOS (arm64)
|
||||
|
||||
```bash
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||
-c Release `
|
||||
-o ./publish/osx-arm64 `
|
||||
--self-contained `
|
||||
-r osx-arm64
|
||||
```
|
||||
|
||||
## 生成安装包
|
||||
|
||||
### Windows 安装包 (Inno Setup)
|
||||
|
||||
**前提条件:**
|
||||
```powershell
|
||||
# 安装 Inno Setup
|
||||
choco install innosetup -y
|
||||
```
|
||||
|
||||
**生成安装包:**
|
||||
```powershell
|
||||
# 1. 发布应用
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||
-c Release `
|
||||
-o ./publish/windows-x64 `
|
||||
--self-contained `
|
||||
-r win-x64
|
||||
|
||||
# 2. 运行 Inno Setup 编译器
|
||||
$version = "1.0.0"
|
||||
$arch = "x64"
|
||||
|
||||
iscc.exe `
|
||||
/DMyAppVersion=$version `
|
||||
/DMyAppArch=$arch `
|
||||
/DPublishDir="publish\windows-x64" `
|
||||
/DMyOutputDir="build-installer" `
|
||||
LanMountainDesktop\installer\LanMountainDesktop.iss
|
||||
```
|
||||
|
||||
**输出:**
|
||||
```
|
||||
build-installer/
|
||||
└── LanMountainDesktop-Setup-1.0.0-x64.exe
|
||||
```
|
||||
|
||||
### Linux 包 (.deb)
|
||||
|
||||
```bash
|
||||
# TODO: 添加 .deb 打包脚本
|
||||
```
|
||||
|
||||
### macOS 包 (.dmg)
|
||||
|
||||
```bash
|
||||
# TODO: 添加 .dmg 打包脚本
|
||||
```
|
||||
|
||||
## CI/CD 流程
|
||||
|
||||
### GitHub Actions 工作流
|
||||
|
||||
项目使用 GitHub Actions 自动化构建和发布。
|
||||
|
||||
**触发条件:**
|
||||
- 推送 `v*` 标签 (例如: `v1.0.0`)
|
||||
- 手动触发 (workflow_dispatch)
|
||||
|
||||
**工作流文件:** `.github/workflows/release.yml`
|
||||
|
||||
### 发布流程
|
||||
|
||||
```
|
||||
1. prepare job
|
||||
├─ 解析版本号
|
||||
└─ 设置构建变量
|
||||
|
||||
2. build-windows job
|
||||
├─ 构建 x64 和 x86 版本
|
||||
├─ 重组为 app-{version} 结构
|
||||
├─ 生成增量包
|
||||
├─ 生成 Inno Setup 安装包
|
||||
└─ 上传 artifacts
|
||||
|
||||
3. build-linux job
|
||||
├─ 构建 x64 版本
|
||||
├─ 生成 .deb 包
|
||||
└─ 上传 artifacts
|
||||
|
||||
4. build-macos job
|
||||
├─ 构建 arm64 和 x64 版本
|
||||
├─ 生成 .dmg 包
|
||||
└─ 上传 artifacts
|
||||
|
||||
5. release job
|
||||
├─ 下载所有 artifacts
|
||||
├─ 创建 GitHub Release
|
||||
└─ 上传所有安装包和增量包
|
||||
```
|
||||
|
||||
### 发布产物
|
||||
|
||||
**GitHub Release Assets:**
|
||||
```
|
||||
LanMountainDesktop-v1.0.0/
|
||||
├── LanMountainDesktop-Setup-1.0.0-x64.exe # Windows 安装包
|
||||
├── LanMountainDesktop-Setup-1.0.0-x86.exe
|
||||
├── LanMountainDesktop-1.0.0-linux-x64.deb # Linux 包
|
||||
├── LanMountainDesktop-1.0.0-macos-arm64.dmg # macOS 包
|
||||
├── app-1.0.0.zip # 完整应用包
|
||||
├── delta-0.9.9-to-1.0.0.zip # 增量包
|
||||
├── files-1.0.0.json # 文件清单
|
||||
└── files-1.0.0.json.sig # RSA 签名
|
||||
```
|
||||
|
||||
## 手动发布
|
||||
|
||||
### 1. 准备发布
|
||||
|
||||
```bash
|
||||
# 1. 更新版本号
|
||||
# 编辑 Directory.Build.props 中的 <Version>
|
||||
|
||||
# 2. 更新 CHANGELOG.md
|
||||
# 记录本次发布的变更
|
||||
|
||||
# 3. 提交变更
|
||||
git add .
|
||||
git commit -m "chore: prepare release v1.0.0"
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. 创建 Release 标签
|
||||
|
||||
```bash
|
||||
# 创建标签
|
||||
git tag v1.0.0
|
||||
|
||||
# 推送标签 (触发 CI)
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
### 3. 等待 CI 完成
|
||||
|
||||
访问 GitHub Actions 页面,等待构建完成:
|
||||
```
|
||||
https://github.com/YourOrg/LanMountainDesktop/actions
|
||||
```
|
||||
|
||||
### 4. 验证 Release
|
||||
|
||||
1. 访问 Releases 页面
|
||||
2. 检查所有安装包是否上传成功
|
||||
3. 下载并测试安装包
|
||||
4. 验证增量更新功能
|
||||
|
||||
### 5. 发布公告
|
||||
|
||||
- 在 GitHub Release 中编辑发布说明
|
||||
- 发布到社区/论坛
|
||||
- 更新官网下载链接
|
||||
|
||||
## 增量包生成
|
||||
|
||||
### 手动生成增量包
|
||||
|
||||
```powershell
|
||||
# 1. 准备两个版本的发布目录
|
||||
dotnet publish ... -o ./publish/app-1.0.0
|
||||
dotnet publish ... -o ./publish/app-1.0.1
|
||||
|
||||
# 2. 生成增量包
|
||||
./scripts/Generate-DeltaPackage.ps1 `
|
||||
-PreviousVersion "1.0.0" `
|
||||
-CurrentVersion "1.0.1" `
|
||||
-PreviousDir "./publish/app-1.0.0" `
|
||||
-CurrentDir "./publish/app-1.0.1" `
|
||||
-OutputDir "./delta-output"
|
||||
|
||||
# 3. 签名文件清单
|
||||
./scripts/Sign-FileMap.ps1 `
|
||||
-FilesJsonPath "./delta-output/files-1.0.1.json" `
|
||||
-PrivateKeyPath "./private-key.pem"
|
||||
```
|
||||
|
||||
**输出:**
|
||||
```
|
||||
delta-output/
|
||||
├── delta-1.0.0-to-1.0.1.zip
|
||||
├── files-1.0.1.json
|
||||
└── files-1.0.1.json.sig
|
||||
```
|
||||
|
||||
### 生成 RSA 密钥对
|
||||
|
||||
```powershell
|
||||
# 生成私钥
|
||||
openssl genrsa -out private-key.pem 2048
|
||||
|
||||
# 提取公钥
|
||||
openssl rsa -in private-key.pem -pubout -out public-key.pem
|
||||
```
|
||||
|
||||
**重要:**
|
||||
- 私钥保存在安全位置 (GitHub Secrets)
|
||||
- 公钥打包到 Launcher 中 (`.launcher/update/public-key.pem`)
|
||||
|
||||
## 版本号规范
|
||||
|
||||
遵循 [Semantic Versioning 2.0.0](https://semver.org/):
|
||||
|
||||
```
|
||||
MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
|
||||
|
||||
例如:
|
||||
- 1.0.0 (正式版)
|
||||
- 1.0.1 (补丁版本)
|
||||
- 1.1.0 (新功能)
|
||||
- 2.0.0 (破坏性变更)
|
||||
- 1.0.0-beta.1 (预览版)
|
||||
- 1.0.0-rc.1 (候选版本)
|
||||
```
|
||||
|
||||
### 版本号更新规则
|
||||
|
||||
- **MAJOR**: 破坏性 API 变更
|
||||
- **MINOR**: 新功能,向后兼容
|
||||
- **PATCH**: Bug 修复,向后兼容
|
||||
- **PRERELEASE**: 预览版标识 (alpha, beta, rc)
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 构建失败
|
||||
|
||||
**问题**: `error NU1102: Unable to find package`
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
dotnet restore --force
|
||||
dotnet nuget locals all --clear
|
||||
```
|
||||
|
||||
### 发布失败
|
||||
|
||||
**问题**: Launcher 目录不存在
|
||||
|
||||
**解决**: 检查 `LanMountainDesktop.csproj` 中的 `CopyLauncherToPublish` 目标是否正确执行。
|
||||
|
||||
### 安装包生成失败
|
||||
|
||||
**问题**: Inno Setup 找不到文件
|
||||
|
||||
**解决**: 确保 `PublishDir` 路径正确,且包含 `app-{version}/` 目录结构。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [开发文档](DEVELOPMENT.md)
|
||||
- [Launcher 架构](LAUNCHER.md)
|
||||
- [更新系统](UPDATE_SYSTEM.md)
|
||||
- [故障排除](TROUBLESHOOTING.md)
|
||||
@@ -20,10 +20,32 @@ dotnet build LanMountainDesktop.slnx -c Debug
|
||||
|
||||
#### 运行桌面宿主
|
||||
|
||||
**开发模式 (直接运行主程序,跳过 Launcher):**
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
**生产模式 (通过 Launcher 启动):**
|
||||
```bash
|
||||
# 先构建 Launcher
|
||||
dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug
|
||||
|
||||
# 通过 Launcher 启动主程序
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
**Launcher 其他命令:**
|
||||
```bash
|
||||
# 检查更新
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check
|
||||
|
||||
# 安装插件
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- plugin install <path-to-plugin.laapp>
|
||||
|
||||
# 版本回退
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
|
||||
```
|
||||
|
||||
#### 运行测试
|
||||
|
||||
```bash
|
||||
@@ -33,13 +55,18 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
### 常见工作区域
|
||||
|
||||
- 宿主应用:`LanMountainDesktop/`
|
||||
- **Launcher (启动器):`LanMountainDesktop.Launcher/`**
|
||||
- Plugin SDK:`LanMountainDesktop.PluginSdk/`
|
||||
- 共享契约:`LanMountainDesktop.Shared.Contracts/`
|
||||
- 测试:`LanMountainDesktop.Tests/`
|
||||
- 插件打包脚本:`scripts/Pack-PluginPackages.ps1`
|
||||
- **增量更新脚本:`scripts/Generate-DeltaPackage.ps1`, `scripts/Sign-FileMap.ps1`**
|
||||
|
||||
### 调试建议
|
||||
|
||||
- **Launcher 启动问题优先看 `LanMountainDesktop.Launcher/Program.cs` 和 `Services/LauncherFlowCoordinator.cs`**
|
||||
- **版本管理问题优先看 `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs`**
|
||||
- **更新系统问题优先看 `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs` 和 `UpdateCheckService.cs`**
|
||||
- 启动问题优先看 `LanMountainDesktop/Program.cs` 和 `LanMountainDesktop/App.axaml.cs`
|
||||
- 设置窗口和设置页问题优先看 `LanMountainDesktop/Views/`、`ViewModels/` 与相关 `Services/`
|
||||
- 插件加载与安装问题优先看 `LanMountainDesktop/plugins/`
|
||||
@@ -74,8 +101,68 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
- 需求与实施拆解更新到 `.trae/specs/`
|
||||
- AI 协作入口和代码地图更新到 `AGENTS.md` 与 `docs/ai/`
|
||||
|
||||
### Launcher 架构说明
|
||||
|
||||
LanMountainDesktop 使用 Launcher 作为唯一入口,负责版本管理、更新和启动主程序。
|
||||
|
||||
#### 目录结构
|
||||
|
||||
安装后的目录结构:
|
||||
```
|
||||
C:\Program Files\LanMountainDesktop\
|
||||
├── LanMountainDesktop.Launcher.exe ← 唯一入口
|
||||
├── app-1.0.0/ ← 版本目录
|
||||
│ ├── .current ← 当前版本标记
|
||||
│ ├── LanMountainDesktop.exe
|
||||
│ └── ... (所有依赖)
|
||||
├── app-1.0.1/ ← 新版本
|
||||
│ ├── .partial ← 下载中标记
|
||||
│ └── ...
|
||||
└── .launcher/ ← Launcher 数据
|
||||
├── state/ ← OOBE 状态
|
||||
├── update/incoming/ ← 更新缓存
|
||||
└── snapshots/ ← 更新快照
|
||||
```
|
||||
|
||||
#### 版本标记文件
|
||||
|
||||
- `.current` - 标记当前使用的版本
|
||||
- `.partial` - 标记下载未完成的版本
|
||||
- `.destroy` - 标记待删除的旧版本
|
||||
|
||||
#### 启动流程
|
||||
|
||||
1. 用户启动 `LanMountainDesktop.Launcher.exe`
|
||||
2. Launcher 扫描 `app-*` 目录,选择最佳版本
|
||||
3. 如果是首次启动,显示 OOBE 引导
|
||||
4. 显示 Splash 启动动画
|
||||
5. 检查并应用待处理的更新
|
||||
6. 处理插件升级队列
|
||||
7. 启动主程序 `app-{version}/LanMountainDesktop.exe`
|
||||
8. 清理标记为 `.destroy` 的旧版本
|
||||
|
||||
#### 更新流程
|
||||
|
||||
1. Launcher 调用 GitHub Release API 检查更新
|
||||
2. 根据更新频道(Stable/Preview)过滤版本
|
||||
3. 下载增量包到 `app-{new_version}/` 并标记 `.partial`
|
||||
4. 验证文件完整性(SHA256)
|
||||
5. 删除 `.partial`,添加 `.current` 到新版本
|
||||
6. 标记旧版本 `.destroy`
|
||||
7. 下次启动时自动清理
|
||||
|
||||
#### 版本回退
|
||||
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
|
||||
```
|
||||
|
||||
回退会切换到上一个有效版本,并保留快照记录。
|
||||
|
||||
## English
|
||||
|
||||
Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is `dotnet restore`, `dotnet build LanMountainDesktop.slnx -c Debug`, `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`, and `dotnet test LanMountainDesktop.slnx -c Debug`.
|
||||
|
||||
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
|
||||
|
||||
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
549
docs/LAUNCHER.md
Normal file
549
docs/LAUNCHER.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# Launcher 架构文档
|
||||
|
||||
> LanMountainDesktop.Launcher - 应用启动器和版本管理系统
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [职责范围](#职责范围)
|
||||
- [架构设计](#架构设计)
|
||||
- [核心服务](#核心服务)
|
||||
- [版本管理](#版本管理)
|
||||
- [启动流程](#启动流程)
|
||||
- [命令行接口](#命令行接口)
|
||||
- [开发指南](#开发指南)
|
||||
|
||||
## 概述
|
||||
|
||||
Launcher 是 LanMountainDesktop 的唯一入口点,负责:
|
||||
- 首次体验引导 (OOBE)
|
||||
- 启动动画 (Splash Screen)
|
||||
- 多版本管理和选择
|
||||
- 应用更新 (增量更新、原子化更新)
|
||||
- 插件安装和升级
|
||||
- 版本回退
|
||||
|
||||
**设计理念**: 参考 ClassIsland 项目,实现原子化的多版本管理和随时版本回退能力。
|
||||
|
||||
## 职责范围
|
||||
|
||||
### 1. OOBE (Out-of-Box Experience)
|
||||
- 首次启动引导
|
||||
- 欢迎页面
|
||||
- 初始设置向导
|
||||
|
||||
### 2. Splash Screen
|
||||
- 启动动画
|
||||
- 加载进度显示
|
||||
- 品牌展示
|
||||
|
||||
### 3. 版本管理
|
||||
- 多版本并存 (`app-{version}/` 目录)
|
||||
- 版本选择算法
|
||||
- 版本标记系统 (`.current`, `.partial`, `.destroy`)
|
||||
- 旧版本自动清理
|
||||
|
||||
### 4. 应用更新
|
||||
- GitHub Release API 集成
|
||||
- 更新频道管理 (Stable/Preview)
|
||||
- 增量更新下载
|
||||
- 原子化更新应用
|
||||
- 签名验证
|
||||
- 版本回退
|
||||
|
||||
### 5. 插件管理
|
||||
- 插件安装 (`.laapp` 包)
|
||||
- 插件更新检查
|
||||
- 插件升级队列处理
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 目录结构
|
||||
|
||||
**安装后的目录结构:**
|
||||
```
|
||||
C:\Program Files\LanMountainDesktop\
|
||||
├── LanMountainDesktop.Launcher.exe ← 唯一入口
|
||||
├── app-1.0.0/ ← 版本目录
|
||||
│ ├── .current ← 当前版本标记
|
||||
│ ├── LanMountainDesktop.exe
|
||||
│ ├── LanMountainDesktop.dll
|
||||
│ └── ... (所有依赖)
|
||||
├── app-1.0.1/ ← 新版本
|
||||
│ ├── .partial ← 下载中标记
|
||||
│ └── ...
|
||||
├── app-0.9.9/ ← 旧版本
|
||||
│ ├── .destroy ← 待删除标记
|
||||
│ └── ...
|
||||
└── .launcher/ ← Launcher 数据目录
|
||||
├── state/
|
||||
│ └── first_run_completed ← OOBE 完成标记
|
||||
├── update/
|
||||
│ ├── incoming/ ← 更新缓存
|
||||
│ │ ├── files.json
|
||||
│ │ ├── files.json.sig
|
||||
│ │ └── update.zip
|
||||
│ └── public-key.pem ← RSA 公钥
|
||||
└── snapshots/ ← 更新快照
|
||||
└── {snapshot-id}.json
|
||||
```
|
||||
|
||||
### 版本标记文件
|
||||
|
||||
| 文件名 | 作用 | 创建时机 | 删除时机 |
|
||||
|--------|------|----------|----------|
|
||||
| `.current` | 标记当前使用的版本 | 更新完成后 | 新版本激活时 |
|
||||
| `.partial` | 标记下载未完成的版本 | 开始下载时 | 下载完成验证通过后 |
|
||||
| `.destroy` | 标记待删除的旧版本 | 新版本激活时 | 目录删除后 |
|
||||
|
||||
## 核心服务
|
||||
|
||||
### DeploymentLocator
|
||||
**职责**: 扫描和定位版本目录,选择最佳版本
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
// 查找当前部署目录
|
||||
string? FindCurrentDeploymentDirectory()
|
||||
|
||||
// 解析主程序可执行文件路径
|
||||
string? ResolveHostExecutablePath()
|
||||
|
||||
// 获取当前版本号
|
||||
string GetCurrentVersion()
|
||||
|
||||
// 构建下一个部署目录路径
|
||||
string BuildNextDeploymentDirectory(string targetVersion)
|
||||
|
||||
// 清理标记为 .destroy 的目录
|
||||
void CleanupDestroyedDeployments()
|
||||
```
|
||||
|
||||
**版本选择算法**:
|
||||
1. 扫描所有 `app-*` 目录
|
||||
2. 过滤掉带 `.destroy` 或 `.partial` 标记的目录
|
||||
3. 优先选择带 `.current` 标记的版本
|
||||
4. 如果没有 `.current`,选择版本号最高的
|
||||
|
||||
### UpdateCheckService
|
||||
**职责**: 检查 GitHub Release 更新
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
// 检查更新
|
||||
Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||
string currentVersion,
|
||||
UpdateChannel channel,
|
||||
CancellationToken cancellationToken = default)
|
||||
```
|
||||
|
||||
**更新频道**:
|
||||
- `Stable` - 只检查 `prerelease=false` 的版本
|
||||
- `Preview` - 检查所有版本 (包括 `prerelease=true`)
|
||||
|
||||
### UpdateEngineService
|
||||
**职责**: 下载、验证、应用更新
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
// 检查待处理的更新
|
||||
LauncherResult CheckPendingUpdate()
|
||||
|
||||
// 下载更新
|
||||
Task<LauncherResult> DownloadAsync(
|
||||
string manifestUrl,
|
||||
string signatureUrl,
|
||||
string archiveUrl,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
// 应用待处理的更新
|
||||
LauncherResult ApplyPendingUpdate()
|
||||
|
||||
// 回退到上一个版本
|
||||
LauncherResult RollbackLatest()
|
||||
|
||||
// 清理待删除的部署
|
||||
void CleanupDestroyedDeployments()
|
||||
```
|
||||
|
||||
### LauncherFlowCoordinator
|
||||
**职责**: 协调完整的启动流程
|
||||
|
||||
**启动流程**:
|
||||
1. 清理待删除的旧版本
|
||||
2. 检查是否首次运行,显示 OOBE
|
||||
3. 显示 Splash 窗口
|
||||
4. 应用待处理的更新
|
||||
5. 处理插件升级队列
|
||||
6. 启动主程序
|
||||
7. 关闭 Splash 窗口
|
||||
|
||||
### OobeStateService
|
||||
**职责**: 管理首次运行状态
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
// 检查是否首次运行
|
||||
bool IsFirstRun()
|
||||
|
||||
// 标记 OOBE 已完成
|
||||
void MarkCompleted()
|
||||
```
|
||||
|
||||
### PluginInstallerService
|
||||
**职责**: 处理插件安装
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
// 安装插件包
|
||||
Task<PluginInstallResult> InstallAsync(
|
||||
string packagePath,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
```
|
||||
|
||||
### PluginUpgradeQueueService
|
||||
**职责**: 批量处理插件升级队列
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
// 应用待处理的插件升级
|
||||
LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||
```
|
||||
|
||||
## 版本管理
|
||||
|
||||
### 版本选择算法详解
|
||||
|
||||
```csharp
|
||||
public string? FindCurrentDeploymentDirectory()
|
||||
{
|
||||
var candidates = Directory.GetDirectories(rootDir, "app-*");
|
||||
|
||||
// 1. 过滤无效版本
|
||||
var validCandidates = candidates
|
||||
.Where(path =>
|
||||
!File.Exists(Path.Combine(path, ".destroy")) &&
|
||||
!File.Exists(Path.Combine(path, ".partial")))
|
||||
.ToList();
|
||||
|
||||
// 2. 优先选择带 .current 标记的
|
||||
var withMarkers = validCandidates
|
||||
.Where(path => File.Exists(Path.Combine(path, ".current")))
|
||||
.OrderByDescending(path => ParseVersion(path))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (withMarkers != null)
|
||||
return withMarkers;
|
||||
|
||||
// 3. 选择版本号最高的
|
||||
return validCandidates
|
||||
.OrderByDescending(path => ParseVersion(path))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
```
|
||||
|
||||
### 版本激活流程
|
||||
|
||||
```csharp
|
||||
private void ActivateDeployment(string fromDeployment, string toDeployment)
|
||||
{
|
||||
// 1. 在新版本添加 .current 标记
|
||||
File.WriteAllText(Path.Combine(toDeployment, ".current"), string.Empty);
|
||||
|
||||
// 2. 移除旧版本的 .current 标记
|
||||
var fromCurrent = Path.Combine(fromDeployment, ".current");
|
||||
if (File.Exists(fromCurrent))
|
||||
File.Delete(fromCurrent);
|
||||
|
||||
// 3. 标记旧版本为待删除
|
||||
File.WriteAllText(Path.Combine(fromDeployment, ".destroy"), string.Empty);
|
||||
|
||||
// 4. 移除新版本的 .partial 标记 (如果有)
|
||||
var toPartial = Path.Combine(toDeployment, ".partial");
|
||||
if (File.Exists(toPartial))
|
||||
File.Delete(toPartial);
|
||||
}
|
||||
```
|
||||
|
||||
### 版本清理流程
|
||||
|
||||
```csharp
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
var destroyedDirs = Directory.GetDirectories(rootDir)
|
||||
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
|
||||
|
||||
foreach (var dir in destroyedDirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败 (可能文件被占用)
|
||||
// 下次启动时再试
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 启动流程
|
||||
|
||||
### 完整启动流程图
|
||||
|
||||
```
|
||||
用户启动 Launcher.exe
|
||||
↓
|
||||
清理旧版本 (.destroy 目录)
|
||||
↓
|
||||
首次运行? ──Yes→ 显示 OOBE 窗口
|
||||
↓ No
|
||||
显示 Splash 窗口
|
||||
↓
|
||||
检查待处理的更新
|
||||
↓
|
||||
有更新? ──Yes→ 应用更新 (原子化)
|
||||
↓ No
|
||||
处理插件升级队列
|
||||
↓
|
||||
选择最佳版本 (DeploymentLocator)
|
||||
↓
|
||||
启动主程序 (Process.Start)
|
||||
↓
|
||||
关闭 Splash 窗口
|
||||
↓
|
||||
Launcher 退出
|
||||
```
|
||||
|
||||
### 代码流程
|
||||
|
||||
**Program.cs**:
|
||||
```csharp
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
|
||||
// 处理 CLI 命令
|
||||
if (commandContext.Command != "launch")
|
||||
return await Commands.RunCliCommandAsync(commandContext);
|
||||
|
||||
// 启动 Avalonia 应用
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
```
|
||||
|
||||
**App.axaml.cs**:
|
||||
```csharp
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateCheckService = new UpdateCheckService("owner", "repo");
|
||||
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
_ = RunCoordinatorAsync(desktop, coordinator);
|
||||
}
|
||||
```
|
||||
|
||||
**LauncherFlowCoordinator.RunAsync()**:
|
||||
```csharp
|
||||
public async Task<LauncherResult> RunAsync()
|
||||
{
|
||||
// 1. 清理旧版本
|
||||
_deploymentLocator.CleanupDestroyedDeployments();
|
||||
|
||||
// 2. OOBE
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
foreach (var step in _oobeSteps)
|
||||
await step.RunAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// 3. Splash
|
||||
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
window.Show();
|
||||
return window;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// 4. 应用更新
|
||||
var updateResult = _updateEngine.ApplyPendingUpdate();
|
||||
if (!updateResult.Success)
|
||||
return updateResult;
|
||||
|
||||
// 5. 插件升级
|
||||
var pluginsDir = Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService)
|
||||
.ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success)
|
||||
return queueResult;
|
||||
|
||||
// 6. 启动主程序
|
||||
var hostResult = LaunchHost();
|
||||
if (!hostResult.Success)
|
||||
return hostResult;
|
||||
|
||||
return new LauncherResult { Success = true };
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 命令行接口
|
||||
|
||||
### launch - 启动应用
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe launch
|
||||
```
|
||||
|
||||
启动完整流程: OOBE → Splash → 更新 → 插件 → 主程序
|
||||
|
||||
### update check - 检查更新
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update check
|
||||
```
|
||||
|
||||
检查 GitHub Release 是否有新版本。
|
||||
|
||||
### update download - 下载更新
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update download --version 1.0.1
|
||||
```
|
||||
|
||||
下载指定版本的更新包。
|
||||
|
||||
### update apply - 应用更新
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update apply
|
||||
```
|
||||
|
||||
应用已下载的更新 (原子化操作)。
|
||||
|
||||
### update rollback - 版本回退
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
```
|
||||
|
||||
回退到上一个有效版本。
|
||||
|
||||
### plugin install - 安装插件
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe plugin install <path-to-plugin.laapp>
|
||||
```
|
||||
|
||||
安装 `.laapp` 插件包。
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 本地调试
|
||||
|
||||
**直接运行 Launcher:**
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
**调试特定命令:**
|
||||
```bash
|
||||
# 检查更新
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check
|
||||
|
||||
# 版本回退
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
|
||||
```
|
||||
|
||||
### 模拟多版本环境
|
||||
|
||||
```bash
|
||||
# 1. 发布主程序
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Debug -o ./test-deploy/app-1.0.0
|
||||
|
||||
# 2. 创建 .current 标记
|
||||
New-Item -ItemType File -Path ./test-deploy/app-1.0.0/.current
|
||||
|
||||
# 3. 复制 Launcher 到根目录
|
||||
Copy-Item LanMountainDesktop.Launcher/bin/Debug/net10.0/* ./test-deploy/
|
||||
|
||||
# 4. 运行 Launcher
|
||||
./test-deploy/LanMountainDesktop.Launcher.exe launch
|
||||
```
|
||||
|
||||
### 测试更新流程
|
||||
|
||||
```bash
|
||||
# 1. 创建两个版本
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -o ./test-deploy/app-1.0.0
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -o ./test-deploy/app-1.0.1
|
||||
|
||||
# 2. 生成增量包
|
||||
pwsh ./scripts/Generate-DeltaPackage.ps1 `
|
||||
-PreviousVersion "1.0.0" `
|
||||
-CurrentVersion "1.0.1" `
|
||||
-PreviousDir "./test-deploy/app-1.0.0" `
|
||||
-CurrentDir "./test-deploy/app-1.0.1" `
|
||||
-OutputDir "./test-deploy/.launcher/update/incoming"
|
||||
|
||||
# 3. 测试应用更新
|
||||
./test-deploy/LanMountainDesktop.Launcher.exe update apply
|
||||
```
|
||||
|
||||
### 添加新的 OOBE 步骤
|
||||
|
||||
1. 实现 `IOobeStep` 接口:
|
||||
```csharp
|
||||
public class MyOobeStep : IOobeStep
|
||||
{
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 显示 OOBE 窗口
|
||||
// 等待用户完成
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `LauncherFlowCoordinator` 中注册:
|
||||
```csharp
|
||||
_oobeSteps = [
|
||||
new WelcomeOobeStep(_oobeStateService),
|
||||
new MyOobeStep() // 添加新步骤
|
||||
];
|
||||
```
|
||||
|
||||
### 自定义更新源
|
||||
|
||||
修改 `App.axaml.cs` 中的 GitHub 仓库信息:
|
||||
```csharp
|
||||
var updateCheckService = new UpdateCheckService(
|
||||
"YourOrg", // GitHub 组织/用户名
|
||||
"YourRepo" // 仓库名
|
||||
);
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [更新系统详细文档](UPDATE_SYSTEM.md)
|
||||
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
||||
- [架构文档](ARCHITECTURE.md)
|
||||
- [开发文档](DEVELOPMENT.md)
|
||||
686
docs/PLUGIN_DEVELOPMENT.md
Normal file
686
docs/PLUGIN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# 插件开发指南
|
||||
|
||||
> 为 LanMountainDesktop 开发自定义插件
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速开始](#快速开始)
|
||||
- [插件架构](#插件架构)
|
||||
- [创建插件](#创建插件)
|
||||
- [插件生命周期](#插件生命周期)
|
||||
- [添加组件](#添加组件)
|
||||
- [添加设置页](#添加设置页)
|
||||
- [使用服务](#使用服务)
|
||||
- [打包和发布](#打包和发布)
|
||||
- [最佳实践](#最佳实践)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装插件模板
|
||||
|
||||
```bash
|
||||
# 安装官方插件模板
|
||||
dotnet new install LanMountainDesktop.PluginTemplate
|
||||
|
||||
# 查看可用模板
|
||||
dotnet new list | findstr lmd
|
||||
```
|
||||
|
||||
### 创建新插件
|
||||
|
||||
```bash
|
||||
# 创建插件项目
|
||||
dotnet new lmd-plugin -n MyAwesomePlugin
|
||||
|
||||
# 进入项目目录
|
||||
cd MyAwesomePlugin
|
||||
|
||||
# 还原依赖
|
||||
dotnet restore
|
||||
|
||||
# 构建插件
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
MyAwesomePlugin/
|
||||
├── MyAwesomePlugin.csproj # 项目文件
|
||||
├── Plugin.cs # 插件入口
|
||||
├── Components/ # 组件目录
|
||||
│ └── MyComponent.cs
|
||||
├── Views/ # 视图目录
|
||||
│ └── MyComponentView.axaml
|
||||
├── ViewModels/ # 视图模型
|
||||
│ └── MyComponentViewModel.cs
|
||||
├── Settings/ # 设置页
|
||||
│ └── MySettingsPage.axaml
|
||||
└── plugin.json # 插件清单
|
||||
```
|
||||
|
||||
## 插件架构
|
||||
|
||||
### 插件 SDK 版本
|
||||
|
||||
当前 SDK 版本: **4.0.1**
|
||||
|
||||
```xml
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
|
||||
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="4.0.1" />
|
||||
```
|
||||
|
||||
### 插件清单 (plugin.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"Id": "com.example.myawesomeplugin",
|
||||
"Name": "My Awesome Plugin",
|
||||
"Version": "1.0.0",
|
||||
"Author": "Your Name",
|
||||
"Description": "A plugin that does awesome things",
|
||||
"MinHostVersion": "1.0.0",
|
||||
"Dependencies": [],
|
||||
"Permissions": [
|
||||
"FileSystem.Read",
|
||||
"Network.Access"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 核心接口
|
||||
|
||||
**IPlugin** - 插件入口接口:
|
||||
```csharp
|
||||
public interface IPlugin
|
||||
{
|
||||
string Id { get; }
|
||||
string Name { get; }
|
||||
string Version { get; }
|
||||
|
||||
Task InitializeAsync(IPluginContext context);
|
||||
Task ShutdownAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**IPluginContext** - 插件上下文:
|
||||
```csharp
|
||||
public interface IPluginContext
|
||||
{
|
||||
string PluginDirectory { get; }
|
||||
IServiceProvider Services { get; }
|
||||
ILogger Logger { get; }
|
||||
ISettingsService Settings { get; }
|
||||
}
|
||||
```
|
||||
|
||||
## 创建插件
|
||||
|
||||
### 1. 实现插件入口
|
||||
|
||||
```csharp
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace MyAwesomePlugin;
|
||||
|
||||
public class Plugin : IPlugin
|
||||
{
|
||||
public string Id => "com.example.myawesomeplugin";
|
||||
public string Name => "My Awesome Plugin";
|
||||
public string Version => "1.0.0";
|
||||
|
||||
private IPluginContext? _context;
|
||||
|
||||
public async Task InitializeAsync(IPluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
// 注册组件
|
||||
var componentRegistry = context.Services.GetService<IComponentRegistry>();
|
||||
componentRegistry?.RegisterComponent<MyComponent>();
|
||||
|
||||
// 注册设置页
|
||||
var settingsRegistry = context.Services.GetService<ISettingsPageRegistry>();
|
||||
settingsRegistry?.RegisterPage<MySettingsPage>("我的插件设置");
|
||||
|
||||
// 初始化逻辑
|
||||
context.Logger.LogInformation("Plugin initialized");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
// 清理资源
|
||||
_context?.Logger.LogInformation("Plugin shutting down");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 配置项目文件
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!-- 插件元数据 -->
|
||||
<PluginId>com.example.myawesomeplugin</PluginId>
|
||||
<PluginName>My Awesome Plugin</PluginName>
|
||||
<PluginVersion>1.0.0</PluginVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
|
||||
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="4.0.1" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 复制 plugin.json 到输出目录 -->
|
||||
<ItemGroup>
|
||||
<None Update="plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
## 插件生命周期
|
||||
|
||||
### 生命周期阶段
|
||||
|
||||
```
|
||||
1. 发现 (Discovery)
|
||||
↓
|
||||
2. 加载 (Load)
|
||||
├─ 加载程序集
|
||||
├─ 验证依赖
|
||||
└─ 创建插件实例
|
||||
↓
|
||||
3. 初始化 (Initialize)
|
||||
├─ 调用 InitializeAsync()
|
||||
├─ 注册组件
|
||||
├─ 注册设置页
|
||||
└─ 初始化服务
|
||||
↓
|
||||
4. 运行 (Running)
|
||||
├─ 组件渲染
|
||||
├─ 事件处理
|
||||
└─ 服务调用
|
||||
↓
|
||||
5. 关闭 (Shutdown)
|
||||
├─ 调用 ShutdownAsync()
|
||||
├─ 清理资源
|
||||
└─ 卸载程序集
|
||||
```
|
||||
|
||||
### 生命周期钩子
|
||||
|
||||
```csharp
|
||||
public class Plugin : IPlugin
|
||||
{
|
||||
// 插件加载后立即调用
|
||||
public async Task InitializeAsync(IPluginContext context)
|
||||
{
|
||||
// 注册组件、服务、设置页
|
||||
// 初始化资源
|
||||
}
|
||||
|
||||
// 插件卸载前调用
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
// 保存状态
|
||||
// 释放资源
|
||||
// 取消订阅
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 添加组件
|
||||
|
||||
### 1. 定义组件类
|
||||
|
||||
```csharp
|
||||
using LanMountainDesktop.PluginSdk.Components;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace MyAwesomePlugin.Components;
|
||||
|
||||
[Component(
|
||||
Id = "com.example.myawesomeplugin.mycomponent",
|
||||
Name = "我的组件",
|
||||
Description = "一个很棒的组件",
|
||||
Category = "工具",
|
||||
Icon = "avares://MyAwesomePlugin/Assets/icon.png"
|
||||
)]
|
||||
public class MyComponent : ComponentBase
|
||||
{
|
||||
public override string Id => "com.example.myawesomeplugin.mycomponent";
|
||||
public override string Name => "我的组件";
|
||||
|
||||
// 组件设置
|
||||
private string _message = "Hello, World!";
|
||||
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
set => SetProperty(ref _message, value);
|
||||
}
|
||||
|
||||
// 组件初始化
|
||||
public override Task InitializeAsync()
|
||||
{
|
||||
// 加载设置
|
||||
Message = Settings.GetValue("Message", "Hello, World!");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 组件更新 (定时调用)
|
||||
public override Task UpdateAsync()
|
||||
{
|
||||
// 更新组件数据
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建组件视图
|
||||
|
||||
**MyComponentView.axaml:**
|
||||
```xml
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:MyAwesomePlugin.ViewModels"
|
||||
x:Class="MyAwesomePlugin.Views.MyComponentView"
|
||||
x:DataType="vm:MyComponentViewModel">
|
||||
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding Component.Name}"
|
||||
FontSize="18"
|
||||
FontWeight="Bold" />
|
||||
|
||||
<TextBlock Text="{Binding Component.Message}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Button Content="点击我"
|
||||
Command="{Binding ClickCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
**MyComponentView.axaml.cs:**
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace MyAwesomePlugin.Views;
|
||||
|
||||
public partial class MyComponentView : UserControl
|
||||
{
|
||||
public MyComponentView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 创建视图模型
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace MyAwesomePlugin.ViewModels;
|
||||
|
||||
public partial class MyComponentViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private MyComponent _component;
|
||||
|
||||
public MyComponentViewModel(MyComponent component)
|
||||
{
|
||||
_component = component;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Click()
|
||||
{
|
||||
Component.Message = "按钮被点击了!";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 注册组件
|
||||
|
||||
```csharp
|
||||
public async Task InitializeAsync(IPluginContext context)
|
||||
{
|
||||
var componentRegistry = context.Services.GetService<IComponentRegistry>();
|
||||
|
||||
// 注册组件
|
||||
componentRegistry?.RegisterComponent<MyComponent>(
|
||||
componentFactory: () => new MyComponent(),
|
||||
viewFactory: (component) => new MyComponentView
|
||||
{
|
||||
DataContext = new MyComponentViewModel((MyComponent)component)
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 添加设置页
|
||||
|
||||
### 1. 创建设置页视图
|
||||
|
||||
**MySettingsPage.axaml:**
|
||||
```xml
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="MyAwesomePlugin.Settings.MySettingsPage">
|
||||
<StackPanel Spacing="16" Margin="24">
|
||||
<TextBlock Text="我的插件设置"
|
||||
FontSize="24"
|
||||
FontWeight="Bold" />
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="消息内容:" />
|
||||
<TextBox x:Name="MessageTextBox"
|
||||
Watermark="输入消息..." />
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="保存"
|
||||
Click="SaveButton_Click" />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
**MySettingsPage.axaml.cs:**
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace MyAwesomePlugin.Settings;
|
||||
|
||||
public partial class MySettingsPage : UserControl
|
||||
{
|
||||
private readonly ISettingsService _settings;
|
||||
|
||||
public MySettingsPage(ISettingsService settings)
|
||||
{
|
||||
InitializeComponent();
|
||||
_settings = settings;
|
||||
|
||||
// 加载设置
|
||||
MessageTextBox.Text = _settings.GetValue("Message", "Hello, World!");
|
||||
}
|
||||
|
||||
private void SaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// 保存设置
|
||||
_settings.SetValue("Message", MessageTextBox.Text);
|
||||
|
||||
// 显示提示
|
||||
// TODO: 显示保存成功提示
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 注册设置页
|
||||
|
||||
```csharp
|
||||
public async Task InitializeAsync(IPluginContext context)
|
||||
{
|
||||
var settingsRegistry = context.Services.GetService<ISettingsPageRegistry>();
|
||||
|
||||
settingsRegistry?.RegisterPage(
|
||||
title: "我的插件",
|
||||
category: "插件",
|
||||
pageFactory: () => new MySettingsPage(context.Settings)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 使用服务
|
||||
|
||||
### 可用服务
|
||||
|
||||
**ILogger** - 日志服务:
|
||||
```csharp
|
||||
context.Logger.LogInformation("信息日志");
|
||||
context.Logger.LogWarning("警告日志");
|
||||
context.Logger.LogError("错误日志");
|
||||
```
|
||||
|
||||
**ISettingsService** - 设置服务:
|
||||
```csharp
|
||||
// 读取设置
|
||||
var value = context.Settings.GetValue("Key", "DefaultValue");
|
||||
|
||||
// 写入设置
|
||||
context.Settings.SetValue("Key", "NewValue");
|
||||
|
||||
// 监听设置变化
|
||||
context.Settings.SettingChanged += (sender, e) =>
|
||||
{
|
||||
if (e.Key == "Key")
|
||||
{
|
||||
// 设置已变更
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**INotificationService** - 通知服务:
|
||||
```csharp
|
||||
var notificationService = context.Services.GetService<INotificationService>();
|
||||
|
||||
notificationService?.ShowNotification(
|
||||
title: "通知标题",
|
||||
message: "通知内容",
|
||||
type: NotificationType.Information
|
||||
);
|
||||
```
|
||||
|
||||
**IHttpClientFactory** - HTTP 客户端:
|
||||
```csharp
|
||||
var httpFactory = context.Services.GetService<IHttpClientFactory>();
|
||||
var httpClient = httpFactory?.CreateClient();
|
||||
|
||||
var response = await httpClient.GetStringAsync("https://api.example.com/data");
|
||||
```
|
||||
|
||||
## 打包和发布
|
||||
|
||||
### 1. 构建插件
|
||||
|
||||
```bash
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
### 2. 打包为 .laapp
|
||||
|
||||
```bash
|
||||
# 使用官方打包脚本
|
||||
pwsh ./scripts/Pack-PluginPackages.ps1 -PluginProject ./MyAwesomePlugin/MyAwesomePlugin.csproj
|
||||
|
||||
# 或手动打包
|
||||
cd MyAwesomePlugin/bin/Release/net10.0
|
||||
zip -r MyAwesomePlugin-1.0.0.laapp *
|
||||
```
|
||||
|
||||
### 3. 测试插件
|
||||
|
||||
```bash
|
||||
# 安装插件
|
||||
LanMountainDesktop.Launcher.exe plugin install MyAwesomePlugin-1.0.0.laapp
|
||||
|
||||
# 启动应用测试
|
||||
LanMountainDesktop.Launcher.exe launch
|
||||
```
|
||||
|
||||
### 4. 发布插件
|
||||
|
||||
**选项 1: GitHub Release**
|
||||
1. 创建 GitHub 仓库
|
||||
2. 上传 `.laapp` 文件到 Release
|
||||
3. 用户可以手动下载安装
|
||||
|
||||
**选项 2: 插件市场** (如果可用)
|
||||
1. 提交插件到官方市场
|
||||
2. 等待审核
|
||||
3. 用户可以在应用内浏览和安装
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 性能优化
|
||||
|
||||
1. **避免阻塞 UI 线程:**
|
||||
```csharp
|
||||
// 错误
|
||||
public override Task UpdateAsync()
|
||||
{
|
||||
Thread.Sleep(1000); // 阻塞!
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 正确
|
||||
public override async Task UpdateAsync()
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
```
|
||||
|
||||
2. **使用异步 API:**
|
||||
```csharp
|
||||
// 使用 async/await
|
||||
var data = await httpClient.GetStringAsync(url);
|
||||
```
|
||||
|
||||
3. **缓存数据:**
|
||||
```csharp
|
||||
private string? _cachedData;
|
||||
private DateTime _cacheTime;
|
||||
|
||||
public async Task<string> GetDataAsync()
|
||||
{
|
||||
if (_cachedData != null && DateTime.Now - _cacheTime < TimeSpan.FromMinutes(5))
|
||||
return _cachedData;
|
||||
|
||||
_cachedData = await FetchDataAsync();
|
||||
_cacheTime = DateTime.Now;
|
||||
return _cachedData;
|
||||
}
|
||||
```
|
||||
|
||||
### 资源管理
|
||||
|
||||
1. **实现 IDisposable:**
|
||||
```csharp
|
||||
public class MyComponent : ComponentBase, IDisposable
|
||||
{
|
||||
private HttpClient? _httpClient;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **取消订阅事件:**
|
||||
```csharp
|
||||
public override Task ShutdownAsync()
|
||||
{
|
||||
context.Settings.SettingChanged -= OnSettingChanged;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
1. **捕获异常:**
|
||||
```csharp
|
||||
public override async Task UpdateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await FetchDataAsync();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to fetch data");
|
||||
// 显示错误提示给用户
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **验证输入:**
|
||||
```csharp
|
||||
public void SetUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
throw new ArgumentException("URL cannot be empty", nameof(url));
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out _))
|
||||
throw new ArgumentException("Invalid URL format", nameof(url));
|
||||
|
||||
_url = url;
|
||||
}
|
||||
```
|
||||
|
||||
### 本地化
|
||||
|
||||
1. **使用资源文件:**
|
||||
```csharp
|
||||
// Resources/Strings.resx
|
||||
// Name: ComponentName, Value: My Component
|
||||
|
||||
public override string Name => Resources.Strings.ComponentName;
|
||||
```
|
||||
|
||||
2. **支持多语言:**
|
||||
```xml
|
||||
<!-- Resources/Strings.zh-CN.resx -->
|
||||
<data name="ComponentName" xml:space="preserve">
|
||||
<value>我的组件</value>
|
||||
</data>
|
||||
```
|
||||
|
||||
### 安全性
|
||||
|
||||
1. **验证用户输入:**
|
||||
```csharp
|
||||
// 防止路径遍历
|
||||
var safePath = Path.GetFullPath(Path.Combine(pluginDirectory, userInput));
|
||||
if (!safePath.StartsWith(pluginDirectory))
|
||||
throw new SecurityException("Invalid path");
|
||||
```
|
||||
|
||||
2. **使用 HTTPS:**
|
||||
```csharp
|
||||
// 强制使用 HTTPS
|
||||
if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
throw new SecurityException("Only HTTPS URLs are allowed");
|
||||
```
|
||||
|
||||
## 示例插件
|
||||
|
||||
查看官方示例插件:
|
||||
- **天气组件** - 显示天气信息
|
||||
- **倒计时组件** - 倒计时功能
|
||||
- **RSS 阅读器** - 订阅和显示 RSS 源
|
||||
|
||||
仓库: https://github.com/YourOrg/LanMountainDesktop.SamplePlugin
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Plugin SDK v4 迁移指南](PLUGIN_SDK_V4_MIGRATION.md)
|
||||
- [组件开发指南](COMPONENT_DEVELOPMENT.md)
|
||||
- [API 参考](API_REFERENCE.md)
|
||||
- [架构文档](ARCHITECTURE.md)
|
||||
644
docs/TROUBLESHOOTING.md
Normal file
644
docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,644 @@
|
||||
# 故障排除指南
|
||||
|
||||
> LanMountainDesktop 常见问题和解决方案
|
||||
|
||||
## 目录
|
||||
|
||||
- [构建问题](#构建问题)
|
||||
- [运行时问题](#运行时问题)
|
||||
- [Launcher 问题](#launcher-问题)
|
||||
- [更新问题](#更新问题)
|
||||
- [插件问题](#插件问题)
|
||||
- [性能问题](#性能问题)
|
||||
- [平台特定问题](#平台特定问题)
|
||||
|
||||
## 构建问题
|
||||
|
||||
### 问题: 编译错误 - 找不到 Windows.Win32 命名空间
|
||||
|
||||
**症状:**
|
||||
```
|
||||
error CS0246: The type or namespace name 'Windows' could not be found
|
||||
```
|
||||
|
||||
**原因:** CsWin32 尚未生成 P/Invoke 代码
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 清理并重新构建
|
||||
dotnet clean
|
||||
dotnet restore
|
||||
dotnet build
|
||||
```
|
||||
|
||||
首次构建时 CsWin32 会自动生成代码,第二次构建应该成功。
|
||||
|
||||
---
|
||||
|
||||
### 问题: NuGet 包还原失败
|
||||
|
||||
**症状:**
|
||||
```
|
||||
error NU1102: Unable to find package 'XXX' with version (>= X.X.X)
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 清理 NuGet 缓存
|
||||
dotnet nuget locals all --clear
|
||||
|
||||
# 强制还原
|
||||
dotnet restore --force
|
||||
|
||||
# 重新构建
|
||||
dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题: 构建时提示 SDK 版本不匹配
|
||||
|
||||
**症状:**
|
||||
```
|
||||
error NETSDK1045: The current .NET SDK does not support targeting .NET 10.0
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 检查当前 SDK 版本
|
||||
dotnet --version
|
||||
|
||||
# 应该显示 10.x.x
|
||||
# 如果不是,请安装 .NET 10 SDK
|
||||
```
|
||||
|
||||
下载地址: https://dotnet.microsoft.com/download/dotnet/10.0
|
||||
|
||||
---
|
||||
|
||||
### 问题: Avalonia 设计器无法加载
|
||||
|
||||
**症状:** XAML 预览显示错误或空白
|
||||
|
||||
**解决方案:**
|
||||
1. 重启 IDE
|
||||
2. 清理并重新构建项目
|
||||
3. 检查 Avalonia 版本是否一致
|
||||
|
||||
```bash
|
||||
dotnet clean
|
||||
dotnet build
|
||||
```
|
||||
|
||||
## 运行时问题
|
||||
|
||||
### 问题: 应用启动后立即崩溃
|
||||
|
||||
**诊断步骤:**
|
||||
|
||||
1. **检查日志文件:**
|
||||
```
|
||||
Windows: %LOCALAPPDATA%\LanMountainDesktop\logs\
|
||||
Linux: ~/.local/share/LanMountainDesktop/logs/
|
||||
macOS: ~/Library/Application Support/LanMountainDesktop/logs/
|
||||
```
|
||||
|
||||
2. **以调试模式运行:**
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
3. **检查依赖:**
|
||||
```bash
|
||||
# Windows: 确保安装了 .NET 10 Desktop Runtime
|
||||
# Linux: 确保安装了必要的图形库
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题: 窗口无法显示或黑屏
|
||||
|
||||
**可能原因:**
|
||||
- 显卡驱动问题
|
||||
- 渲染模式不兼容
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **切换渲染模式** (编辑配置文件):
|
||||
```json
|
||||
{
|
||||
"Win32RenderingMode": 1 // 尝试不同的值: 0, 1, 2, 3, 4
|
||||
}
|
||||
```
|
||||
|
||||
2. **禁用硬件加速:**
|
||||
```bash
|
||||
# 设置环境变量
|
||||
set AVALONIA_RENDERING_MODE=Software
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题: 单实例锁定失败
|
||||
|
||||
**症状:** 提示"应用已在运行"但实际没有
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# Windows
|
||||
taskkill /F /IM LanMountainDesktop.exe
|
||||
|
||||
# Linux/macOS
|
||||
pkill -9 LanMountainDesktop
|
||||
```
|
||||
|
||||
如果问题持续,删除锁文件:
|
||||
```
|
||||
Windows: %TEMP%\LanMountainDesktop.lock
|
||||
Linux: /tmp/LanMountainDesktop.lock
|
||||
macOS: /tmp/LanMountainDesktop.lock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题: 设置无法保存
|
||||
|
||||
**症状:** 修改设置后重启应用,设置恢复默认
|
||||
|
||||
**诊断:**
|
||||
```bash
|
||||
# 检查设置文件是否存在
|
||||
# Windows: %LOCALAPPDATA%\LanMountainDesktop\settings.json
|
||||
# Linux: ~/.local/share/LanMountainDesktop/settings.json
|
||||
# macOS: ~/Library/Application Support/LanMountainDesktop/settings.json
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
1. 检查文件权限
|
||||
2. 检查磁盘空间
|
||||
3. 删除损坏的设置文件 (会重置为默认)
|
||||
|
||||
## Launcher 问题
|
||||
|
||||
### 问题: Launcher 找不到主程序
|
||||
|
||||
**症状:**
|
||||
```
|
||||
找不到有效的 LanMountainDesktop 版本,可能是安装已损坏。
|
||||
```
|
||||
|
||||
**诊断:**
|
||||
```bash
|
||||
# 检查目录结构
|
||||
ls "C:\Program Files\LanMountainDesktop\"
|
||||
|
||||
# 应该看到:
|
||||
# - LanMountainDesktop.Launcher.exe
|
||||
# - app-{version}/
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **检查 app-* 目录是否存在:**
|
||||
```bash
|
||||
ls "C:\Program Files\LanMountainDesktop\app-*"
|
||||
```
|
||||
|
||||
2. **检查主程序是否存在:**
|
||||
```bash
|
||||
ls "C:\Program Files\LanMountainDesktop\app-{version}\LanMountainDesktop.exe"
|
||||
```
|
||||
|
||||
3. **重新安装应用**
|
||||
|
||||
---
|
||||
|
||||
### 问题: OOBE 窗口重复出现
|
||||
|
||||
**症状:** 每次启动都显示欢迎页面
|
||||
|
||||
**原因:** OOBE 完成标记文件丢失或无法创建
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 手动创建标记文件
|
||||
# Windows:
|
||||
New-Item -ItemType File -Path "$env:LOCALAPPDATA\LanMountainDesktop\.launcher\state\first_run_completed"
|
||||
|
||||
# Linux/macOS:
|
||||
mkdir -p ~/.local/share/LanMountainDesktop/.launcher/state
|
||||
touch ~/.local/share/LanMountainDesktop/.launcher/state/first_run_completed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题: Splash 窗口卡住不消失
|
||||
|
||||
**症状:** 启动动画一直显示,主程序无法启动
|
||||
|
||||
**诊断:**
|
||||
```bash
|
||||
# 检查主程序是否启动
|
||||
# Windows:
|
||||
tasklist | findstr LanMountainDesktop
|
||||
|
||||
# Linux/macOS:
|
||||
ps aux | grep LanMountainDesktop
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
1. 强制关闭 Launcher
|
||||
2. 直接运行主程序测试:
|
||||
```bash
|
||||
"C:\Program Files\LanMountainDesktop\app-{version}\LanMountainDesktop.exe"
|
||||
```
|
||||
3. 检查日志文件
|
||||
|
||||
## 更新问题
|
||||
|
||||
### 问题: 更新下载失败
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Failed to download update: The remote server returned an error
|
||||
```
|
||||
|
||||
**可能原因:**
|
||||
- 网络连接问题
|
||||
- GitHub API 限流
|
||||
- 代理设置问题
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **检查网络连接:**
|
||||
```bash
|
||||
# 测试 GitHub 连接
|
||||
curl https://api.github.com/repos/YourOrg/LanMountainDesktop/releases/latest
|
||||
```
|
||||
|
||||
2. **配置代理** (如果需要):
|
||||
```bash
|
||||
# 设置环境变量
|
||||
set HTTP_PROXY=http://proxy.example.com:8080
|
||||
set HTTPS_PROXY=http://proxy.example.com:8080
|
||||
```
|
||||
|
||||
3. **手动下载更新:**
|
||||
- 访问 GitHub Releases 页面
|
||||
- 下载安装包
|
||||
- 重新安装
|
||||
|
||||
---
|
||||
|
||||
### 问题: 更新签名验证失败
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Signature verification failed
|
||||
```
|
||||
|
||||
**原因:**
|
||||
- 文件损坏
|
||||
- 公钥不匹配
|
||||
- 文件被篡改
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **删除损坏的更新文件:**
|
||||
```bash
|
||||
# Windows:
|
||||
Remove-Item "$env:LOCALAPPDATA\LanMountainDesktop\.launcher\update\incoming\*"
|
||||
|
||||
# Linux/macOS:
|
||||
rm -rf ~/.local/share/LanMountainDesktop/.launcher/update/incoming/*
|
||||
```
|
||||
|
||||
2. **重新下载更新**
|
||||
|
||||
3. **如果问题持续,重新安装应用**
|
||||
|
||||
---
|
||||
|
||||
### 问题: 更新后应用无法启动
|
||||
|
||||
**症状:** 更新完成后,应用启动失败或崩溃
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **版本回退:**
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
```
|
||||
|
||||
2. **检查更新快照:**
|
||||
```bash
|
||||
# Windows:
|
||||
ls "$env:LOCALAPPDATA\LanMountainDesktop\.launcher\snapshots\"
|
||||
|
||||
# 查看快照内容
|
||||
cat "$env:LOCALAPPDATA\LanMountainDesktop\.launcher\snapshots\{snapshot-id}.json"
|
||||
```
|
||||
|
||||
3. **手动切换版本:**
|
||||
```bash
|
||||
# 删除新版本的 .current 标记
|
||||
Remove-Item "C:\Program Files\LanMountainDesktop\app-{new}\\.current"
|
||||
|
||||
# 添加 .current 到旧版本
|
||||
New-Item -ItemType File -Path "C:\Program Files\LanMountainDesktop\app-{old}\\.current"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题: 增量更新文件哈希不匹配
|
||||
|
||||
**症状:**
|
||||
```
|
||||
File hash mismatch for 'XXX.dll'
|
||||
```
|
||||
|
||||
**原因:**
|
||||
- 文件下载不完整
|
||||
- 文件损坏
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **删除部分下载的更新:**
|
||||
```bash
|
||||
# 删除标记为 .partial 的目录
|
||||
Remove-Item "C:\Program Files\LanMountainDesktop\app-*" -Recurse -Force -Include *.partial
|
||||
```
|
||||
|
||||
2. **清理更新缓存:**
|
||||
```bash
|
||||
Remove-Item "$env:LOCALAPPDATA\LanMountainDesktop\.launcher\update\incoming\*"
|
||||
```
|
||||
|
||||
3. **重新下载更新**
|
||||
|
||||
## 插件问题
|
||||
|
||||
### 问题: 插件无法加载
|
||||
|
||||
**症状:** 插件列表中看不到已安装的插件
|
||||
|
||||
**诊断:**
|
||||
```bash
|
||||
# 检查插件目录
|
||||
ls "C:\Program Files\LanMountainDesktop\plugins\"
|
||||
|
||||
# 检查插件清单
|
||||
cat "C:\Program Files\LanMountainDesktop\plugins\{plugin-id}\plugin.json"
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **检查插件兼容性:**
|
||||
- 插件 SDK 版本是否匹配
|
||||
- 插件是否支持当前平台
|
||||
|
||||
2. **重新安装插件:**
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe plugin install <path-to-plugin.laapp>
|
||||
```
|
||||
|
||||
3. **检查日志文件** 查看插件加载错误
|
||||
|
||||
---
|
||||
|
||||
### 问题: 插件安装失败
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Failed to install plugin: Invalid package format
|
||||
```
|
||||
|
||||
**可能原因:**
|
||||
- `.laapp` 文件损坏
|
||||
- 插件包格式不正确
|
||||
- 权限不足
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **验证插件包:**
|
||||
```bash
|
||||
# .laapp 实际上是 ZIP 文件
|
||||
unzip -t plugin.laapp
|
||||
```
|
||||
|
||||
2. **检查权限:**
|
||||
```bash
|
||||
# 以管理员身份运行 Launcher
|
||||
```
|
||||
|
||||
3. **手动解压安装:**
|
||||
```bash
|
||||
# 解压到插件目录
|
||||
unzip plugin.laapp -d "C:\Program Files\LanMountainDesktop\plugins\{plugin-id}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题: 插件更新失败
|
||||
|
||||
**症状:** 插件升级队列处理失败
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **清理升级队列:**
|
||||
```bash
|
||||
Remove-Item "$env:LOCALAPPDATA\LanMountainDesktop\.launcher\plugin-upgrades\*"
|
||||
```
|
||||
|
||||
2. **手动更新插件:**
|
||||
- 卸载旧版本
|
||||
- 安装新版本
|
||||
|
||||
## 性能问题
|
||||
|
||||
### 问题: CPU 占用过高
|
||||
|
||||
**可能原因:**
|
||||
- 渲染模式不当
|
||||
- 组件更新频率过高
|
||||
- 内存泄漏
|
||||
|
||||
**诊断:**
|
||||
```bash
|
||||
# Windows: 使用任务管理器查看详细信息
|
||||
# Linux: top 或 htop
|
||||
# macOS: Activity Monitor
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **切换渲染模式** (参见"窗口无法显示"部分)
|
||||
|
||||
2. **禁用不必要的组件:**
|
||||
- 减少桌面组件数量
|
||||
- 降低组件更新频率
|
||||
|
||||
3. **检查是否有死循环或资源泄漏**
|
||||
|
||||
---
|
||||
|
||||
### 问题: 内存占用过高
|
||||
|
||||
**诊断:**
|
||||
```bash
|
||||
# 检查内存使用情况
|
||||
# Windows: 任务管理器
|
||||
# Linux: free -h
|
||||
# macOS: Activity Monitor
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **重启应用**
|
||||
|
||||
2. **减少组件数量**
|
||||
|
||||
3. **检查插件是否有内存泄漏**
|
||||
|
||||
4. **更新到最新版本** (可能包含内存优化)
|
||||
|
||||
---
|
||||
|
||||
### 问题: 启动速度慢
|
||||
|
||||
**可能原因:**
|
||||
- 插件过多
|
||||
- 磁盘 I/O 慢
|
||||
- 首次启动需要初始化
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **禁用不必要的插件**
|
||||
|
||||
2. **使用 SSD**
|
||||
|
||||
3. **清理缓存:**
|
||||
```bash
|
||||
Remove-Item "$env:LOCALAPPDATA\LanMountainDesktop\cache\*" -Recurse
|
||||
```
|
||||
|
||||
## 平台特定问题
|
||||
|
||||
### Windows
|
||||
|
||||
#### 问题: WebView2 缺失
|
||||
|
||||
**症状:**
|
||||
```
|
||||
Microsoft Edge WebView2 Runtime is required
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
1. 下载并安装 WebView2 Runtime:
|
||||
https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||
|
||||
2. 或使用安装包自动安装
|
||||
|
||||
---
|
||||
|
||||
#### 问题: 与窗口美化工具冲突
|
||||
|
||||
**症状:** 窗口显示异常、崩溃
|
||||
|
||||
**已知冲突工具:**
|
||||
- Mica For Everyone
|
||||
- TranslucentTB
|
||||
- 其他修改窗口材质的工具
|
||||
|
||||
**解决方案:**
|
||||
将 LanMountainDesktop 添加到这些工具的排除列表中。
|
||||
|
||||
---
|
||||
|
||||
### Linux
|
||||
|
||||
#### 问题: 缺少图形库依赖
|
||||
|
||||
**症状:**
|
||||
```
|
||||
error while loading shared libraries: libXXX.so
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**Debian/Ubuntu:**
|
||||
```bash
|
||||
sudo apt install libx11-6 libice6 libsm6 libfontconfig1
|
||||
```
|
||||
|
||||
**Fedora/RHEL:**
|
||||
```bash
|
||||
sudo dnf install libX11 libICE libSM fontconfig
|
||||
```
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S libx11 libice libsm fontconfig
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 问题: Wayland 兼容性
|
||||
|
||||
**症状:** 在 Wayland 下运行异常
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 强制使用 X11
|
||||
export GDK_BACKEND=x11
|
||||
./LanMountainDesktop.Launcher
|
||||
```
|
||||
|
||||
或通过 XWayland 运行 (不保证所有功能正常)。
|
||||
|
||||
---
|
||||
|
||||
### macOS
|
||||
|
||||
#### 问题: 应用无法打开 - "来自身份不明的开发者"
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 移除隔离属性
|
||||
xattr -cr /Applications/LanMountainDesktop.app
|
||||
```
|
||||
|
||||
或在"系统偏好设置" > "安全性与隐私"中允许。
|
||||
|
||||
---
|
||||
|
||||
#### 问题: 权限问题
|
||||
|
||||
**症状:** 无法访问某些目录或功能
|
||||
|
||||
**解决方案:**
|
||||
在"系统偏好设置" > "安全性与隐私" > "隐私"中授予必要权限:
|
||||
- 文件和文件夹
|
||||
- 辅助功能 (如果需要)
|
||||
|
||||
## 获取帮助
|
||||
|
||||
如果以上方案都无法解决问题:
|
||||
|
||||
1. **查看日志文件** (包含详细错误信息)
|
||||
2. **搜索 GitHub Issues** - 可能已有解决方案
|
||||
3. **提交新 Issue** - 包含:
|
||||
- 操作系统和版本
|
||||
- 应用版本
|
||||
- 详细错误信息
|
||||
- 重现步骤
|
||||
- 日志文件 (如果相关)
|
||||
|
||||
**GitHub Issues:** https://github.com/YourOrg/LanMountainDesktop/issues
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [开发文档](DEVELOPMENT.md)
|
||||
- [Launcher 架构](LAUNCHER.md)
|
||||
- [更新系统](UPDATE_SYSTEM.md)
|
||||
- [构建和部署](BUILD_AND_DEPLOY.md)
|
||||
444
docs/UPDATE_SYSTEM.md
Normal file
444
docs/UPDATE_SYSTEM.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# 更新系统文档
|
||||
|
||||
> LanMountainDesktop 增量更新和版本管理系统
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [更新流程](#更新流程)
|
||||
- [增量更新](#增量更新)
|
||||
- [原子化更新](#原子化更新)
|
||||
- [版本回退](#版本回退)
|
||||
- [CI/CD 集成](#cicd-集成)
|
||||
- [安全机制](#安全机制)
|
||||
|
||||
## 概述
|
||||
|
||||
LanMountainDesktop 使用基于 GitHub Release 的增量更新系统,支持:
|
||||
- ✅ 增量更新 (只下载变更文件)
|
||||
- ✅ 原子化更新 (保证完整性)
|
||||
- ✅ 签名验证 (RSA)
|
||||
- ✅ 版本回退
|
||||
- ✅ 更新频道 (Stable/Preview)
|
||||
- ✅ 静默更新 (后台下载)
|
||||
|
||||
## 更新流程
|
||||
|
||||
### 完整更新流程图
|
||||
|
||||
```
|
||||
Launcher 启动
|
||||
↓
|
||||
UpdateCheckService.CheckForUpdateAsync()
|
||||
├─ 调用 GitHub Release API
|
||||
├─ 根据更新频道过滤版本
|
||||
└─ 对比当前版本和最新版本
|
||||
↓
|
||||
有新版本? ──No→ 继续启动
|
||||
↓ Yes
|
||||
UpdateEngineService.DownloadAsync()
|
||||
├─ 下载 files-{version}.json
|
||||
├─ 下载 files-{version}.json.sig
|
||||
└─ 下载 delta-{old}-to-{new}.zip (或完整包)
|
||||
↓
|
||||
保存到 .launcher/update/incoming/
|
||||
↓
|
||||
下次启动时
|
||||
↓
|
||||
UpdateEngineService.ApplyPendingUpdate()
|
||||
├─ 验证签名
|
||||
├─ 创建 app-{new}/ 目录
|
||||
├─ 标记 .partial
|
||||
├─ 解压增量包
|
||||
├─ 从旧版本复用未变更文件
|
||||
├─ 验证所有文件 SHA256
|
||||
├─ 删除 .partial
|
||||
├─ 添加 .current 到新版本
|
||||
├─ 标记旧版本 .destroy
|
||||
└─ 保存更新快照
|
||||
↓
|
||||
启动新版本
|
||||
↓
|
||||
清理旧版本 (.destroy)
|
||||
```
|
||||
|
||||
### 更新频道
|
||||
|
||||
| 频道 | 说明 | GitHub Release 过滤 |
|
||||
|------|------|---------------------|
|
||||
| **Stable** | 正式版 | `prerelease=false` |
|
||||
| **Preview** | 预览版 | 所有版本 (包括 `prerelease=true`) |
|
||||
|
||||
用户可以在设置中切换更新频道。
|
||||
|
||||
## 增量更新
|
||||
|
||||
### 增量包结构
|
||||
|
||||
**GitHub Release Assets:**
|
||||
```
|
||||
LanMountainDesktop-v1.0.1/
|
||||
├── LanMountainDesktop-Setup-1.0.1-x64.exe # 完整安装包
|
||||
├── app-1.0.1.zip # 完整应用包
|
||||
├── delta-1.0.0-to-1.0.1.zip # 增量包
|
||||
├── files-1.0.1.json # 文件清单
|
||||
└── files-1.0.1.json.sig # RSA 签名
|
||||
```
|
||||
|
||||
### files.json 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"FromVersion": "1.0.0",
|
||||
"ToVersion": "1.0.1",
|
||||
"GeneratedAt": "2025-01-01T00:00:00Z",
|
||||
"Files": [
|
||||
{
|
||||
"Path": "LanMountainDesktop.exe",
|
||||
"Action": "replace",
|
||||
"Sha256": "abc123...",
|
||||
"Size": 1024000,
|
||||
"ArchivePath": "LanMountainDesktop.exe"
|
||||
},
|
||||
{
|
||||
"Path": "LanMountainDesktop.dll",
|
||||
"Action": "reuse",
|
||||
"Sha256": "def456...",
|
||||
"Size": 512000
|
||||
},
|
||||
{
|
||||
"Path": "OldFile.dll",
|
||||
"Action": "delete"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 文件操作类型
|
||||
|
||||
| Action | 说明 | 处理方式 |
|
||||
|--------|------|----------|
|
||||
| `add` | 新增文件 | 从增量包解压 |
|
||||
| `replace` | 替换文件 | 从增量包解压 |
|
||||
| `reuse` | 复用文件 | 从旧版本复制 |
|
||||
| `delete` | 删除文件 | 不操作 (新版本中不存在) |
|
||||
|
||||
### 增量包生成
|
||||
|
||||
使用 `Generate-DeltaPackage.ps1` 脚本:
|
||||
|
||||
```powershell
|
||||
./scripts/Generate-DeltaPackage.ps1 `
|
||||
-PreviousVersion "1.0.0" `
|
||||
-CurrentVersion "1.0.1" `
|
||||
-PreviousDir "./publish/app-1.0.0" `
|
||||
-CurrentDir "./publish/app-1.0.1" `
|
||||
-OutputDir "./delta-output"
|
||||
```
|
||||
|
||||
**生成过程:**
|
||||
1. 扫描两个版本的所有文件
|
||||
2. 计算每个文件的 SHA256
|
||||
3. 对比哈希值,识别变更
|
||||
4. 只打包变更的文件到 `delta.zip`
|
||||
5. 生成 `files.json` 清单
|
||||
|
||||
**优势:**
|
||||
- 大幅减少下载大小 (通常只有 10-30% 的完整包大小)
|
||||
- 加快更新速度
|
||||
- 节省带宽
|
||||
|
||||
## 原子化更新
|
||||
|
||||
### 原子化保证
|
||||
|
||||
更新过程中的任何失败都会触发自动回滚,确保应用始终处于可用状态。
|
||||
|
||||
**关键机制:**
|
||||
1. **`.partial` 标记** - 更新过程中保持此标记
|
||||
2. **旧版本保留** - 直到新版本验证通过
|
||||
3. **SHA256 验证** - 确保所有文件完整性
|
||||
4. **快照记录** - 记录更新前后状态
|
||||
5. **自动回滚** - 失败时恢复到旧版本
|
||||
|
||||
### 更新步骤详解
|
||||
|
||||
```csharp
|
||||
public LauncherResult ApplyPendingUpdate()
|
||||
{
|
||||
// 1. 验证签名
|
||||
var verifyResult = VerifySignature(fileMapPath, signaturePath);
|
||||
if (!verifyResult.Success)
|
||||
return Failed("signature_failed");
|
||||
|
||||
// 2. 创建新版本目录
|
||||
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
|
||||
// 3. 标记为未完成
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
|
||||
// 4. 保存快照
|
||||
var snapshot = new SnapshotMetadata { ... };
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
// 5. 解压增量包
|
||||
ZipFile.ExtractToDirectory(archivePath, extractRoot);
|
||||
|
||||
// 6. 应用文件操作
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
|
||||
}
|
||||
|
||||
// 7. 验证所有文件
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
var actualHash = ComputeSha256Hex(fullPath);
|
||||
if (actualHash != file.Sha256)
|
||||
throw new InvalidOperationException("Hash mismatch");
|
||||
}
|
||||
|
||||
// 8. 激活新版本
|
||||
ActivateDeployment(currentDeployment, targetDeployment);
|
||||
|
||||
// 9. 更新快照状态
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
// 10. 清理
|
||||
CleanupIncomingArtifacts();
|
||||
|
||||
return Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 自动回滚
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
return Failed("apply_failed", ex.Message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 失败回滚
|
||||
|
||||
```csharp
|
||||
private void TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 删除未完成的新版本目录
|
||||
if (Directory.Exists(snapshot.TargetDirectory))
|
||||
Directory.Delete(snapshot.TargetDirectory, true);
|
||||
|
||||
// 2. 移除旧版本的 .destroy 标记
|
||||
var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy");
|
||||
if (File.Exists(destroyMarker))
|
||||
File.Delete(destroyMarker);
|
||||
|
||||
// 3. 确保旧版本有 .current 标记
|
||||
var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current");
|
||||
if (!File.Exists(currentMarker))
|
||||
File.WriteAllText(currentMarker, string.Empty);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 记录错误但不抛出
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 版本回退
|
||||
|
||||
### 手动回退
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
```
|
||||
|
||||
### 回退流程
|
||||
|
||||
```csharp
|
||||
public LauncherResult RollbackLatest()
|
||||
{
|
||||
// 1. 读取最新快照
|
||||
var snapshotPath = Directory
|
||||
.EnumerateFiles(_snapshotsRoot, "*.json")
|
||||
.OrderByDescending(File.GetCreationTimeUtc)
|
||||
.FirstOrDefault();
|
||||
|
||||
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(
|
||||
File.ReadAllText(snapshotPath));
|
||||
|
||||
// 2. 获取当前部署
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
|
||||
// 3. 激活旧版本
|
||||
ActivateDeployment(currentDeployment, snapshot.SourceDirectory);
|
||||
|
||||
// 4. 更新快照状态
|
||||
snapshot.Status = "manual_rollback";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
return Success($"Rolled back to {snapshot.SourceVersion}");
|
||||
}
|
||||
```
|
||||
|
||||
### 快照格式
|
||||
|
||||
```json
|
||||
{
|
||||
"SnapshotId": "abc123...",
|
||||
"SourceVersion": "1.0.0",
|
||||
"TargetVersion": "1.0.1",
|
||||
"CreatedAt": "2025-01-01T00:00:00Z",
|
||||
"SourceDirectory": "C:\\...\\app-1.0.0",
|
||||
"TargetDirectory": "C:\\...\\app-1.0.1",
|
||||
"Status": "applied"
|
||||
}
|
||||
```
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
### GitHub Actions 工作流
|
||||
|
||||
**release.yml 关键步骤:**
|
||||
|
||||
```yaml
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
# 重组为 app-{version} 结构
|
||||
$appDir = "app-${{ needs.prepare.outputs.version }}"
|
||||
New-Item -ItemType Directory -Path "publish-launcher/windows-x64"
|
||||
Move-Item -Path "publish/windows-x64" -Destination "publish-launcher/windows-x64/$appDir"
|
||||
|
||||
# 移动 Launcher 到根目录
|
||||
Move-Item -Path "publish-launcher/windows-x64/$appDir/Launcher/*" -Destination "publish-launcher/windows-x64/"
|
||||
|
||||
# 创建 .current 标记
|
||||
New-Item -ItemType File -Path "publish-launcher/windows-x64/$appDir/.current"
|
||||
|
||||
- name: Generate Delta Package
|
||||
run: |
|
||||
# 生成 files.json
|
||||
$files = Get-ChildItem -Path $currentAppPath -Recurse -File
|
||||
# ... 计算 SHA256 ...
|
||||
|
||||
# 创建完整应用包
|
||||
Compress-Archive -Path "$currentAppPath\*" -DestinationPath "app-$version.zip"
|
||||
|
||||
- name: Upload Delta Package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: delta-package-windows-x64
|
||||
path: delta-output/*
|
||||
```
|
||||
|
||||
### 增量包生成脚本
|
||||
|
||||
**scripts/Generate-DeltaPackage.ps1:**
|
||||
- 对比两个版本目录
|
||||
- 识别新增、修改、删除的文件
|
||||
- 只打包变更文件
|
||||
- 生成 `files.json` 清单
|
||||
|
||||
**scripts/Sign-FileMap.ps1:**
|
||||
- 使用 RSA 私钥签名 `files.json`
|
||||
- 生成 `files.json.sig`
|
||||
|
||||
## 安全机制
|
||||
|
||||
### RSA 签名验证
|
||||
|
||||
**签名生成 (CI):**
|
||||
```powershell
|
||||
# 读取私钥
|
||||
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
|
||||
# 签名
|
||||
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
|
||||
$signature = $rsa.SignData(
|
||||
$jsonBytes,
|
||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||
)
|
||||
|
||||
# 保存为 Base64
|
||||
$signatureBase64 = [Convert]::ToBase64String($signature)
|
||||
Set-Content -Path "$FilesJsonPath.sig" -Value $signatureBase64
|
||||
```
|
||||
|
||||
**签名验证 (Launcher):**
|
||||
```csharp
|
||||
private (bool Success, string Message) VerifySignature(
|
||||
string fileMapPath,
|
||||
string signaturePath)
|
||||
{
|
||||
// 1. 读取公钥
|
||||
var publicKeyPath = Path.Combine(_launcherRoot, "update", "public-key.pem");
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(publicKeyPath));
|
||||
|
||||
// 2. 读取签名
|
||||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
||||
var signature = Convert.FromBase64String(signatureBase64);
|
||||
|
||||
// 3. 验证
|
||||
var jsonBytes = File.ReadAllBytes(fileMapPath);
|
||||
var isValid = rsa.VerifyData(
|
||||
jsonBytes,
|
||||
signature,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
return isValid
|
||||
? (true, "ok")
|
||||
: (false, "Signature verification failed");
|
||||
}
|
||||
```
|
||||
|
||||
### 文件完整性验证
|
||||
|
||||
```csharp
|
||||
// 验证所有文件的 SHA256
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
if (!NeedsVerification(file))
|
||||
continue;
|
||||
|
||||
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||||
var actualHash = ComputeSha256Hex(fullPath);
|
||||
|
||||
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 路径遍历防护
|
||||
|
||||
```csharp
|
||||
private static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
{
|
||||
var fullTarget = Path.GetFullPath(targetPath);
|
||||
var fullRoot = Path.GetFullPath(rootPath);
|
||||
|
||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Launcher 架构文档](LAUNCHER.md)
|
||||
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
||||
- [故障排除指南](TROUBLESHOOTING.md)
|
||||
@@ -16,7 +16,7 @@
|
||||
| `LanMountainDesktop.DesktopHost/` | 桌面宿主流程 | 生命周期、宿主流程支撑 |
|
||||
| `LanMountainDesktop.DesktopComponents.Runtime/` | 组件运行时 | 组件宿主运行时支撑 |
|
||||
| `LanMountainDesktop.Host.Abstractions/` | 宿主抽象 | 宿主接口与抽象层 |
|
||||
| `LanMountainDesktop.PluginsInstallHelper/` | 插件安装辅助 | 发布输出和插件安装辅助程序 |
|
||||
| `LanMountainDesktop.Launcher/` | 启动器 | 发布输出、OOBE、启动页、更新与插件安装/更新 |
|
||||
| `LanMountainDesktop.PluginTemplate/` | 插件模板 | `dotnet new lmd-plugin` 模板内容 |
|
||||
| `LanMountainDesktop.Tests/` | 测试 | 行为回归、契约验证、基础能力校验 |
|
||||
|
||||
|
||||
184
scripts/Generate-DeltaPackage.ps1
Normal file
184
scripts/Generate-DeltaPackage.ps1
Normal file
@@ -0,0 +1,184 @@
|
||||
# Generate-DeltaPackage.ps1
|
||||
# 生成增量更新包 (delta.zip + files.json)
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$PreviousVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$CurrentVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$PreviousDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$CurrentDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
|
||||
Write-Host "从版本: $PreviousVersion"
|
||||
Write-Host "到版本: $CurrentVersion"
|
||||
Write-Host "上一版本目录: $PreviousDir"
|
||||
Write-Host "当前版本目录: $CurrentDir"
|
||||
Write-Host "输出目录: $OutputDir"
|
||||
Write-Host ""
|
||||
|
||||
# 确保输出目录存在
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
|
||||
# 计算文件 SHA256
|
||||
function Get-FileSha256 {
|
||||
param([string]$Path)
|
||||
$hash = Get-FileHash -Path $Path -Algorithm SHA256
|
||||
return $hash.Hash.ToLower()
|
||||
}
|
||||
|
||||
# 获取目录中所有文件的相对路径和哈希
|
||||
function Get-FileManifest {
|
||||
param([string]$RootDir)
|
||||
|
||||
$manifest = @{}
|
||||
$files = Get-ChildItem -Path $RootDir -Recurse -File
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
|
||||
$relativePath = $relativePath.Replace('\', '/')
|
||||
|
||||
$manifest[$relativePath] = @{
|
||||
Path = $relativePath
|
||||
Sha256 = Get-FileSha256 -Path $file.FullName
|
||||
Size = $file.Length
|
||||
}
|
||||
}
|
||||
|
||||
return $manifest
|
||||
}
|
||||
|
||||
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
|
||||
$previousManifest = Get-FileManifest -RootDir $PreviousDir
|
||||
|
||||
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
|
||||
$currentManifest = Get-FileManifest -RootDir $CurrentDir
|
||||
|
||||
# 分析文件变更
|
||||
$changedFiles = @()
|
||||
$reusedFiles = @()
|
||||
$deletedFiles = @()
|
||||
|
||||
Write-Host "分析文件变更..." -ForegroundColor Yellow
|
||||
|
||||
# 检查新增和修改的文件
|
||||
foreach ($path in $currentManifest.Keys) {
|
||||
$currentFile = $currentManifest[$path]
|
||||
|
||||
if ($previousManifest.ContainsKey($path)) {
|
||||
$previousFile = $previousManifest[$path]
|
||||
|
||||
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
||||
# 文件未变更,可以复用
|
||||
$reusedFiles += @{
|
||||
Path = $path
|
||||
Action = "reuse"
|
||||
Sha256 = $currentFile.Sha256
|
||||
Size = $currentFile.Size
|
||||
}
|
||||
} else {
|
||||
# 文件已修改
|
||||
$changedFiles += @{
|
||||
Path = $path
|
||||
Action = "replace"
|
||||
Sha256 = $currentFile.Sha256
|
||||
Size = $currentFile.Size
|
||||
ArchivePath = $path
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# 新增文件
|
||||
$changedFiles += @{
|
||||
Path = $path
|
||||
Action = "add"
|
||||
Sha256 = $currentFile.Sha256
|
||||
Size = $currentFile.Size
|
||||
ArchivePath = $path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 检查删除的文件
|
||||
foreach ($path in $previousManifest.Keys) {
|
||||
if (-not $currentManifest.ContainsKey($path)) {
|
||||
$deletedFiles += @{
|
||||
Path = $path
|
||||
Action = "delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "变更统计:" -ForegroundColor Green
|
||||
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
|
||||
Write-Host " 复用: $($reusedFiles.Count) 个文件"
|
||||
Write-Host " 删除: $($deletedFiles.Count) 个文件"
|
||||
Write-Host ""
|
||||
|
||||
# 创建临时目录用于打包
|
||||
$tempDir = Join-Path $OutputDir "temp_delta"
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
|
||||
|
||||
# 复制变更的文件到临时目录
|
||||
Write-Host "复制变更文件..." -ForegroundColor Yellow
|
||||
foreach ($file in $changedFiles) {
|
||||
$sourcePath = Join-Path $CurrentDir $file.Path
|
||||
$destPath = Join-Path $tempDir $file.Path
|
||||
$destDir = Split-Path -Parent $destPath
|
||||
|
||||
if (-not (Test-Path $destDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $sourcePath -Destination $destPath -Force
|
||||
}
|
||||
|
||||
# 创建 delta.zip
|
||||
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
|
||||
Write-Host "创建增量包: $deltaZipPath" -ForegroundColor Yellow
|
||||
|
||||
if (Test-Path $deltaZipPath) {
|
||||
Remove-Item -Path $deltaZipPath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $deltaZipPath -CompressionLevel Optimal
|
||||
|
||||
# 清理临时目录
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
|
||||
# 生成 files.json
|
||||
$filesJson = @{
|
||||
FromVersion = $PreviousVersion
|
||||
ToVersion = $CurrentVersion
|
||||
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Files = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||
}
|
||||
|
||||
$filesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
|
||||
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
|
||||
|
||||
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
|
||||
|
||||
# 计算增量包大小
|
||||
$deltaSize = (Get-Item $deltaZipPath).Length
|
||||
$deltaSizeMB = [math]::Round($deltaSize / 1MB, 2)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
||||
Write-Host "增量包大小: $deltaSizeMB MB"
|
||||
Write-Host "输出文件:"
|
||||
Write-Host " - $deltaZipPath"
|
||||
Write-Host " - $filesJsonPath"
|
||||
65
scripts/Sign-FileMap.ps1
Normal file
65
scripts/Sign-FileMap.ps1
Normal file
@@ -0,0 +1,65 @@
|
||||
# Sign-FileMap.ps1
|
||||
# 对 files.json 进行 RSA 签名
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FilesJsonPath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$PrivateKeyPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== 签名文件清单 ===" -ForegroundColor Cyan
|
||||
Write-Host "文件清单: $FilesJsonPath"
|
||||
Write-Host "私钥: $PrivateKeyPath"
|
||||
Write-Host ""
|
||||
|
||||
# 检查文件是否存在
|
||||
if (-not (Test-Path $FilesJsonPath)) {
|
||||
Write-Error "文件清单不存在: $FilesJsonPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PrivateKeyPath)) {
|
||||
Write-Error "私钥文件不存在: $PrivateKeyPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 确定输出路径
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = "$FilesJsonPath.sig"
|
||||
}
|
||||
|
||||
# 读取文件内容
|
||||
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
|
||||
|
||||
# 读取私钥
|
||||
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
|
||||
|
||||
# 使用 .NET 进行 RSA 签名
|
||||
Add-Type -AssemblyName System.Security.Cryptography
|
||||
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
|
||||
# 生成签名
|
||||
$signature = $rsa.SignData(
|
||||
$jsonBytes,
|
||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||
)
|
||||
|
||||
# 转换为 Base64
|
||||
$signatureBase64 = [Convert]::ToBase64String($signature)
|
||||
|
||||
# 写入签名文件
|
||||
Set-Content -Path $OutputPath -Value $signatureBase64 -Encoding ASCII
|
||||
|
||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
||||
Write-Host "签名文件: $OutputPath"
|
||||
Write-Host "签名长度: $($signature.Length) 字节"
|
||||
Reference in New Issue
Block a user