Compare commits

...

4 Commits

30 changed files with 3506 additions and 261 deletions

View File

@@ -32,6 +32,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -66,12 +67,15 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
clang zlib1g-dev \
libportaudio2 libasound2 \
libwebkit2gtk-4.1-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -98,10 +102,14 @@ jobs:
fetch-depth: 0
submodules: recursive
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -132,6 +140,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Pack SDK and template packages
shell: pwsh

View File

@@ -31,6 +31,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}

View File

@@ -88,6 +88,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -219,8 +220,10 @@ jobs:
Move-Item -Path $newStructure -Destination $publishDir -Force
shell: pwsh
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
- name: Install Inno Setup and 7z
run: |
choco install innosetup -y --no-progress
choco install 7zip -y --no-progress
shell: pwsh
- name: Build Installer
@@ -314,18 +317,41 @@ jobs:
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh
- name: Generate Delta Package
- name: Create App Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$version = "${{ needs.prepare.outputs.version }}"
$publishDir = "publish/windows-${{ matrix.arch }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Determine previous version and download its update.zip for diff ---
# 创建 app-{version}-win-{arch}.zip 供后续版本作为旧版本对比
$appZipPath = Join-Path $outputDir "app-$version-win-$arch.zip"
Write-Host "Creating app-$version-win-$arch.zip..."
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal
$sizeMB = [Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)
Write-Host "Created app-$version-win-$arch.zip: $sizeMB MB"
shell: pwsh
- name: Generate Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Determine previous version and download its app package for diff ---
$previousVersion = $null
$previousAppPath = $null
try {
@@ -336,128 +362,73 @@ jobs:
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
Write-Host "Previous release version: $previousVersion"
# Try to download update.zip from previous release for diff
$prevUpdateZip = $previousRelease.assets | Where-Object { $_.name -eq "update.zip" } | Select-Object -First 1
if ($prevUpdateZip) {
Write-Host "Found update.zip in previous release - extracting for diff..."
$prevZipDest = Join-Path $outputDir "prev-update.zip"
Invoke-WebRequest -Uri $prevUpdateZip.browser_download_url -OutFile $prevZipDest -Headers $headers
# 下载旧版本的 app-{version}-win-{arch}.zip
$prevAppZip = $previousRelease.assets | Where-Object { $_.name -eq "app-$previousVersion-win-$arch.zip" } | Select-Object -First 1
if ($prevAppZip) {
Write-Host "Found app-$previousVersion-win-$arch.zip in previous release - downloading for diff..."
$prevAppZipDest = Join-Path $outputDir "prev-app.zip"
Invoke-WebRequest -Uri $prevAppZip.browser_download_url -OutFile $prevAppZipDest -Headers $headers
# 解压 app-{version}.zip
$previousAppPath = Join-Path $outputDir "prev-app"
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
Expand-Archive -Path $prevZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevZipDest -Force
Expand-Archive -Path $prevAppZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevAppZipDest -Force -ErrorAction SilentlyContinue
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
if ($previousAppPath -and (Test-Path $previousAppPath)) {
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
}
} else {
Write-Host "No update.zip found in previous release - will generate full package"
Write-Host "No app-$previousVersion-win-$arch.zip found in previous release - will generate full package"
Write-Host "This is expected for the first release after this fix."
}
}
} catch {
Write-Host "Could not fetch previous release: $_"
}
# --- Generate file manifest with diff against previous version ---
Write-Host "Generating update package for version $version..."
$files = Get-ChildItem -Path $currentAppPath -Recurse -File
$fileEntries = [System.Collections.ArrayList]::new()
$changedFiles = [System.Collections.ArrayList]::new()
$reusedCount = 0
$addedCount = 0
$replacedCount = 0
$deletedCount = 0
# --- Generate delta package using the script ---
if ($previousAppPath -and (Test-Path $previousAppPath) -and $previousVersion) {
Write-Host "Generating delta package from $previousVersion to $version..."
& $scriptPath `
-PreviousVersion $previousVersion `
-CurrentVersion $version `
-PreviousDir $previousAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
# Build hash map of previous version files for quick lookup
$prevHashMap = @{}
if ($previousAppPath -and (Test-Path $previousAppPath)) {
$prevFiles = Get-ChildItem -Path $previousAppPath -Recurse -File
foreach ($pf in $prevFiles) {
$relPath = $pf.FullName.Substring($previousAppPath.Length).TrimStart('\', '/').Replace('\', '/')
if ($relPath -match '^\.(current|partial|destroy)$') { continue }
$prevHashMap[$relPath] = (Get-FileHash -Path $pf.FullName -Algorithm SHA256).Hash.ToLower()
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
Write-Host "Previous version has $($prevHashMap.Count) files for comparison"
}
foreach ($file in $files) {
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
$relativePath = $relativePath.Replace('\', '/')
# Skip deployment marker files
if ($relativePath -match '^\.(current|partial|destroy)$') {
continue
}
$hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower()
if ($prevHashMap.ContainsKey($relativePath)) {
$prevHash = $prevHashMap[$relativePath]
if ($hash -eq $prevHash) {
$fileEntries += @{ Path = $relativePath; Action = "reuse"; Sha256 = $hash }
$reusedCount++
} else {
$fileEntries += @{ Path = $relativePath; Action = "replace"; Sha256 = $hash; ArchivePath = $relativePath }
$changedFiles += $file
$replacedCount++
}
$prevHashMap.Remove($relativePath)
} else {
$fileEntries += @{ Path = $relativePath; Action = "add"; Sha256 = $hash; ArchivePath = $relativePath }
$changedFiles += $file
$addedCount++
}
}
# Files in previous version but not in current = deleted
foreach ($deletedPath in $prevHashMap.Keys) {
$fileEntries += @{ Path = $deletedPath; Action = "delete" }
$deletedCount++
}
Write-Host "Delta summary: $reusedCount reused, $replacedCount replaced, $addedCount added, $deletedCount deleted"
Write-Host "Changed files to include in update.zip: $($changedFiles.Count)"
$filesJson = @{
FromVersion = $previousVersion
ToVersion = $version
Platform = "windows"
Arch = "x64"
Files = $fileEntries
} | ConvertTo-Json -Depth 10
$filesJsonPath = Join-Path $outputDir "files.json"
$filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8
Write-Host "Generated files.json with $($fileEntries.Count) entries"
# Create update.zip with only changed files
$tempDir = Join-Path $outputDir "temp_staging"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
foreach ($file in $changedFiles) {
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
$destPath = Join-Path $tempDir $relativePath
$destDir = Split-Path -Parent $destPath
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
Copy-Item -Path $file.FullName -Destination $destPath -Force
}
$updateZipPath = Join-Path $outputDir "update.zip"
if ($changedFiles.Count -gt 0) {
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
} else {
# No changed files - create a minimal zip
$emptyMarker = Join-Path $tempDir ".no-changes"
Set-Content -Path $emptyMarker -Value ""
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
}
Remove-Item -Path $tempDir -Recurse -Force
Write-Host "No previous version available - generating full package..."
# Generate a "full" delta package (all files as "add")
& $scriptPath `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
Write-Host "Created update.zip: $([Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)) MB"
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
}
# Clean up previous version extraction
if ($previousAppPath -and (Test-Path $previousAppPath)) {
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Display results
$updateZipPath = Join-Path $outputDir "update.zip"
if (Test-Path $updateZipPath) {
$sizeMB = [Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)
Write-Host "Created update.zip: $sizeMB MB"
}
shell: pwsh
- name: Sign File Map
@@ -512,6 +483,7 @@ jobs:
delta-output/files.json
delta-output/files.json.sig
delta-output/update.zip
delta-output/app-*.zip
if-no-files-found: error
retention-days: 90
@@ -544,12 +516,15 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
clang zlib1g-dev \
libportaudio2 libasound2 \
libwebkit2gtk-4.1-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -736,10 +711,14 @@ jobs:
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -912,6 +891,8 @@ jobs:
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
# Copy delta update files (files.json, files.json.sig, update.zip)
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
# Copy app package for future delta generation (app-{version}-win-{arch}.zip)
find artifacts -type f -name "app-*.zip" -exec cp -v {} release-files/ \;
echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"

View File

@@ -0,0 +1,23 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -56,7 +56,11 @@
<!-- 允许 IL 警告 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- FluentAvaloniaUI 需要启用反射序列化AOT 兼容模式) -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -149,10 +149,7 @@ internal static class Commands
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
});
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}

View File

@@ -322,7 +322,7 @@ internal sealed class DeploymentLocator
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json);
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{
if (Directory.Exists(snapshot.SourceDirectory))
@@ -445,7 +445,7 @@ internal sealed class DeploymentLocator
try
{
var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize<AppVersionInfo>(json);
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
if (info is not null)
{
return info;

View File

@@ -159,7 +159,7 @@ namespace LanMountainDesktop.Launcher.Services;
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json);
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
@@ -617,13 +617,13 @@ namespace LanMountainDesktop.Launcher.Services;
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
/// <summary>
/// 发现配置文件
/// </summary>
private class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}
}
/// <summary>
/// 发现配置文件
/// </summary>
internal class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}

View File

@@ -143,7 +143,7 @@ public class LauncherIpcServer : IDisposable
// 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
if (message is not null)
{
_onProgress(message);

View File

@@ -9,6 +9,15 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator
{
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
@@ -41,6 +50,18 @@ internal sealed class LauncherFlowCoordinator
// 清理旧版本保留至少3个版本
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
// 检测老版本安装(首次运行时)
if (_oobeStateService.IsFirstRun())
{
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
if (legacyInfo != null)
{
var migrationResult = await ShowMigrationPromptAsync(legacyInfo);
// 无论用户选择什么,都继续启动流程
Console.WriteLine($"[LauncherFlowCoordinator] Migration prompt result: {migrationResult}");
}
}
// 使用传入的 Splash 窗口或创建新的
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
{
@@ -51,9 +72,23 @@ internal sealed class LauncherFlowCoordinator
var reporter = (ISplashStageReporter)splashWindow;
// 创建加载详情窗口(可选,用于显示详细加载状态)
LoadingDetailsWindow? loadingDetailsWindow = null;
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
loadingDetailsWindow = new LoadingDetailsWindow();
loadingDetailsWindow.Show();
});
}
// 跟踪主程序是否已就绪,就绪后自动关闭 Splash 窗口
var hostReadyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// 加载状态管理
var loadingState = new LoadingStateMessage();
// 启动 IPC 服务端监听主程序进度
using var ipcServer = new LauncherIpcServer(msg =>
{
@@ -61,12 +96,29 @@ internal sealed class LauncherFlowCoordinator
{
try
{
// 更新加载状态
loadingState = loadingState with
{
Stage = msg.Stage,
OverallProgressPercent = msg.ProgressPercent,
Message = msg.Message,
Timestamp = DateTimeOffset.UtcNow
};
// 报告到 Splash 窗口
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
// 主程序报告就绪后,关闭 Splash 窗口
if (msg.Stage == StartupStage.Ready && splashWindow.IsVisible && splashWindow.IsLoaded)
// 更新加载详情窗口
loadingDetailsWindow?.UpdateLoadingState(loadingState);
// 主程序报告就绪后,关闭 Splash 窗口和加载详情窗口
if (msg.Stage == StartupStage.Ready)
{
splashWindow.Close();
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
loadingDetailsWindow?.Close();
hostReadyTcs.TrySetResult();
}
}
@@ -121,20 +173,52 @@ internal sealed class LauncherFlowCoordinator
// 维持 IPC 管道服务端供主程序报告启动进度。
if (hostProcess is not null)
{
// 等待主程序就绪或进程退出(取先发生者)
// 如果主程序在 60 秒内未报告 Ready也关闭 Splash 窗口作为超时保护
var readyOrTimeout = Task.WhenAny(
hostReadyTcs.Task,
Task.Delay(TimeSpan.FromSeconds(60)));
var processExitTask = hostProcess.WaitForExitAsync();
// 等待就绪/超时,然后等待进程退出
await readyOrTimeout;
// 等待主程序就绪或进程退出(取先发生者)
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
var readyOrTimeoutOrExit = Task.WhenAny(
hostReadyTcs.Task,
processExitTask,
Task.Delay(TimeSpan.FromSeconds(30)));
var completedTask = await readyOrTimeoutOrExit;
// 检查是否是进程先退出(异常情况)
if (completedTask == processExitTask)
{
var exitCode = hostProcess.ExitCode;
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited unexpectedly with code: {exitCode}");
// 关闭 Splash 窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
}
});
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "host_crashed",
Message = $"主程序异常退出,退出代码: {exitCode}"
};
}
// 如果 Splash 窗口仍然打开(超时情况),关闭它
if (splashWindow.IsVisible)
{
Console.WriteLine("[LauncherFlowCoordinator] Timeout waiting for Ready signal, closing splash window...");
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
@@ -151,7 +235,11 @@ internal sealed class LauncherFlowCoordinator
});
}
await processExitTask;
// 继续等待主程序进程退出(如果它还在运行)
if (!hostProcess.HasExited)
{
await processExitTask;
}
}
else
{
@@ -236,32 +324,55 @@ internal sealed class LauncherFlowCoordinator
EnsureExecutable(hostPath);
}
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
var versionInfo = _deploymentLocator.GetVersionInfo();
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0)
// 避免子进程窗口创建成功但不可见的问题。
var arguments = new System.Text.StringBuilder();
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid
foreach (var arg in _context.RawArgs)
{
if (arg == _context.Command || arg == _context.SubCommand)
continue;
if (arg.StartsWith("--"))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0) key = key[..equalsIndex];
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
continue;
}
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append(QuoteArgument(arg));
}
// 通过命令行参数传递 IPC 连接信息UseShellExecute=true 时不支持 EnvironmentVariables
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
UseShellExecute = true,
WorkingDirectory = hostWorkingDir,
Arguments = arguments.ToString()
};
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项
foreach (var arg in _context.RawArgs)
{
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
{
continue;
}
processStartInfo.ArgumentList.Add(arg);
}
// 传递环境变量供 IPC 使用
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot();
// 传递版本信息
var versionInfo = _deploymentLocator.GetVersionInfo();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
@@ -341,6 +452,99 @@ internal sealed class LauncherFlowCoordinator
return (result, customPath);
}
/// <summary>
/// 显示迁移提示窗口
/// </summary>
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
{
MigrationPromptWindow? migrationWindow = null;
// 在 UI 线程创建并显示迁移提示窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
migrationWindow = new MigrationPromptWindow();
migrationWindow.SetLegacyInfo(legacyInfo);
migrationWindow.Show();
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow shown");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show MigrationPromptWindow: {ex.Message}");
}
});
if (migrationWindow is null)
{
Console.Error.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow is null, skipping migration prompt");
return MigrationResult.Skipped;
}
// 等待用户选择
MigrationResult result;
try
{
result = await migrationWindow.WaitForChoiceAsync();
Console.WriteLine($"[LauncherFlowCoordinator] MigrationPromptWindow result: {result}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for migration choice: {ex.Message}");
result = MigrationResult.Skipped;
}
// 安全关闭窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
{
migrationWindow.Close();
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow closed successfully");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing MigrationPromptWindow: {ex.Message}");
}
});
return result;
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())

View File

@@ -0,0 +1,341 @@
using System.Diagnostics;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
/// </summary>
internal sealed class LegacyVersionDetector
{
private const string LegacyAppName = "LanMountainDesktop";
private const string LegacyExeName = "LanMountainDesktop.exe";
/// <summary>
/// 检测是否存在老版本安装
/// </summary>
public static LegacyVersionInfo? DetectLegacyInstallation()
{
// 1. 检查注册表(安装版)
var registryInfo = DetectFromRegistry();
if (registryInfo != null)
{
return registryInfo;
}
// 2. 检查常见安装目录
var commonPaths = DetectFromCommonPaths();
if (commonPaths != null)
{
return commonPaths;
}
// 3. 检查便携版位置
var portableInfo = DetectPortableInstallation();
if (portableInfo != null)
{
return portableInfo;
}
return null;
}
/// <summary>
/// 从注册表检测安装信息
/// </summary>
private static LegacyVersionInfo? DetectFromRegistry()
{
try
{
// 检查 HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall
using var key = Registry.LocalMachine.OpenSubKey(
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
if (key != null)
{
var installLocation = key.GetValue("InstallLocation") as string;
var displayVersion = key.GetValue("DisplayVersion") as string;
var uninstallString = key.GetValue("UninstallString") as string;
if (!string.IsNullOrWhiteSpace(installLocation) &&
File.Exists(Path.Combine(installLocation, LegacyExeName)))
{
return new LegacyVersionInfo
{
Version = displayVersion ?? "0.8.x",
InstallPath = installLocation,
UninstallCommand = uninstallString,
InstallType = LegacyInstallType.Registry
};
}
}
// 检查 HKCU用户级安装
using var userKey = Registry.CurrentUser.OpenSubKey(
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
if (userKey != null)
{
var installLocation = userKey.GetValue("InstallLocation") as string;
var displayVersion = userKey.GetValue("DisplayVersion") as string;
var uninstallString = userKey.GetValue("UninstallString") as string;
if (!string.IsNullOrWhiteSpace(installLocation) &&
File.Exists(Path.Combine(installLocation, LegacyExeName)))
{
return new LegacyVersionInfo
{
Version = displayVersion ?? "0.8.x",
InstallPath = installLocation,
UninstallCommand = uninstallString,
InstallType = LegacyInstallType.Registry
};
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Registry detection failed: {ex.Message}");
}
return null;
}
/// <summary>
/// 从常见安装路径检测
/// </summary>
private static LegacyVersionInfo? DetectFromCommonPaths()
{
var commonPaths = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), LegacyAppName),
};
foreach (var path in commonPaths)
{
try
{
if (Directory.Exists(path))
{
// 检查是否存在老版本的特征文件(没有 app-* 目录)
var exePath = Path.Combine(path, LegacyExeName);
var hasAppDirs = Directory.GetDirectories(path, "app-*").Length > 0;
if (File.Exists(exePath) && !hasAppDirs)
{
// 尝试读取版本信息
var version = TryGetFileVersion(exePath);
return new LegacyVersionInfo
{
Version = version ?? "0.8.x",
InstallPath = path,
UninstallCommand = FindUninstaller(path),
InstallType = LegacyInstallType.CommonPath
};
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Path detection failed for {path}: {ex.Message}");
}
}
return null;
}
/// <summary>
/// 检测便携版安装
/// </summary>
private static LegacyVersionInfo? DetectPortableInstallation()
{
try
{
// 检查启动器所在目录的父目录(便携版常见布局)
var launcherDir = AppContext.BaseDirectory;
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
var exePath = Path.Combine(parentDir, LegacyExeName);
var hasAppDirs = Directory.GetDirectories(parentDir, "app-*").Length > 0;
// 如果存在 exe 且没有 app-* 目录,可能是老版本
if (File.Exists(exePath) && !hasAppDirs)
{
var version = TryGetFileVersion(exePath);
// 检查是否真的是老版本(通过文件版本或特定标记)
if (IsLegacyVersion(version))
{
return new LegacyVersionInfo
{
Version = version ?? "0.8.x",
InstallPath = parentDir,
UninstallCommand = null, // 便携版没有卸载程序
InstallType = LegacyInstallType.Portable
};
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Portable detection failed: {ex.Message}");
}
return null;
}
/// <summary>
/// 查找卸载程序
/// </summary>
private static string? FindUninstaller(string installPath)
{
try
{
// 常见的卸载程序命名
var uninstallerNames = new[] { "unins000.exe", "uninstall.exe", "Uninstall.exe" };
foreach (var name in uninstallerNames)
{
var path = Path.Combine(installPath, name);
if (File.Exists(path))
{
return path;
}
}
}
catch { }
return null;
}
/// <summary>
/// 获取文件版本
/// </summary>
private static string? TryGetFileVersion(string filePath)
{
try
{
var versionInfo = FileVersionInfo.GetVersionInfo(filePath);
return versionInfo.FileVersion;
}
catch
{
return null;
}
}
/// <summary>
/// 判断是否为老版本(版本号 < 1.0.0
/// </summary>
private static bool IsLegacyVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return true; // 无法确定版本时,保守认为是老版本
}
if (Version.TryParse(version.Split(' ')[0], out var v))
{
return v.Major < 1;
}
return true;
}
/// <summary>
/// 打开卸载界面
/// </summary>
public static void OpenUninstallInterface(LegacyVersionInfo info)
{
try
{
if (!string.IsNullOrWhiteSpace(info.UninstallCommand))
{
// 有卸载命令,直接执行
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
var fileName = parts[0].Trim('"');
var arguments = parts.Length > 1 ? parts[1] : "";
Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = true,
Verb = "runas" // 请求管理员权限
});
}
else
{
// 没有卸载命令,打开系统卸载面板
Process.Start(new ProcessStartInfo
{
FileName = "appwiz.cpl",
UseShellExecute = true
});
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to open uninstall: {ex.Message}");
// 兜底:打开系统卸载面板
try
{
Process.Start(new ProcessStartInfo
{
FileName = "appwiz.cpl",
UseShellExecute = true
});
}
catch { }
}
}
/// <summary>
/// 在资源管理器中显示老版本位置
/// </summary>
public static void ShowInExplorer(string path)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"/select,\"{path}\"",
UseShellExecute = false
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to show in explorer: {ex.Message}");
}
}
}
/// <summary>
/// 老版本信息
/// </summary>
public class LegacyVersionInfo
{
public string Version { get; set; } = "0.8.x";
public string InstallPath { get; set; } = "";
public string? UninstallCommand { get; set; }
public LegacyInstallType InstallType { get; set; }
}
/// <summary>
/// 老版本安装类型
/// </summary>
public enum LegacyInstallType
{
Registry, // 注册表安装版
CommonPath, // 常见路径安装
Portable // 便携版
}

View File

@@ -73,7 +73,7 @@ internal sealed class PluginInstallerService
using var stream = entries[0].Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
if (manifest == null)
{
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");

View File

@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
@@ -63,10 +63,7 @@ internal sealed class PluginUpgradeQueueService
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
{
WriteIndented = true
}));
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
}
return new LauncherResult
@@ -79,19 +76,19 @@ internal sealed class PluginUpgradeQueueService
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
};
}
}
private sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
internal sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}

View File

@@ -15,7 +15,6 @@ internal sealed class UpdateCheckService
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public UpdateCheckService(string repoOwner, string repoName)
{
@@ -24,12 +23,6 @@ internal sealed class UpdateCheckService
_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>
@@ -97,7 +90,7 @@ internal sealed class UpdateCheckService
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
return releases?.Select(r => new ReleaseInfo
{
@@ -131,38 +124,38 @@ internal sealed class UpdateCheckService
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; }
}
}
// GitHub API 响应模型
internal 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; }
}
internal 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; }
}

View File

@@ -48,7 +48,7 @@ internal sealed class UpdateEngineService
}
var fileMapText = File.ReadAllText(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null)
{
return Failed("update.check", "invalid_manifest", "files.json is invalid.");
@@ -137,7 +137,7 @@ internal sealed class UpdateEngineService
}
var fileMapText = await File.ReadAllTextAsync(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null || fileMap.Files.Count == 0)
{
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
@@ -438,7 +438,7 @@ internal sealed class UpdateEngineService
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(File.ReadAllText(snapshotPath));
var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata);
if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
{
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
@@ -656,10 +656,7 @@ internal sealed class UpdateEngineService
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
{
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions
{
WriteIndented = true
}));
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
}
private static LauncherResult Failed(string stage, string code, string message)

View File

@@ -0,0 +1,250 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
Title="阑山桌面 - 加载详情"
Width="600"
Height="500"
WindowStartupLocation="CenterScreen"
CanResize="True"
MinWidth="500"
MinHeight="400"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
Icon="/Assets/logo.ico">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 标题栏 -->
<Border Grid.Row="0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="20,16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="正在启动阑山桌面"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock x:Name="SubtitleText"
Text="初始化系统组件..."
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<Border Grid.Column="1"
Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="12"
Padding="12,6"
VerticalAlignment="Center">
<TextBlock x:Name="PercentText"
Text="0%"
FontSize="16"
FontWeight="Bold"
Foreground="White"/>
</Border>
</Grid>
</Border>
<!-- 主要内容区域 -->
<Grid Grid.Row="1" Margin="16,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 整体进度条 -->
<ProgressBar x:Name="OverallProgressBar"
Grid.Row="0"
Height="8"
Minimum="0"
Maximum="100"
Value="0"
CornerRadius="4"
Margin="0,0,0,16"/>
<!-- 当前活动项 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="8"
Padding="16,12"
Margin="0,0,0,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<!-- 图标 -->
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
Width="40"
Height="40"
CornerRadius="20"
Background="{DynamicResource AccentFillColorDefaultBrush}"
Margin="0,0,12,0"
VerticalAlignment="Center">
<TextBlock x:Name="CurrentItemIcon"
Text="&#xE768;"
FontSize="20"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 名称 -->
<TextBlock x:Name="CurrentItemName"
Grid.Row="0" Grid.Column="1"
Text="正在初始化..."
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<!-- 描述 -->
<TextBlock x:Name="CurrentItemDescription"
Grid.Row="1" Grid.Column="1"
Text="准备加载系统组件"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0"/>
<!-- 进度 -->
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
<ProgressBar x:Name="CurrentItemProgress"
Height="4"
Minimum="0"
Maximum="100"
Value="0"
CornerRadius="2"/>
</Grid>
</Grid>
</Border>
<!-- 加载项列表 -->
<Border Grid.Row="2"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8">
<Grid RowDefinitions="Auto,*">
<!-- 列表标题 -->
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0"
Text="加载项"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
<TextBlock x:Name="CompletedCountText"
Grid.Column="1"
Text="0"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,0,4,0"/>
<TextBlock Grid.Column="2"
Text="已完成"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
</Grid>
<!-- 列表内容 -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Margin="8,0,8,8">
<ItemsControl x:Name="LoadingItemsList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
Margin="4,3"
Opacity="{Binding Opacity}">
<!-- 状态图标 -->
<TextBlock Grid.Column="0"
Text="{Binding StatusIcon}"
FontSize="14"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{Binding StatusColor}"
Margin="0,0,8,0"
VerticalAlignment="Center"/>
<!-- 名称 -->
<TextBlock Grid.Column="1"
Text="{Binding Name}"
FontSize="13"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<!-- 进度 -->
<TextBlock Grid.Column="2"
Text="{Binding ProgressText}"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="8,0"
VerticalAlignment="Center"/>
<!-- 类型标签 -->
<Border Grid.Column="3"
Background="{Binding TypeBackground}"
CornerRadius="4"
Padding="6,2"
VerticalAlignment="Center">
<TextBlock Text="{Binding TypeLabel}"
FontSize="11"
Foreground="{Binding TypeForeground}"/>
</Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</Grid>
<!-- 错误信息区域 -->
<Border x:Name="ErrorPanel"
Grid.Row="2"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
BorderBrush="{DynamicResource SystemFillColorCriticalBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="12,10"
Margin="16,0,16,12"
IsVisible="False">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="&#xE783;"
FontSize="16"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Margin="0,0,8,0"
VerticalAlignment="Center"/>
<TextBlock x:Name="ErrorText"
Grid.Column="1"
Text="加载过程中出现错误"
FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"/>
</Grid>
</Border>
<!-- 底部按钮 -->
<Border Grid.Row="3"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16,12">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="VersionText"
Grid.Column="0"
Text="v1.0.0"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button x:Name="DetailsButton"
Content="查看详情"
Width="90"
Height="32"
FontSize="13"/>
<Button x:Name="CancelButton"
Content="取消"
Width="90"
Height="32"
FontSize="13"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,396 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 加载详情窗口 - 显示详细的加载状态和进度
/// </summary>
public partial class LoadingDetailsWindow : Window
{
private readonly ObservableCollection<LoadingItemViewModel> _items = new();
private readonly DispatcherTimer _updateTimer;
private DateTimeOffset _startTime;
public LoadingDetailsWindow()
{
AvaloniaXamlLoader.Load(this);
// 初始化列表
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
if (itemsList != null)
{
itemsList.ItemsSource = _items;
}
// 创建更新定时器
_updateTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100)
};
_updateTimer.Tick += OnUpdateTimerTick;
_startTime = DateTimeOffset.UtcNow;
}
/// <summary>
/// 窗口加载完成
/// </summary>
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
_updateTimer.Start();
}
/// <summary>
/// 窗口关闭
/// </summary>
protected override void OnClosing(WindowClosingEventArgs e)
{
_updateTimer.Stop();
base.OnClosing(e);
}
/// <summary>
/// 更新加载状态
/// </summary>
public void UpdateLoadingState(LoadingStateMessage state)
{
Dispatcher.UIThread.Post(() =>
{
try
{
// 更新标题和副标题
UpdateHeader(state);
// 更新整体进度
UpdateOverallProgress(state);
// 更新当前活动项
UpdateCurrentItem(state);
// 更新列表
UpdateItemsList(state);
// 更新错误信息
UpdateErrorPanel(state);
// 更新完成计数
UpdateCompletedCount(state);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LoadingDetailsWindow] Error updating state: {ex.Message}");
}
});
}
/// <summary>
/// 更新标题
/// </summary>
private void UpdateHeader(LoadingStateMessage state)
{
var subtitleText = this.FindControl<TextBlock>("SubtitleText");
if (subtitleText != null)
{
subtitleText.Text = GetStageDescription(state.Stage);
}
}
/// <summary>
/// 更新整体进度
/// </summary>
private void UpdateOverallProgress(LoadingStateMessage state)
{
var progressBar = this.FindControl<ProgressBar>("OverallProgressBar");
var percentText = this.FindControl<TextBlock>("PercentText");
if (progressBar != null)
{
progressBar.Value = state.OverallProgressPercent;
}
if (percentText != null)
{
percentText.Text = $"{state.OverallProgressPercent}%";
}
}
/// <summary>
/// 更新当前活动项
/// </summary>
private void UpdateCurrentItem(LoadingStateMessage state)
{
var currentItem = state.ActiveItems.FirstOrDefault();
if (currentItem == null) return;
var nameText = this.FindControl<TextBlock>("CurrentItemName");
var descText = this.FindControl<TextBlock>("CurrentItemDescription");
var progressBar = this.FindControl<ProgressBar>("CurrentItemProgress");
var iconText = this.FindControl<TextBlock>("CurrentItemIcon");
if (nameText != null)
{
nameText.Text = currentItem.Name;
}
if (descText != null)
{
descText.Text = currentItem.Message ?? GetItemDescription(currentItem);
}
if (progressBar != null)
{
progressBar.Value = currentItem.ProgressPercent;
}
if (iconText != null)
{
iconText.Text = GetItemIcon(currentItem.Type);
}
}
/// <summary>
/// 更新列表
/// </summary>
private void UpdateItemsList(LoadingStateMessage state)
{
// 同步列表项
foreach (var item in state.ActiveItems)
{
var existing = _items.FirstOrDefault(i => i.Id == item.Id);
if (existing != null)
{
existing.UpdateFrom(item);
}
else
{
_items.Add(new LoadingItemViewModel(item));
}
}
// 移除已完成的项保留最近完成的5个
var completedItems = _items.Where(i => i.State == LoadingState.Completed).ToList();
if (completedItems.Count > 5)
{
var itemsToRemove = completedItems.OrderBy(i => i.CompletedTime).Take(completedItems.Count - 5);
foreach (var item in itemsToRemove)
{
_items.Remove(item);
}
}
// 按状态排序:进行中 -> 等待中 -> 已完成 -> 失败
var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
_items.Clear();
foreach (var item in sortedItems)
{
_items.Add(item);
}
}
/// <summary>
/// 更新错误面板
/// </summary>
private void UpdateErrorPanel(LoadingStateMessage state)
{
var errorPanel = this.FindControl<Border>("ErrorPanel");
var errorText = this.FindControl<TextBlock>("ErrorText");
if (errorPanel != null)
{
errorPanel.IsVisible = state.HasErrors;
}
if (errorText != null && state.ErrorMessages?.Any() == true)
{
errorText.Text = string.Join("\n", state.ErrorMessages.Take(3));
}
}
/// <summary>
/// 更新完成计数
/// </summary>
private void UpdateCompletedCount(LoadingStateMessage state)
{
var countText = this.FindControl<TextBlock>("CompletedCountText");
if (countText != null)
{
countText.Text = state.CompletedCount.ToString();
}
}
/// <summary>
/// 定时更新
/// </summary>
private void OnUpdateTimerTick(object? sender, EventArgs e)
{
// 可以在这里添加时间显示等实时更新
}
/// <summary>
/// 获取阶段描述
/// </summary>
private static string GetStageDescription(StartupStage stage) => stage switch
{
StartupStage.Initializing => "正在初始化系统...",
StartupStage.LoadingSettings => "正在加载设置...",
StartupStage.LoadingPlugins => "正在加载插件...",
StartupStage.InitializingUI => "正在初始化界面...",
StartupStage.Ready => "加载完成",
_ => "正在加载..."
};
/// <summary>
/// 获取项描述
/// </summary>
private static string GetItemDescription(LoadingItem item)
{
if (!string.IsNullOrEmpty(item.Description))
return item.Description;
return item.Type switch
{
LoadingItemType.Plugin => "正在加载插件...",
LoadingItemType.Component => "正在加载组件...",
LoadingItemType.Resource => "正在加载资源...",
LoadingItemType.Data => "正在加载数据...",
LoadingItemType.Network => "正在下载...",
_ => "正在处理..."
};
}
/// <summary>
/// 获取项图标
/// </summary>
private static string GetItemIcon(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => "\uE768",
LoadingItemType.Component => "\uE7C4",
LoadingItemType.Resource => "\uE7C5",
LoadingItemType.Data => "\uE7C6",
LoadingItemType.Network => "\uE774",
LoadingItemType.Settings => "\uE713",
LoadingItemType.System => "\uE7C7",
_ => "\uE768"
};
/// <summary>
/// 获取状态优先级
/// </summary>
private static int GetStatePriority(LoadingState state) => state switch
{
LoadingState.InProgress => 0,
LoadingState.Pending => 1,
LoadingState.Completed => 2,
LoadingState.Failed => 3,
LoadingState.Timeout => 4,
LoadingState.Cancelled => 5,
_ => 6
};
}
/// <summary>
/// 加载项视图模型
/// </summary>
public class LoadingItemViewModel : INotifyPropertyChanged
{
public string Id { get; }
public string Name { get; private set; }
public LoadingItemType Type { get; private set; }
public LoadingState State { get; private set; }
public int ProgressPercent { get; private set; }
public DateTimeOffset? CompletedTime { get; private set; }
public string StatusIcon => GetStatusIcon(State);
public IBrush StatusColor => GetStatusColor(State);
public string ProgressText => State == LoadingState.Completed ? "完成" : $"{ProgressPercent}%";
public string TypeLabel => GetTypeLabel(Type);
public IBrush TypeBackground => GetTypeBackground(Type);
public IBrush TypeForeground => GetTypeForeground(Type);
public double Opacity => State == LoadingState.Completed ? 0.6 : 1.0;
public event PropertyChangedEventHandler? PropertyChanged;
public LoadingItemViewModel(LoadingItem item)
{
Id = item.Id;
UpdateFrom(item);
}
public void UpdateFrom(LoadingItem item)
{
Name = item.Name;
Type = item.Type;
State = item.State;
ProgressPercent = item.ProgressPercent;
if (State == LoadingState.Completed && !CompletedTime.HasValue)
{
CompletedTime = DateTimeOffset.UtcNow;
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
}
private static string GetStatusIcon(LoadingState state) => state switch
{
LoadingState.Pending => "\uE7C3",
LoadingState.InProgress => "\uE768",
LoadingState.Completed => "\uE73E",
LoadingState.Failed => "\uE783",
LoadingState.Timeout => "\uE71A",
LoadingState.Cancelled => "\uE711",
_ => "\uE7C3"
};
private static IBrush GetStatusColor(LoadingState state) => state switch
{
LoadingState.Pending => new SolidColorBrush(Colors.Gray),
LoadingState.InProgress => new SolidColorBrush(Colors.DodgerBlue),
LoadingState.Completed => new SolidColorBrush(Colors.Green),
LoadingState.Failed => new SolidColorBrush(Colors.Red),
LoadingState.Timeout => new SolidColorBrush(Colors.Orange),
LoadingState.Cancelled => new SolidColorBrush(Colors.Gray),
_ => new SolidColorBrush(Colors.Gray)
};
private static string GetTypeLabel(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => "插件",
LoadingItemType.Component => "组件",
LoadingItemType.Resource => "资源",
LoadingItemType.Data => "数据",
LoadingItemType.Network => "网络",
LoadingItemType.Settings => "设置",
LoadingItemType.System => "系统",
_ => "其他"
};
private static IBrush GetTypeBackground(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#E3F2FD")),
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#F3E5F5")),
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#E8F5E9")),
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#FFF3E0")),
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#E0F7FA")),
_ => new SolidColorBrush(Color.Parse("#F5F5F5"))
};
private static IBrush GetTypeForeground(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#1976D2")),
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#7B1FA2")),
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#388E3C")),
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#F57C00")),
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#0097A7")),
_ => new SolidColorBrush(Color.Parse("#616161"))
};
}

View File

@@ -0,0 +1,149 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="360"
x:Class="LanMountainDesktop.Launcher.Views.MigrationPromptWindow"
x:DataType="views:MigrationPromptWindow"
Title="阑山桌面 - 版本迁移"
Width="520"
Height="360"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:MigrationPromptWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto">
<!-- 主内容区域 -->
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
<!-- 左侧:信息图标 -->
<Border Grid.Column="0"
Width="48"
Height="48"
Margin="0,4,16,0"
Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
CornerRadius="24"
VerticalAlignment="Top">
<TextBlock Text="&#xE7BA;"
FontSize="24"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCautionBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 右侧:内容 -->
<StackPanel Grid.Column="1" Spacing="12">
<!-- 标题 -->
<TextBlock Text="检测到旧版本"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"/>
<!-- 说明文字 -->
<TextBlock x:Name="DescriptionText"
Text="检测到您的系统中安装了旧版本的阑山桌面0.8.4)。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。"
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="20"/>
<!-- 老版本信息卡片 -->
<Border Margin="0,8,0,0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8"
Padding="16,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<!-- 版本号 -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="版本:"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
<TextBlock x:Name="VersionText"
Grid.Row="0" Grid.Column="1"
Text="0.8.4"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="8,0,0,0"/>
<!-- 安装路径 -->
<TextBlock Grid.Row="1" Grid.Column="0"
Text="位置:"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0"/>
<TextBlock x:Name="PathText"
Grid.Row="1" Grid.Column="1"
Text="C:\Program Files\LanMountainDesktop"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextTrimming="CharacterEllipsis"
Margin="8,4,0,0"/>
<!-- 安装类型 -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="类型:"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0"/>
<TextBlock x:Name="TypeText"
Grid.Row="2" Grid.Column="1"
Text="安装版"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="8,4,0,0"/>
</Grid>
</Border>
<!-- 提示信息 -->
<TextBlock Text="卸载旧版本不会影响新版本的使用,您的个人数据将保留。"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
TextWrapping="Wrap"
Margin="0,4,0,0"/>
</StackPanel>
</Grid>
<!-- 底部:按钮区域 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="24,16">
<Grid ColumnDefinitions="*,Auto">
<!-- 左侧:查看位置按钮 -->
<Button x:Name="ShowLocationButton"
Grid.Column="0"
Content="查看位置"
Width="100"
Height="32"
FontSize="13"
HorizontalAlignment="Left"/>
<!-- 右侧:操作按钮 -->
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="SkipButton"
Content="暂不处理"
Width="100"
Height="32"
FontSize="13"/>
<Button x:Name="UninstallButton"
Content="卸载旧版本"
Width="100"
Height="32"
FontSize="13"
Theme="{DynamicResource AccentButtonTheme}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,157 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 迁移提示窗口 - 提示用户卸载旧版本
/// </summary>
public partial class MigrationPromptWindow : Window
{
private readonly TaskCompletionSource<MigrationResult> _completionSource = new();
private LegacyVersionInfo? _legacyInfo;
public MigrationPromptWindow()
{
AvaloniaXamlLoader.Load(this);
InitializeEventHandlers();
}
/// <summary>
/// 设置老版本信息
/// </summary>
public void SetLegacyInfo(LegacyVersionInfo info)
{
_legacyInfo = info;
// 更新 UI
var versionText = this.FindControl<TextBlock>("VersionText");
var pathText = this.FindControl<TextBlock>("PathText");
var typeText = this.FindControl<TextBlock>("TypeText");
var descriptionText = this.FindControl<TextBlock>("DescriptionText");
if (versionText != null)
{
versionText.Text = info.Version;
}
if (pathText != null)
{
pathText.Text = info.InstallPath;
}
if (typeText != null)
{
typeText.Text = info.InstallType switch
{
LegacyInstallType.Registry => "安装版",
LegacyInstallType.Portable => "便携版",
_ => "未知"
};
}
if (descriptionText != null)
{
descriptionText.Text = $"检测到您的系统中安装了旧版本的阑山桌面({info.Version})。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。";
}
}
/// <summary>
/// 初始化事件处理程序
/// </summary>
private void InitializeEventHandlers()
{
var showLocationButton = this.FindControl<Button>("ShowLocationButton");
var skipButton = this.FindControl<Button>("SkipButton");
var uninstallButton = this.FindControl<Button>("UninstallButton");
if (showLocationButton != null)
{
showLocationButton.Click += OnShowLocationClick;
}
if (skipButton != null)
{
skipButton.Click += OnSkipClick;
}
if (uninstallButton != null)
{
uninstallButton.Click += OnUninstallClick;
}
}
/// <summary>
/// 查看位置按钮点击
/// </summary>
private void OnShowLocationClick(object? sender, RoutedEventArgs e)
{
if (_legacyInfo != null)
{
LegacyVersionDetector.ShowInExplorer(_legacyInfo.InstallPath);
}
}
/// <summary>
/// 跳过按钮点击
/// </summary>
private void OnSkipClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(MigrationResult.Skipped);
Close();
}
/// <summary>
/// 卸载按钮点击
/// </summary>
private void OnUninstallClick(object? sender, RoutedEventArgs e)
{
if (_legacyInfo != null)
{
LegacyVersionDetector.OpenUninstallInterface(_legacyInfo);
}
_completionSource.TrySetResult(MigrationResult.UninstallOpened);
Close();
}
/// <summary>
/// 等待用户选择
/// </summary>
public Task<MigrationResult> WaitForChoiceAsync()
{
return _completionSource.Task;
}
/// <summary>
/// 窗口关闭事件
/// </summary>
protected override void OnClosing(WindowClosingEventArgs e)
{
// 如果还没有完成,标记为跳过
if (!_completionSource.Task.IsCompleted)
{
_completionSource.TrySetResult(MigrationResult.Skipped);
}
base.OnClosing(e);
}
}
/// <summary>
/// 迁移结果
/// </summary>
public enum MigrationResult
{
/// <summary>
/// 用户选择跳过
/// </summary>
Skipped,
/// <summary>
/// 已打开卸载界面
/// </summary>
UninstallOpened
}

View File

@@ -0,0 +1,231 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// 加载项类型
/// </summary>
public enum LoadingItemType
{
/// <summary>
/// 系统初始化
/// </summary>
System,
/// <summary>
/// 设置加载
/// </summary>
Settings,
/// <summary>
/// 插件
/// </summary>
Plugin,
/// <summary>
/// 组件
/// </summary>
Component,
/// <summary>
/// 资源
/// </summary>
Resource,
/// <summary>
/// 数据
/// </summary>
Data,
/// <summary>
/// 网络请求
/// </summary>
Network,
/// <summary>
/// 其他
/// </summary>
Other
}
/// <summary>
/// 加载状态
/// </summary>
public enum LoadingState
{
/// <summary>
/// 等待中
/// </summary>
Pending,
/// <summary>
/// 进行中
/// </summary>
InProgress,
/// <summary>
/// 已完成
/// </summary>
Completed,
/// <summary>
/// 失败
/// </summary>
Failed,
/// <summary>
/// 已取消
/// </summary>
Cancelled,
/// <summary>
/// 超时
/// </summary>
Timeout
}
/// <summary>
/// 加载项信息
/// </summary>
public record LoadingItem
{
/// <summary>
/// 加载项唯一标识
/// </summary>
public required string Id { get; init; }
/// <summary>
/// 加载项类型
/// </summary>
public LoadingItemType Type { get; init; }
/// <summary>
/// 加载项名称
/// </summary>
public required string Name { get; init; }
/// <summary>
/// 加载项描述
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 当前状态
/// </summary>
public LoadingState State { get; init; }
/// <summary>
/// 进度百分比 (0-100)
/// </summary>
public int ProgressPercent { get; init; }
/// <summary>
/// 状态消息
/// </summary>
public string? Message { get; init; }
/// <summary>
/// 错误信息(当 State 为 Failed 时)
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// 开始时间
/// </summary>
public DateTimeOffset? StartTime { get; init; }
/// <summary>
/// 结束时间
/// </summary>
public DateTimeOffset? EndTime { get; init; }
/// <summary>
/// 预计剩余时间(秒)
/// </summary>
public int? EstimatedRemainingSeconds { get; init; }
/// <summary>
/// 子加载项
/// </summary>
public List<LoadingItem>? Children { get; init; }
/// <summary>
/// 额外数据
/// </summary>
public Dictionary<string, string>? Metadata { get; init; }
/// <summary>
/// 时间戳
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// 加载状态更新消息
/// </summary>
public record LoadingStateMessage
{
/// <summary>
/// 当前启动阶段
/// </summary>
public StartupStage Stage { get; init; }
/// <summary>
/// 整体进度百分比 (0-100)
/// </summary>
public int OverallProgressPercent { get; init; }
/// <summary>
/// 当前活动的加载项
/// </summary>
public List<LoadingItem> ActiveItems { get; init; } = new();
/// <summary>
/// 已完成的加载项数量
/// </summary>
public int CompletedCount { get; init; }
/// <summary>
/// 总加载项数量
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 状态消息
/// </summary>
public string? Message { get; init; }
/// <summary>
/// 是否有错误
/// </summary>
public bool HasErrors { get; init; }
/// <summary>
/// 错误消息列表
/// </summary>
public List<string>? ErrorMessages { get; init; }
/// <summary>
/// 时间戳
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// 详细的加载进度消息(用于实时更新)
/// </summary>
public record DetailedProgressMessage : StartupProgressMessage
{
/// <summary>
/// 当前加载项
/// </summary>
public LoadingItem? CurrentItem { get; init; }
/// <summary>
/// 所有加载项
/// </summary>
public List<LoadingItem>? AllItems { get; init; }
/// <summary>
/// 是否为主要更新
/// </summary>
public bool IsMajorUpdate { get; init; }
}

View File

@@ -20,6 +20,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Services.Loading;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Theme;
@@ -74,6 +75,8 @@ public partial class App : Application
private bool _uiUnhandledExceptionHooked;
private DesktopShellHost? _desktopShellHost;
private LauncherIpcClient? _launcherIpcClient;
private LoadingStateManager? _loadingStateManager;
private LoadingStateReporter? _loadingStateReporter;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
@@ -139,7 +142,7 @@ public partial class App : Application
EnsureNotificationService();
}
public override async void OnFrameworkInitializationCompleted()
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode)
{
@@ -149,12 +152,8 @@ public partial class App : Application
AppLogger.Info("App", "Framework initialization completed.");
// 初始化 Launcher IPC 客户端(如果从 Launcher 启动)
await InitializeLauncherIpcAsync();
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
@@ -163,6 +162,10 @@ public partial class App : Application
}
base.OnFrameworkInitializationCompleted();
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
// 使用 fire-and-forget 模式,不阻塞主流程
_ = InitializeLauncherIpcAsync();
}
private async Task InitializeLauncherIpcAsync()
@@ -178,7 +181,18 @@ public partial class App : Application
if (connected)
{
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
// 初始化加载状态管理器
_loadingStateManager = new LoadingStateManager();
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
_loadingStateReporter.Start();
// 注册系统初始化加载项
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
_loadingStateManager.StartItem("system.init", "已连接启动器");
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
}
}
catch (Exception ex)
@@ -213,6 +227,41 @@ public partial class App : Application
});
}
/// <summary>
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
/// 用于 Ready 等关键状态报告
/// </summary>
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
{
if (_launcherIpcClient is null)
return;
try
{
_ = Task.Run(async () =>
{
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = stage,
ProgressPercent = percent,
Message = message
});
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
}
});
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
}
}
private void ApplyDesignTimeTheme()
{
RequestedThemeVariant = ThemeVariant.Light;
@@ -927,10 +976,57 @@ public partial class App : Application
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
LogBrowserStartupDiagnostics();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
ReportStartupProgress(StartupStage.Ready, 100, "就绪");
// 延迟报告 Ready 直到窗口实际打开并可见
// 使用 Opened 事件确保所有资源已加载完毕
mainWindow.Opened += OnMainWindowOpened;
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(10));
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
{
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Ready,
ProgressPercent = 100,
Message = "就绪"
});
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
}
catch { }
}
});
return mainWindow;
}
/// <summary>
/// 主窗口打开完成事件 - 此时所有组件、资源及功能模块均已完全加载
/// </summary>
private void OnMainWindowOpened(object? sender, EventArgs e)
{
if (sender is MainWindow mainWindow)
{
mainWindow.Opened -= OnMainWindowOpened;
AppLogger.Info("App", "Main window opened and ready. Reporting Ready to Launcher...");
// 完成系统初始化加载项
_loadingStateManager?.CompleteItem("system.init", "系统初始化完成");
// 报告 Ready 状态,启动器可以安全关闭 Splash 窗口
ReportStartupProgressSync(StartupStage.Ready, 100, "就绪");
// 停止加载状态上报
_loadingStateReporter?.Stop();
}
}
private MainWindow GetOrCreateMainWindow(
IClassicDesktopStyleApplicationLifetime desktop,
string reason)

View File

@@ -100,12 +100,15 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
var args = new System.Text.StringBuilder();
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -122,13 +125,16 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = dotnetHostPath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串
var args = new System.Text.StringBuilder();
args.Append(QuoteArgument(entryAssemblyPath));
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -145,11 +151,61 @@ public static class AppRestartService
}
}
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
if (builder.Length > 0) builder.Append(' ');
builder.Append(QuoteArgument(commandLineArgs[i]));
}
}
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
{
if (builder.Length > 0) builder.Append(' ');
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;

View File

@@ -17,6 +17,11 @@ public class LauncherIpcClient : IDisposable
private bool _isConnected;
private readonly object _writeLock = new();
/// <summary>
/// 是否已连接到 Launcher
/// </summary>
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// </summary>
@@ -92,11 +97,28 @@ public class LauncherIpcClient : IDisposable
/// <summary>
/// 检查是否从 Launcher 启动
/// 优先检查环境变量回退到命令行参数UseShellExecute=true 时环境变量仍可继承,
/// 命令行参数作为备选确保兼容性)
/// </summary>
public static bool IsLaunchedByLauncher()
{
return !string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
// 优先检查环境变量
if (!string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
{
return true;
}
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>
foreach (var arg in Environment.GetCommandLineArgs())
{
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public void Dispose()

View File

@@ -0,0 +1,380 @@
using System.Collections.Concurrent;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载状态管理器 - 管理所有加载项的状态
/// </summary>
public class LoadingStateManager : IDisposable
{
private readonly ConcurrentDictionary<string, LoadingItem> _items = new();
private readonly ConcurrentDictionary<string, DateTimeOffset> _startTimes = new();
private readonly object _lock = new();
private readonly CancellationTokenSource _cts = new();
/// <summary>
/// 状态变更事件
/// </summary>
public event EventHandler<LoadingStateChangedEventArgs>? StateChanged;
/// <summary>
/// 整体进度变更事件
/// </summary>
public event EventHandler<OverallProgressChangedEventArgs>? OverallProgressChanged;
/// <summary>
/// 当前启动阶段
/// </summary>
public StartupStage CurrentStage { get; private set; } = StartupStage.Initializing;
/// <summary>
/// 整体进度百分比
/// </summary>
public int OverallProgressPercent { get; private set; }
/// <summary>
/// 是否正在加载
/// </summary>
public bool IsLoading => _items.Values.Any(i => i.State == LoadingState.InProgress);
/// <summary>
/// 是否有错误
/// </summary>
public bool HasErrors => _items.Values.Any(i => i.State == LoadingState.Failed);
/// <summary>
/// 获取所有加载项
/// </summary>
public IReadOnlyCollection<LoadingItem> GetAllItems() => _items.Values.ToList();
/// <summary>
/// 获取活动的加载项
/// </summary>
public IReadOnlyCollection<LoadingItem> GetActiveItems() =>
_items.Values.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
/// <summary>
/// 注册加载项
/// </summary>
public LoadingItem RegisterItem(
string id,
LoadingItemType type,
string name,
string? description = null,
Dictionary<string, string>? metadata = null)
{
var item = new LoadingItem
{
Id = id,
Type = type,
Name = name,
Description = description,
State = LoadingState.Pending,
ProgressPercent = 0,
Metadata = metadata,
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = item;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = item,
PreviousState = null,
CurrentState = item.State
});
return item;
}
/// <summary>
/// 开始加载
/// </summary>
public void StartItem(string id, string? message = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var startTime = DateTimeOffset.UtcNow;
_startTimes[id] = startTime;
var updatedItem = item with
{
State = LoadingState.InProgress,
StartTime = startTime,
Message = message ?? $"正在加载 {item.Name}...",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 更新进度
/// </summary>
public void UpdateProgress(string id, int percent, string? message = null, int? estimatedRemainingSeconds = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var updatedItem = item with
{
ProgressPercent = Math.Clamp(percent, 0, 100),
Message = message ?? item.Message,
EstimatedRemainingSeconds = estimatedRemainingSeconds,
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = item.State,
CurrentState = updatedItem.State,
IsProgressUpdate = true
});
UpdateOverallProgress();
}
/// <summary>
/// 完成加载
/// </summary>
public void CompleteItem(string id, string? message = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var endTime = DateTimeOffset.UtcNow;
_startTimes.TryRemove(id, out _);
var updatedItem = item with
{
State = LoadingState.Completed,
ProgressPercent = 100,
EndTime = endTime,
Message = message ?? $"{item.Name} 加载完成",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 标记失败
/// </summary>
public void FailItem(string id, string errorMessage, string? details = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var endTime = DateTimeOffset.UtcNow;
_startTimes.TryRemove(id, out _);
var fullErrorMessage = string.IsNullOrEmpty(details)
? errorMessage
: $"{errorMessage}: {details}";
var updatedItem = item with
{
State = LoadingState.Failed,
ErrorMessage = fullErrorMessage,
EndTime = endTime,
Message = $"{item.Name} 加载失败",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 标记超时
/// </summary>
public void TimeoutItem(string id, string? message = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var endTime = DateTimeOffset.UtcNow;
_startTimes.TryRemove(id, out _);
var updatedItem = item with
{
State = LoadingState.Timeout,
EndTime = endTime,
Message = message ?? $"{item.Name} 加载超时",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 设置当前启动阶段
/// </summary>
public void SetStage(StartupStage stage, string? message = null)
{
CurrentStage = stage;
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
{
Stage = stage,
OverallProgressPercent = OverallProgressPercent,
Message = message
});
}
/// <summary>
/// 更新整体进度
/// </summary>
private void UpdateOverallProgress()
{
lock (_lock)
{
var items = _items.Values.ToList();
if (items.Count == 0)
{
OverallProgressPercent = 0;
return;
}
// 计算加权进度
var totalWeight = items.Count;
var completedWeight = items.Count(i => i.State == LoadingState.Completed);
var inProgressWeight = items
.Where(i => i.State == LoadingState.InProgress)
.Sum(i => i.ProgressPercent / 100.0);
var progress = (int)((completedWeight + inProgressWeight) / totalWeight * 100);
OverallProgressPercent = Math.Clamp(progress, 0, 100);
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
{
Stage = CurrentStage,
OverallProgressPercent = OverallProgressPercent
});
}
}
/// <summary>
/// 获取加载状态消息
/// </summary>
public LoadingStateMessage GetLoadingStateMessage()
{
var items = _items.Values.ToList();
var activeItems = items.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
var errorItems = items.Where(i => i.State == LoadingState.Failed).ToList();
return new LoadingStateMessage
{
Stage = CurrentStage,
OverallProgressPercent = OverallProgressPercent,
ActiveItems = activeItems,
CompletedCount = items.Count(i => i.State == LoadingState.Completed),
TotalCount = items.Count,
HasErrors = errorItems.Any(),
ErrorMessages = errorItems.Select(i => $"{i.Name}: {i.ErrorMessage}").ToList()
};
}
/// <summary>
/// 清理所有加载项
/// </summary>
public void Clear()
{
_items.Clear();
_startTimes.Clear();
OverallProgressPercent = 0;
}
/// <summary>
/// 检查超时项
/// </summary>
public void CheckTimeouts(TimeSpan timeout)
{
var now = DateTimeOffset.UtcNow;
var timeoutItems = _items.Values
.Where(i => i.State == LoadingState.InProgress && i.StartTime.HasValue)
.Where(i => now - i.StartTime.Value > timeout)
.ToList();
foreach (var item in timeoutItems)
{
TimeoutItem(item.Id, $"{item.Name} 加载超时(超过 {timeout.TotalSeconds} 秒)");
}
}
public void Dispose()
{
_cts.Cancel();
_items.Clear();
_startTimes.Clear();
}
}
/// <summary>
/// 加载状态变更事件参数
/// </summary>
public class LoadingStateChangedEventArgs : EventArgs
{
public required LoadingItem Item { get; init; }
public LoadingState? PreviousState { get; init; }
public required LoadingState CurrentState { get; init; }
public bool IsProgressUpdate { get; init; }
}
/// <summary>
/// 整体进度变更事件参数
/// </summary>
public class OverallProgressChangedEventArgs : EventArgs
{
public StartupStage Stage { get; init; }
public int OverallProgressPercent { get; init; }
public string? Message { get; init; }
}

View File

@@ -0,0 +1,360 @@
using System.Timers;
using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载状态上报器 - 将加载状态实时上报给 Launcher
/// </summary>
public class LoadingStateReporter : IDisposable
{
private readonly LoadingStateManager _manager;
private readonly LauncherIpcClient? _ipcClient;
private readonly System.Timers.Timer _reportTimer;
private readonly object _lock = new();
private bool _isDisposed;
/// <summary>
/// 上报间隔(毫秒)
/// </summary>
public int ReportIntervalMs { get; set; } = 100;
/// <summary>
/// 是否启用批量上报优化
/// </summary>
public bool EnableBatching { get; set; } = true;
/// <summary>
/// 最小上报间隔(毫秒),用于限制高频更新
/// </summary>
public int MinReportIntervalMs { get; set; } = 50;
private DateTimeOffset _lastReportTime = DateTimeOffset.MinValue;
private DetailedProgressMessage? _pendingMessage;
private bool _hasPendingMessage;
public LoadingStateReporter(
LoadingStateManager manager,
LauncherIpcClient? ipcClient = null)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_ipcClient = ipcClient;
// 创建定时上报定时器
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
_reportTimer.Elapsed += OnReportTimerElapsed;
_reportTimer.AutoReset = true;
// 订阅状态变更事件
_manager.StateChanged += OnStateChanged;
_manager.OverallProgressChanged += OnOverallProgressChanged;
}
/// <summary>
/// 启动上报
/// </summary>
public void Start()
{
if (_isDisposed) return;
_reportTimer.Start();
AppLogger.Info("LoadingStateReporter", "Loading state reporter started");
}
/// <summary>
/// 停止上报
/// </summary>
public void Stop()
{
_reportTimer.Stop();
// 发送任何待处理的消息
FlushPendingMessage();
AppLogger.Info("LoadingStateReporter", "Loading state reporter stopped");
}
/// <summary>
/// 立即上报当前状态
/// </summary>
public async Task ReportImmediatelyAsync()
{
if (_isDisposed || _ipcClient == null) return;
var message = CreateDetailedProgressMessage();
await SendMessageAsync(message);
}
/// <summary>
/// 上报单个加载项的进度
/// </summary>
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
{
if (_isDisposed || _ipcClient == null) return;
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item == null) return;
var updatedItem = item with
{
ProgressPercent = percent,
Message = message ?? item.Message,
Timestamp = DateTimeOffset.UtcNow
};
var progressMessage = new DetailedProgressMessage
{
Stage = _manager.CurrentStage,
ProgressPercent = _manager.OverallProgressPercent,
CurrentItem = updatedItem,
AllItems = _manager.GetAllItems().ToList(),
Message = message,
IsMajorUpdate = false
};
await SendMessageAsync(progressMessage);
}
/// <summary>
/// 上报阶段变更
/// </summary>
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
{
if (_isDisposed || _ipcClient == null) return;
var progressMessage = new DetailedProgressMessage
{
Stage = stage,
ProgressPercent = _manager.OverallProgressPercent,
AllItems = _manager.GetAllItems().ToList(),
Message = message ?? $"进入阶段: {stage}",
IsMajorUpdate = true
};
await SendMessageAsync(progressMessage);
}
/// <summary>
/// 上报错误
/// </summary>
public async Task ReportErrorAsync(string errorMessage, string? details = null)
{
if (_isDisposed || _ipcClient == null) return;
var fullMessage = string.IsNullOrEmpty(details)
? errorMessage
: $"{errorMessage}: {details}";
var progressMessage = new DetailedProgressMessage
{
Stage = _manager.CurrentStage,
ProgressPercent = _manager.OverallProgressPercent,
AllItems = _manager.GetAllItems().ToList(),
Message = fullMessage,
IsMajorUpdate = true
};
await SendMessageAsync(progressMessage);
}
/// <summary>
/// 状态变更事件处理
/// </summary>
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
{
if (_isDisposed) return;
// 重要状态变更立即上报
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Timeout)
{
_ = Task.Run(async () =>
{
try
{
await ReportImmediatelyAsync();
}
catch (Exception ex)
{
AppLogger.Warn("LoadingStateReporter", $"Failed to report state change: {ex.Message}");
}
});
}
else
{
// 其他状态变更标记为待处理
QueueMessage(CreateDetailedProgressMessage());
}
}
/// <summary>
/// 整体进度变更事件处理
/// </summary>
private void OnOverallProgressChanged(object? sender, OverallProgressChangedEventArgs e)
{
if (_isDisposed) return;
QueueMessage(CreateDetailedProgressMessage(e.Message));
}
/// <summary>
/// 定时上报处理
/// </summary>
private void OnReportTimerElapsed(object? sender, ElapsedEventArgs e)
{
FlushPendingMessage();
}
/// <summary>
/// 将消息加入待处理队列
/// </summary>
private void QueueMessage(DetailedProgressMessage message)
{
if (!EnableBatching)
{
// 如果不启用批量,立即发送
_ = Task.Run(async () => await SendMessageAsync(message));
return;
}
lock (_lock)
{
_pendingMessage = message;
_hasPendingMessage = true;
}
}
/// <summary>
/// 刷新待处理消息
/// </summary>
private void FlushPendingMessage()
{
DetailedProgressMessage? message;
lock (_lock)
{
if (!_hasPendingMessage) return;
message = _pendingMessage;
_pendingMessage = null;
_hasPendingMessage = false;
}
if (message != null)
{
_ = Task.Run(async () =>
{
try
{
await SendMessageAsync(message);
}
catch (Exception ex)
{
AppLogger.Warn("LoadingStateReporter", $"Failed to flush pending message: {ex.Message}");
}
});
}
}
/// <summary>
/// 创建详细的进度消息
/// </summary>
private DetailedProgressMessage CreateDetailedProgressMessage(string? message = null)
{
var activeItems = _manager.GetActiveItems().ToList();
var currentItem = activeItems.FirstOrDefault();
return new DetailedProgressMessage
{
Stage = _manager.CurrentStage,
ProgressPercent = _manager.OverallProgressPercent,
CurrentItem = currentItem,
AllItems = _manager.GetAllItems().ToList(),
Message = message ?? currentItem?.Message,
IsMajorUpdate = false
};
}
/// <summary>
/// 发送消息
/// </summary>
private async Task SendMessageAsync(DetailedProgressMessage message)
{
if (_ipcClient == null) return;
// 检查最小上报间隔
var now = DateTimeOffset.UtcNow;
var elapsed = now - _lastReportTime;
if (elapsed.TotalMilliseconds < MinReportIntervalMs)
{
await Task.Delay(MinReportIntervalMs - (int)elapsed.TotalMilliseconds);
}
try
{
// 转换为 StartupProgressMessage 以保持兼容性
var baseMessage = new StartupProgressMessage
{
Stage = message.Stage,
ProgressPercent = message.ProgressPercent,
Message = FormatMessage(message),
Timestamp = DateTimeOffset.UtcNow
};
await _ipcClient.ReportProgressAsync(baseMessage);
_lastReportTime = DateTimeOffset.UtcNow;
}
catch (Exception ex)
{
AppLogger.Warn("LoadingStateReporter", $"Failed to send message: {ex.Message}");
}
}
/// <summary>
/// 格式化消息
/// </summary>
private string FormatMessage(DetailedProgressMessage message)
{
var parts = new List<string>();
if (message.CurrentItem != null)
{
parts.Add($"[{message.CurrentItem.Type}] {message.CurrentItem.Name}");
if (message.CurrentItem.ProgressPercent > 0)
{
parts.Add($"{message.CurrentItem.ProgressPercent}%");
}
}
if (!string.IsNullOrEmpty(message.Message))
{
parts.Add(message.Message);
}
var completedCount = message.AllItems?.Count(i => i.State == LoadingState.Completed) ?? 0;
var totalCount = message.AllItems?.Count ?? 0;
if (totalCount > 0)
{
parts.Add($"({completedCount}/{totalCount})");
}
return string.Join(" - ", parts);
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
Stop();
_reportTimer.Elapsed -= OnReportTimerElapsed;
_reportTimer.Dispose();
_manager.StateChanged -= OnStateChanged;
_manager.OverallProgressChanged -= OnOverallProgressChanged;
}
}

View File

@@ -0,0 +1,201 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载状态管理使用示例
/// </summary>
public static class LoadingStateUsageExample
{
/// <summary>
/// 示例:插件加载
/// </summary>
public static async Task LoadPluginsExample(LoadingStateManager manager)
{
// 注册插件加载项
var pluginItem = manager.RegisterItem(
"plugins.core",
LoadingItemType.Plugin,
"核心插件",
"加载系统核心插件",
new Dictionary<string, string> { { "version", "1.0.0" } });
// 开始加载
manager.StartItem("plugins.core", "正在下载插件...");
try
{
// 模拟下载进度
for (int i = 0; i <= 100; i += 10)
{
manager.UpdateProgress(
"plugins.core",
i,
$"正在下载... {i}%",
estimatedRemainingSeconds: (100 - i) / 10);
await Task.Delay(100);
}
// 完成加载
manager.CompleteItem("plugins.core", "核心插件加载完成");
}
catch (Exception ex)
{
// 标记失败
manager.FailItem("plugins.core", "插件加载失败", ex.Message);
}
}
/// <summary>
/// 示例:组件加载
/// </summary>
public static async Task LoadComponentsExample(LoadingStateManager manager)
{
var components = new[]
{
("comp.weather", "天气组件"),
("comp.clock", "时钟组件"),
("comp.calendar", "日历组件")
};
foreach (var (id, name) in components)
{
// 注册组件
manager.RegisterItem(id, LoadingItemType.Component, name);
// 开始加载
manager.StartItem(id, $"正在加载 {name}...");
// 模拟加载过程
for (int i = 0; i <= 100; i += 20)
{
manager.UpdateProgress(id, i);
await Task.Delay(50);
}
// 完成
manager.CompleteItem(id, $"{name} 加载完成");
}
}
/// <summary>
/// 示例:网络资源加载
/// </summary>
public static async Task LoadNetworkResourcesExample(LoadingStateManager manager)
{
// 注册网络加载项
manager.RegisterItem(
"network.config",
LoadingItemType.Network,
"配置数据",
"从服务器获取最新配置");
manager.StartItem("network.config", "正在连接服务器...");
try
{
// 模拟网络请求
await Task.Delay(1000);
manager.UpdateProgress("network.config", 50, "正在下载数据...");
await Task.Delay(1000);
manager.CompleteItem("network.config", "配置数据已更新");
}
catch (Exception ex)
{
manager.FailItem("network.config", "网络请求失败", ex.Message);
}
}
/// <summary>
/// 示例:带超时的加载
/// </summary>
public static async Task LoadWithTimeoutExample(
LoadingStateManager manager,
LoadingTimeoutHandler timeoutHandler)
{
// 设置超时时间为 10 秒
timeoutHandler.SetItemTimeout("data.heavy", TimeSpan.FromSeconds(10));
// 注册加载项
manager.RegisterItem(
"data.heavy",
LoadingItemType.Data,
"大数据处理",
"处理大量数据,可能需要较长时间");
// 订阅超时事件
timeoutHandler.ItemTimeout += (s, e) =>
{
Console.WriteLine($"加载项 '{e.ItemName}' 超时!");
};
timeoutHandler.ItemRetry += (s, e) =>
{
Console.WriteLine($"正在重试 '{e.ItemName}' ({e.RetryCount}/{e.MaxRetryCount})...");
};
// 开始加载
manager.StartItem("data.heavy", "正在处理数据...");
// 模拟长时间操作
await Task.Delay(15000);
// 完成
manager.CompleteItem("data.heavy", "数据处理完成");
}
/// <summary>
/// 示例:完整启动流程
/// </summary>
public static async Task FullStartupExample(
LoadingStateManager manager,
LoadingStateReporter reporter,
LoadingTimeoutHandler timeoutHandler)
{
// 启动超时处理器
timeoutHandler.Start();
// 设置阶段
manager.SetStage(StartupStage.Initializing, "开始初始化...");
// 1. 系统初始化
manager.RegisterItem("system.init", LoadingItemType.System, "系统初始化");
manager.StartItem("system.init");
await Task.Delay(500);
manager.CompleteItem("system.init");
// 2. 加载设置
manager.SetStage(StartupStage.LoadingSettings, "正在加载设置...");
manager.RegisterItem("settings.load", LoadingItemType.Settings, "用户设置");
manager.StartItem("settings.load");
await Task.Delay(800);
manager.CompleteItem("settings.load");
// 3. 加载插件
manager.SetStage(StartupStage.LoadingPlugins, "正在加载插件...");
await LoadPluginsExample(manager);
// 4. 加载组件
await LoadComponentsExample(manager);
// 5. 加载网络资源
await LoadNetworkResourcesExample(manager);
// 6. 初始化界面
manager.SetStage(StartupStage.InitializingUI, "正在初始化界面...");
manager.RegisterItem("ui.init", LoadingItemType.System, "界面初始化");
manager.StartItem("ui.init");
await Task.Delay(600);
manager.CompleteItem("ui.init");
// 完成
manager.SetStage(StartupStage.Ready, "加载完成");
// 停止超时处理器
timeoutHandler.Stop();
}
}

View File

@@ -0,0 +1,275 @@
using System.Timers;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载超时处理器 - 监控加载项超时并执行相应处理
/// </summary>
public class LoadingTimeoutHandler : IDisposable
{
private readonly LoadingStateManager _manager;
private readonly System.Timers.Timer _checkTimer;
private readonly Dictionary<string, TimeSpan> _itemTimeouts = new();
private readonly Dictionary<string, int> _retryCounts = new();
private readonly object _lock = new();
private bool _isDisposed;
/// <summary>
/// 默认超时时间
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// 最大重试次数
/// </summary>
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 检查间隔
/// </summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// 超时事件
/// </summary>
public event EventHandler<LoadingTimeoutEventArgs>? ItemTimeout;
/// <summary>
/// 重试事件
/// </summary>
public event EventHandler<LoadingRetryEventArgs>? ItemRetry;
/// <summary>
/// 最终失败事件(超过最大重试次数)
/// </summary>
public event EventHandler<LoadingTimeoutEventArgs>? ItemFailed;
public LoadingTimeoutHandler(LoadingStateManager manager)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_checkTimer = new System.Timers.Timer(CheckInterval.TotalMilliseconds);
_checkTimer.Elapsed += OnCheckTimerElapsed;
_checkTimer.AutoReset = true;
// 订阅状态变更事件
_manager.StateChanged += OnStateChanged;
}
/// <summary>
/// 启动监控
/// </summary>
public void Start()
{
if (_isDisposed) return;
_checkTimer.Start();
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler started");
}
/// <summary>
/// 停止监控
/// </summary>
public void Stop()
{
_checkTimer.Stop();
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler stopped");
}
/// <summary>
/// 为特定加载项设置超时
/// </summary>
public void SetItemTimeout(string itemId, TimeSpan timeout)
{
lock (_lock)
{
_itemTimeouts[itemId] = timeout;
}
}
/// <summary>
/// 获取加载项的超时时间
/// </summary>
public TimeSpan GetItemTimeout(string itemId)
{
lock (_lock)
{
return _itemTimeouts.TryGetValue(itemId, out var timeout) ? timeout : DefaultTimeout;
}
}
/// <summary>
/// 重置重试计数
/// </summary>
public void ResetRetryCount(string itemId)
{
lock (_lock)
{
_retryCounts[itemId] = 0;
}
}
/// <summary>
/// 定时检查超时
/// </summary>
private void OnCheckTimerElapsed(object? sender, ElapsedEventArgs e)
{
if (_isDisposed) return;
try
{
var activeItems = _manager.GetActiveItems().ToList();
var now = DateTimeOffset.UtcNow;
foreach (var item in activeItems)
{
if (!item.StartTime.HasValue) continue;
var timeout = GetItemTimeout(item.Id);
var elapsed = now - item.StartTime.Value;
if (elapsed > timeout)
{
HandleTimeout(item.Id, elapsed);
}
}
}
catch (Exception ex)
{
AppLogger.Warn("LoadingTimeoutHandler", $"Error checking timeouts: {ex.Message}");
}
}
/// <summary>
/// 处理超时
/// </summary>
private void HandleTimeout(string itemId, TimeSpan elapsed)
{
lock (_lock)
{
var retryCount = _retryCounts.GetValueOrDefault(itemId, 0);
if (retryCount < MaxRetryCount)
{
// 重试
_retryCounts[itemId] = retryCount + 1;
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item != null)
{
AppLogger.Warn("LoadingTimeoutHandler",
$"Item '{item.Name}' timed out after {elapsed.TotalSeconds}s, retrying ({retryCount + 1}/{MaxRetryCount})...");
ItemRetry?.Invoke(this, new LoadingRetryEventArgs
{
ItemId = itemId,
ItemName = item.Name,
RetryCount = retryCount + 1,
MaxRetryCount = MaxRetryCount,
ElapsedTime = elapsed
});
// 重新启动该项
_manager.StartItem(itemId, $"第 {retryCount + 1} 次重试...");
}
}
else
{
// 最终失败
_retryCounts.Remove(itemId);
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item != null)
{
AppLogger.Error("LoadingTimeoutHandler",
$"Item '{item.Name}' failed after {MaxRetryCount} retries ({elapsed.TotalSeconds}s)");
var args = new LoadingTimeoutEventArgs
{
ItemId = itemId,
ItemName = item.Name,
ElapsedTime = elapsed,
RetryCount = MaxRetryCount,
IsFinalFailure = true
};
ItemTimeout?.Invoke(this, args);
ItemFailed?.Invoke(this, args);
// 标记为失败
_manager.FailItem(itemId,
$"加载超时(超过 {elapsed.TotalSeconds:F0} 秒)",
$"已重试 {MaxRetryCount} 次但仍失败");
}
}
}
}
/// <summary>
/// 状态变更事件处理
/// </summary>
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
{
// 当项完成或失败时,清除重试计数
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Cancelled)
{
lock (_lock)
{
_retryCounts.Remove(e.Item.Id);
}
}
// 当项开始时,如果是第一次开始,初始化重试计数
if (e.CurrentState == LoadingState.InProgress &&
(e.PreviousState == null || e.PreviousState == LoadingState.Pending))
{
lock (_lock)
{
if (!_retryCounts.ContainsKey(e.Item.Id))
{
_retryCounts[e.Item.Id] = 0;
}
}
}
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
Stop();
_checkTimer.Elapsed -= OnCheckTimerElapsed;
_checkTimer.Dispose();
_manager.StateChanged -= OnStateChanged;
_itemTimeouts.Clear();
_retryCounts.Clear();
}
}
/// <summary>
/// 加载超时事件参数
/// </summary>
public class LoadingTimeoutEventArgs : EventArgs
{
public required string ItemId { get; init; }
public required string ItemName { get; init; }
public required TimeSpan ElapsedTime { get; init; }
public int RetryCount { get; init; }
public bool IsFinalFailure { get; init; }
}
/// <summary>
/// 加载重试事件参数
/// </summary>
public class LoadingRetryEventArgs : EventArgs
{
public required string ItemId { get; init; }
public required string ItemName { get; init; }
public required int RetryCount { get; init; }
public required int MaxRetryCount { get; init; }
public required TimeSpan ElapsedTime { get; init; }
}

View File

@@ -82,6 +82,7 @@ public sealed class UpdateWorkflowService
/// <summary>
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip).
/// Also supports versioned filenames like files-{version}.json, delta-{old}-to-{new}.zip
/// </summary>
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
{
@@ -91,9 +92,67 @@ public sealed class UpdateWorkflowService
}
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
return assetNames.Contains(DeltaManifestFileName)
&& assetNames.Contains(DeltaSignatureFileName)
&& assetNames.Contains(DeltaArchiveFileName);
// Check for exact matches first (preferred)
var hasExactManifest = assetNames.Contains(DeltaManifestFileName);
var hasExactSignature = assetNames.Contains(DeltaSignatureFileName);
var hasExactArchive = assetNames.Contains(DeltaArchiveFileName);
if (hasExactManifest && hasExactSignature && hasExactArchive)
{
return true;
}
// Check for versioned filenames (e.g., files-1.0.0.json, delta-0.9.9-to-1.0.0.zip)
var hasVersionedManifest = assetNames.Any(n => n.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& n.EndsWith(".json", StringComparison.OrdinalIgnoreCase));
var hasVersionedSignature = assetNames.Any(n => n.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& n.EndsWith(".sig", StringComparison.OrdinalIgnoreCase));
var hasVersionedArchive = assetNames.Any(n => n.StartsWith("delta-", StringComparison.OrdinalIgnoreCase)
&& n.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
return hasVersionedManifest && hasVersionedSignature && hasVersionedArchive;
}
/// <summary>
/// Finds the best matching delta asset name from the release assets.
/// Prefers exact matches, falls back to versioned filenames.
/// </summary>
private static string? FindDeltaAssetName(GitHubReleaseInfo release, string baseName)
{
if (release?.Assets is null)
{
return null;
}
// Try exact match first
var exactMatch = release.Assets.FirstOrDefault(a =>
string.Equals(a.Name, baseName, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null)
{
return exactMatch.Name;
}
// Fall back to pattern matching
return baseName.ToLowerInvariant() switch
{
"files.json" => release.Assets
.Where(a => a.Name.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& a.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.Name.Length)
.FirstOrDefault()?.Name,
"files.json.sig" => release.Assets
.Where(a => a.Name.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& a.Name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.Name.Length)
.FirstOrDefault()?.Name,
"update.zip" => release.Assets
.Where(a => a.Name.StartsWith("delta-", StringComparison.OrdinalIgnoreCase)
&& a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.Name.Length)
.FirstOrDefault()?.Name,
_ => null
};
}
/// <summary>
@@ -132,6 +191,24 @@ public sealed class UpdateWorkflowService
var downloadSource = state.UpdateDownloadSource;
var downloadThreads = state.UpdateDownloadThreads;
// Find the actual asset names (support both exact and versioned filenames)
var manifestAssetName = FindDeltaAssetName(checkResult.Release, DeltaManifestFileName);
var signatureAssetName = FindDeltaAssetName(checkResult.Release, DeltaSignatureFileName);
var archiveAssetName = FindDeltaAssetName(checkResult.Release, DeltaArchiveFileName);
if (manifestAssetName is null || signatureAssetName is null || archiveAssetName is null)
{
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
}
// Build asset map with actual names from release
var assetMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[DeltaManifestFileName] = manifestAssetName,
[DeltaSignatureFileName] = signatureAssetName,
[DeltaArchiveFileName] = archiveAssetName
};
var requiredAssets = new Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
{
[DeltaManifestFileName] = null!,
@@ -141,9 +218,14 @@ public sealed class UpdateWorkflowService
foreach (var asset in checkResult.Release.Assets)
{
if (requiredAssets.ContainsKey(asset.Name))
// Match by actual asset name
foreach (var (key, actualName) in assetMap)
{
requiredAssets[asset.Name] = asset;
if (string.Equals(asset.Name, actualName, StringComparison.OrdinalIgnoreCase))
{
requiredAssets[key] = asset;
break;
}
}
}

View File

@@ -5,13 +5,18 @@
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 明确指定不需要管理员权限,以调用者权限运行 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<!-- Windows 10/11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>

View File

@@ -60,10 +60,20 @@ function Get-FileManifest {
}
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
if (-not (Test-Path $PreviousDir)) {
throw "Previous directory does not exist: $PreviousDir"
}
$previousManifest = Get-FileManifest -RootDir $PreviousDir
Write-Host " 找到 $($previousManifest.Count) 个文件" -ForegroundColor Gray
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
Write-Host " 目录: $CurrentDir" -ForegroundColor Gray
if (-not (Test-Path $CurrentDir)) {
throw "Current directory does not exist: $CurrentDir"
}
$currentManifest = Get-FileManifest -RootDir $CurrentDir
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
# 分析文件变更
$changedFiles = @()
@@ -125,6 +135,18 @@ Write-Host " 复用: $($reusedFiles.Count) 个文件"
Write-Host " 删除: $($deletedFiles.Count) 个文件"
Write-Host ""
# 显示前10个变更的文件用于调试
if ($changedFiles.Count -gt 0) {
Write-Host "变更的文件示例:" -ForegroundColor Cyan
$changedFiles | Select-Object -First 10 | ForEach-Object {
Write-Host " [$($_.Action)] $($_.Path)" -ForegroundColor Gray
}
if ($changedFiles.Count -gt 10) {
Write-Host " ... 还有 $($changedFiles.Count - 10) 个文件" -ForegroundColor Gray
}
Write-Host ""
}
# 创建临时目录用于打包
$tempDir = Join-Path $OutputDir "temp_delta"
if (Test-Path $tempDir) {
@@ -146,20 +168,28 @@ foreach ($file in $changedFiles) {
Copy-Item -Path $sourcePath -Destination $destPath -Force
}
# 创建 delta.zip
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
Write-Host "创建增量包: $deltaZipPath" -ForegroundColor Yellow
# 创建 update.zip (Launcher 期望的文件名)
$updateZipPath = Join-Path $OutputDir "update.zip"
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
if (Test-Path $updateZipPath) {
Remove-Item -Path $updateZipPath -Force
}
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
# 同时创建带版本号的副本(用于发布到 GitHub Release
$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
Copy-Item -Path $updateZipPath -Destination $deltaZipPath -Force
# 清理临时目录
Remove-Item -Path $tempDir -Recurse -Force
# 生成 files.json
# 生成 files.json (Launcher 期望的文件名)
$filesJson = @{
FromVersion = $PreviousVersion
ToVersion = $CurrentVersion
@@ -167,18 +197,26 @@ $filesJson = @{
Files = @($changedFiles + $reusedFiles + $deletedFiles)
}
$filesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
$filesJsonPath = Join-Path $OutputDir "files.json"
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
# 同时创建带版本号的副本(用于发布到 GitHub Release
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
# 计算增量包大小
$deltaSize = (Get-Item $deltaZipPath).Length
$deltaSizeMB = [math]::Round($deltaSize / 1MB, 2)
$updateSize = (Get-Item $updateZipPath).Length
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
Write-Host ""
Write-Host "=== 完成 ===" -ForegroundColor Green
Write-Host "增量包大小: $deltaSizeMB MB"
Write-Host "输出文件:"
Write-Host " - $deltaZipPath"
Write-Host "增量包大小: $updateSizeMB MB"
Write-Host "输出文件 (Launcher 使用):"
Write-Host " - $updateZipPath"
Write-Host " - $filesJsonPath"
Write-Host "输出文件 (GitHub Release 发布):"
Write-Host " - $deltaZipPath"
Write-Host " - $versionedFilesJsonPath"