mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 17:24:27 +08:00
Compare commits
3 Commits
v0.8.5
...
e8d2575bc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d2575bc1 | ||
|
|
4b897831de | ||
|
|
9283da5940 |
187
.github/workflows/release.yml
vendored
187
.github/workflows/release.yml
vendored
@@ -219,8 +219,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 +316,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 +361,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 +482,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
|
||||
|
||||
@@ -912,6 +883,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"
|
||||
|
||||
@@ -13,6 +13,10 @@ public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
// 初始化日志记录器
|
||||
Logger.Initialize();
|
||||
Logger.Info("Launcher starting...");
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
@@ -50,27 +54,12 @@ public partial class App : Application
|
||||
}
|
||||
else
|
||||
{
|
||||
// 正常启动流程
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
||||
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
// 先显示 Splash 窗口,确保应用程序不会立即退出
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.Show();
|
||||
|
||||
// 启动协调器流程
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, coordinator, splashWindow);
|
||||
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,14 +200,30 @@ public partial class App : Application
|
||||
|
||||
private static async Task RunCoordinatorWithSplashAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
LauncherFlowCoordinator coordinator,
|
||||
CommandContext context,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
ErrorWindow? errorWindow = null;
|
||||
LauncherFlowCoordinator? coordinator = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 在 try-catch 块中实例化所有服务,确保异常被捕获
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
||||
|
||||
coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -344,11 +349,11 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理旧版本
|
||||
// 3. 清理旧版本,保留至少3个版本以支持回滚
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
|
||||
deploymentLocator.CleanupDestroyedDeployments();
|
||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
@@ -17,44 +18,65 @@ internal sealed class DeploymentLocator
|
||||
|
||||
public string? FindCurrentDeploymentDirectory()
|
||||
{
|
||||
var candidates = Directory.Exists(_appRoot)
|
||||
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
: [];
|
||||
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
|
||||
|
||||
// 过滤掉无效的部署目录
|
||||
var validCandidates = candidates
|
||||
.Where(path =>
|
||||
!File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除
|
||||
!File.Exists(Path.Combine(path, ".partial"))) // 排除未完成
|
||||
.ToList();
|
||||
|
||||
// 优先选择带 .current 标记的版本
|
||||
var withMarkers = validCandidates
|
||||
.Where(path => File.Exists(Path.Combine(path, ".current")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
if (withMarkers.Count > 0)
|
||||
if (!Directory.Exists(_appRoot))
|
||||
{
|
||||
return withMarkers[0].Path;
|
||||
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果没有 .current 标记,选择最新版本
|
||||
var byVersion = validCandidates
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
|
||||
return byVersion.Count > 0 ? byVersion[0].Path : null;
|
||||
try
|
||||
{
|
||||
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
|
||||
|
||||
// ClassIsland 风格的查询:先筛选,后排序
|
||||
var validInstallations = candidates
|
||||
.Where(path =>
|
||||
{
|
||||
var hasDestroy = File.Exists(Path.Combine(path, ".destroy"));
|
||||
var hasPartial = File.Exists(Path.Combine(path, ".partial"));
|
||||
var hasExe = File.Exists(Path.Combine(path, executable));
|
||||
var hasCurrent = File.Exists(Path.Combine(path, ".current"));
|
||||
var version = ParseVersionFromDirectory(path);
|
||||
|
||||
Console.WriteLine($"[DeploymentLocator] Candidate: {Path.GetFileName(path)} | " +
|
||||
$"Version={version} | " +
|
||||
$"Current={hasCurrent} | " +
|
||||
$"Destroy={hasDestroy} | " +
|
||||
$"Partial={hasPartial} | " +
|
||||
$"HasExe={hasExe}");
|
||||
|
||||
return !hasDestroy && !hasPartial && hasExe;
|
||||
})
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path),
|
||||
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
|
||||
})
|
||||
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 标记的排前面
|
||||
.ThenByDescending(x => x.Version) // 然后按版本号降序
|
||||
.ToList();
|
||||
|
||||
if (validInstallations.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
|
||||
return null;
|
||||
}
|
||||
|
||||
var best = validInstallations[0];
|
||||
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
|
||||
return best.Path;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DeploymentLocator] Error searching for deployments: {ex}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public string? ResolveHostExecutablePath()
|
||||
@@ -233,35 +255,159 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanupDestroyedDeployments()
|
||||
/// <summary>
|
||||
/// 清理旧版本部署,保留最近的N个版本
|
||||
/// </summary>
|
||||
/// <param name="minVersionsToKeep">最少保留版本数,默认3个</param>
|
||||
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Directory.Exists(_appRoot)
|
||||
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
: [];
|
||||
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
|
||||
|
||||
var destroyedDirs = candidates
|
||||
.Where(path => File.Exists(Path.Combine(path, ".destroy")));
|
||||
if (!Directory.Exists(_appRoot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var dir in destroyedDirs)
|
||||
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
// 过滤掉无效部署目录(排除partial),按版本排序
|
||||
var validDeployments = candidates
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path),
|
||||
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
|
||||
IsCurrent = File.Exists(Path.Combine(path, ".current"))
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
|
||||
|
||||
// 确定要保留的版本
|
||||
var versionsToKeep = new HashSet<string>();
|
||||
|
||||
// 1. 总是保留当前版本
|
||||
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
|
||||
if (currentVersion != null)
|
||||
{
|
||||
versionsToKeep.Add(currentVersion.Path);
|
||||
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
|
||||
}
|
||||
|
||||
// 2. 保留最近的N个有效版本(不包括已标记destroy的)
|
||||
var activeVersions = validDeployments
|
||||
.Where(d => !d.IsDestroyed)
|
||||
.Take(minVersionsToKeep)
|
||||
.ToList();
|
||||
|
||||
foreach (var ver in activeVersions)
|
||||
{
|
||||
versionsToKeep.Add(ver.Path);
|
||||
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
|
||||
}
|
||||
|
||||
// 3. 保留有快照的版本(用于回滚)
|
||||
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||
if (Directory.Exists(snapshotDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
|
||||
foreach (var snapshotFile in snapshotFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(snapshotFile);
|
||||
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json);
|
||||
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
|
||||
{
|
||||
if (Directory.Exists(snapshot.SourceDirectory))
|
||||
{
|
||||
versionsToKeep.Add(snapshot.SourceDirectory);
|
||||
Console.WriteLine($"[DeploymentLocator] Keep version for rollback: {snapshot.SourceDirectory}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照目录访问错误
|
||||
}
|
||||
}
|
||||
|
||||
// 清理不需要的版本
|
||||
foreach (var deployment in validDeployments)
|
||||
{
|
||||
if (versionsToKeep.Contains(deployment.Path))
|
||||
{
|
||||
// 保留此版本,如果之前标记了destroy则取消标记
|
||||
if (deployment.IsDestroyed)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(Path.Combine(deployment.Path, ".destroy"));
|
||||
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略取消标记失败
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果还没标记destroy的,先标记
|
||||
if (!deployment.IsDestroyed)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
|
||||
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略标记失败
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试删除
|
||||
try
|
||||
{
|
||||
Directory.Delete(deployment.Path, recursive: true);
|
||||
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败(可能文件被占用),下次启动再试
|
||||
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
|
||||
// 忽略清理失败
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅清理已标记为.destroy的部署(兼容旧方法)
|
||||
/// </summary>
|
||||
[Obsolete("Use CleanupOldDeployments instead")]
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
CleanupOldDeployments(3);
|
||||
}
|
||||
|
||||
public static Version ParseVersionFromDirectory(string path)
|
||||
{
|
||||
var text = ParseVersionTextFromDirectory(path);
|
||||
|
||||
@@ -4,50 +4,67 @@ using System.Text.Json;
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 灵活的主程序定位器
|
||||
/// </summary>
|
||||
internal sealed class FlexibleHostLocator
|
||||
{
|
||||
private readonly HostDiscoveryOptions _options;
|
||||
private readonly string _appRoot;
|
||||
|
||||
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
_options = options ?? new HostDiscoveryOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析主程序可执行文件路径
|
||||
/// 灵活的主程序定位器
|
||||
/// </summary>
|
||||
public string? ResolveHostExecutablePath()
|
||||
internal sealed class FlexibleHostLocator
|
||||
{
|
||||
var executable = GetExecutableName();
|
||||
var searchContext = new SearchContext
|
||||
{
|
||||
ExecutableName = executable,
|
||||
AppRoot = _appRoot,
|
||||
Options = _options
|
||||
};
|
||||
private readonly HostDiscoveryOptions _options;
|
||||
private readonly string _appRoot;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
|
||||
// ========== 第一阶段:标准路径查找(快速路径)==========
|
||||
|
||||
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
|
||||
var envPath = GetPathFromEnvironment();
|
||||
if (!string.IsNullOrWhiteSpace(envPath))
|
||||
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
||||
{
|
||||
var validated = ValidateAndReturn(envPath, "environment variable");
|
||||
if (validated != null) return validated;
|
||||
_appRoot = appRoot;
|
||||
_options = options ?? new HostDiscoveryOptions();
|
||||
_deploymentLocator = new DeploymentLocator(appRoot);
|
||||
}
|
||||
|
||||
// 2. 搜索部署目录(app-*)- 生产环境标准路径
|
||||
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
||||
/// <summary>
|
||||
/// 解析主程序可执行文件路径
|
||||
/// </summary>
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
return deploymentPath;
|
||||
}
|
||||
var executable = GetExecutableName();
|
||||
var searchContext = new SearchContext
|
||||
{
|
||||
ExecutableName = executable,
|
||||
AppRoot = _appRoot,
|
||||
Options = _options
|
||||
};
|
||||
|
||||
// 3. 检查 Launcher 同级目录(便携模式)
|
||||
// ========== 第一阶段:标准路径查找(快速路径)==========
|
||||
|
||||
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
|
||||
var envPath = GetPathFromEnvironment();
|
||||
if (!string.IsNullOrWhiteSpace(envPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(envPath, "environment variable");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
|
||||
// 2. 使用 DeploymentLocator(ClassIsland 风格的简洁查询 - 优先)
|
||||
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
|
||||
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var deploymentExePath = Path.Combine(deploymentDir, executable);
|
||||
if (File.Exists(deploymentExePath))
|
||||
{
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
|
||||
return deploymentExePath;
|
||||
}
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
|
||||
}
|
||||
|
||||
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
|
||||
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
|
||||
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
||||
{
|
||||
return deploymentPath;
|
||||
}
|
||||
|
||||
// 4. 检查 Launcher 同级目录(便携模式)
|
||||
var portablePath = SearchPortableLocation(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(portablePath))
|
||||
{
|
||||
@@ -56,7 +73,7 @@ internal sealed class FlexibleHostLocator
|
||||
|
||||
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
|
||||
|
||||
// 4. 检查配置文件中的路径 - 用户自定义配置
|
||||
// 5. 检查配置文件中的路径 - 用户自定义配置
|
||||
var configPath = GetPathFromConfigFile();
|
||||
if (!string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
@@ -71,7 +88,7 @@ internal sealed class FlexibleHostLocator
|
||||
return nearbyPath;
|
||||
}
|
||||
|
||||
// 6. 开发模式:检查保存的自定义路径
|
||||
// 7. 开发模式:检查保存的自定义路径
|
||||
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
@@ -82,21 +99,21 @@ internal sealed class FlexibleHostLocator
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 搜索标准开发路径
|
||||
// 8. 搜索标准开发路径
|
||||
var devPath = SearchDevelopmentPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
|
||||
// 8. 搜索额外的配置路径
|
||||
// 9. 搜索额外的配置路径
|
||||
var additionalPath = SearchAdditionalPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(additionalPath))
|
||||
{
|
||||
return additionalPath;
|
||||
}
|
||||
|
||||
// 9. 递归搜索(如果启用)
|
||||
// 10. 递归搜索(如果启用)
|
||||
if (_options.RecursiveSearch)
|
||||
{
|
||||
var recursivePath = SearchRecursively(searchContext);
|
||||
|
||||
@@ -38,8 +38,20 @@ internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
try
|
||||
{
|
||||
// 清理待删除的旧版本
|
||||
_deploymentLocator.CleanupDestroyedDeployments();
|
||||
// 清理旧版本,保留至少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 +63,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 +87,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 +164,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;
|
||||
// 等待主程序就绪或进程退出(取先发生者)
|
||||
// 延长超时到 120 秒,给主程序足够的加载时间
|
||||
var readyOrTimeoutOrExit = Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(120)));
|
||||
|
||||
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 +226,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
});
|
||||
}
|
||||
|
||||
await processExitTask;
|
||||
// 继续等待主程序进程退出(如果它还在运行)
|
||||
if (!hostProcess.HasExited)
|
||||
{
|
||||
await processExitTask;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -341,6 +420,69 @@ 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 void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
|
||||
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal 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 // 便携版
|
||||
}
|
||||
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 简单的日志记录器 - 同时输出到控制台和文件
|
||||
/// </summary>
|
||||
internal static class Logger
|
||||
{
|
||||
private static readonly object _lock = new();
|
||||
private static string? _logFilePath;
|
||||
private static bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化日志记录器
|
||||
/// </summary>
|
||||
public static void Initialize()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var logDir = GetLogDirectory();
|
||||
if (!string.IsNullOrEmpty(logDir))
|
||||
{
|
||||
Directory.CreateDirectory(logDir);
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
_logFilePath = Path.Combine(logDir, $"launcher_{timestamp}.log");
|
||||
Console.WriteLine($"[Logger] Log file initialized: {_logFilePath}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[Logger] Failed to initialize log file: {ex.Message}");
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取日志文件路径
|
||||
/// </summary>
|
||||
public static string? GetLogFilePath()
|
||||
{
|
||||
return _logFilePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取日志目录
|
||||
/// </summary>
|
||||
private static string? GetLogDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
return Path.Combine(launcherDir, ".launcher", "logs");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录信息日志
|
||||
/// </summary>
|
||||
public static void Info(string message)
|
||||
{
|
||||
WriteLog("INFO", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录警告日志
|
||||
/// </summary>
|
||||
public static void Warn(string message)
|
||||
{
|
||||
WriteLog("WARN", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录错误日志
|
||||
/// </summary>
|
||||
public static void Error(string message)
|
||||
{
|
||||
WriteLog("ERROR", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录错误日志(带异常)
|
||||
/// </summary>
|
||||
public static void Error(string message, Exception exception)
|
||||
{
|
||||
WriteLog("ERROR", $"{message}\n{exception}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入日志
|
||||
/// </summary>
|
||||
private static void WriteLog(string level, string message)
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var logLine = $"[{timestamp}] [{level}] {message}";
|
||||
|
||||
Console.WriteLine(logLine);
|
||||
|
||||
if (string.IsNullOrEmpty(_logFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(_logFilePath, logLine + Environment.NewLine, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,29 +6,99 @@ internal sealed class OobeStateService
|
||||
|
||||
public OobeStateService(string appRoot)
|
||||
{
|
||||
// 将 OOBE 状态文件存储在用户可写的 LocalApplicationData 目录中,
|
||||
// 而不是安装目录(Program Files 下普通用户没有写入权限)。
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
var stateDir = Path.Combine(appDataDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
// 优先使用 LocalApplicationData(用户目录,普通用户一定有权限)
|
||||
string? stateDir = null;
|
||||
Exception? lastException = null;
|
||||
|
||||
// 策略1: LocalApplicationData(首选,用户目录,普通用户一定有写权限)
|
||||
try
|
||||
{
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
stateDir = Path.Combine(appDataDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
|
||||
// 策略2: 如果LocalApplicationData不行,使用用户的临时目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
stateDir = tempDir;
|
||||
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 最后的兜底:使用当前用户的应用程序数据目录(和Launcher同目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
stateDir = Path.Combine(launcherDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
|
||||
// 如果所有策略都失败,抛出异常让上层处理
|
||||
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
|
||||
}
|
||||
}
|
||||
|
||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
||||
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
try
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
|
||||
// 如果无法检查,默认视为首次运行,确保OOBE能显示
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
Console.WriteLine("[OobeStateService] Marked first run as completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
|
||||
// 如果无法写入也没关系,下次启动还会显示OOBE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ internal sealed class UpdateEngineService
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
// 清理旧版本,但保留最近3个版本以支持回滚
|
||||
CleanupDestroyedDeployments();
|
||||
|
||||
return new LauncherResult
|
||||
|
||||
@@ -76,21 +76,30 @@
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="24,16">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="8">
|
||||
<Button x:Name="ExitButton"
|
||||
Content="退出"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="RetryButton"
|
||||
Content="重试"
|
||||
Width="80"
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button x:Name="OpenLogButton"
|
||||
Grid.Column="0"
|
||||
Content="打开日志"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
HorizontalAlignment="Left"/>
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button x:Name="ExitButton"
|
||||
Content="退出"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="RetryButton"
|
||||
Content="重试"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -2,6 +2,8 @@ using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
@@ -66,6 +68,7 @@ public partial class ErrorWindow : Window
|
||||
// 按钮事件
|
||||
var retryButton = this.FindControl<Button>("RetryButton");
|
||||
var exitButton = this.FindControl<Button>("ExitButton");
|
||||
var openLogButton = this.FindControl<Button>("OpenLogButton");
|
||||
|
||||
if (retryButton is not null)
|
||||
{
|
||||
@@ -86,6 +89,16 @@ public partial class ErrorWindow : Window
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!");
|
||||
}
|
||||
|
||||
if (openLogButton is not null)
|
||||
{
|
||||
openLogButton.Click += OnOpenLogClick;
|
||||
Console.WriteLine("[ErrorWindow] OpenLogButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find OpenLogButton!");
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorWindow] Components initialization completed");
|
||||
}
|
||||
@@ -210,6 +223,61 @@ public partial class ErrorWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置存储的基础目录
|
||||
/// </summary>
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户状态)
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
var configDir = Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// LocalApplicationData 不可用,回退到 Launcher 所在目录
|
||||
}
|
||||
|
||||
// 回退方案:使用 Launcher 所在目录
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var configDir = Path.Combine(launcherDir, ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 最后的兜底:使用当前目录
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保配置目录存在
|
||||
/// </summary>
|
||||
private static bool EnsureConfigDirectory(string dirPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dirPath))
|
||||
{
|
||||
Directory.CreateDirectory(dirPath);
|
||||
Console.WriteLine($"[ErrorWindow] Created config directory: {dirPath}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to create config directory: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
@@ -217,17 +285,20 @@ public partial class ErrorWindow : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
var devModeFile = GetDevModeFilePath();
|
||||
var dir = Path.GetDirectoryName(devModeFile);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save dev mode: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to save dev mode state: {ex.Message}");
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save dev mode state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,29 +309,24 @@ public partial class ErrorWindow : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
var devModeFile = GetDevModeFilePath();
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
|
||||
if (File.Exists(devModeFile))
|
||||
{
|
||||
var content = File.ReadAllText(devModeFile).Trim();
|
||||
return content == "1";
|
||||
var enabled = content == "1";
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state loaded: {enabled}");
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load dev mode state: {ex.Message}");
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load dev mode state: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取开发模式状态文件路径
|
||||
/// </summary>
|
||||
private static string GetDevModeFilePath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "devmode.config");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
@@ -268,17 +334,20 @@ public partial class ErrorWindow : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostPathFile = GetCustomHostPathFilePath();
|
||||
var dir = Path.GetDirectoryName(hostPathFile);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save custom path: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
File.WriteAllText(hostPathFile, path ?? string.Empty);
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to save custom host path: {ex.Message}");
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save custom host path: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,43 +358,42 @@ public partial class ErrorWindow : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostPathFile = GetCustomHostPathFilePath();
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
|
||||
if (File.Exists(hostPathFile))
|
||||
{
|
||||
var content = File.ReadAllText(hostPathFile).Trim();
|
||||
// 验证路径是否仍然有效
|
||||
if (!string.IsNullOrEmpty(content) && File.Exists(content))
|
||||
{
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
|
||||
return content;
|
||||
}
|
||||
|
||||
// 路径已失效,清理配置文件
|
||||
try
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
File.Delete(hostPathFile);
|
||||
Console.WriteLine("Custom host path is no longer valid, cleared saved path.");
|
||||
}
|
||||
catch (Exception clearEx)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to clear invalid host path: {clearEx.Message}");
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
|
||||
try
|
||||
{
|
||||
File.Delete(hostPathFile);
|
||||
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
|
||||
}
|
||||
catch (Exception clearEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load custom host path: {ex.Message}");
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取自定义主程序路径文件路径
|
||||
/// </summary>
|
||||
private static string GetCustomHostPathFilePath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "custom-host-path.config");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否启用了开发模式(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
@@ -351,6 +419,110 @@ public partial class ErrorWindow : Window
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开日志文件
|
||||
/// </summary>
|
||||
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFilePath = Logger.GetLogFilePath();
|
||||
|
||||
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
|
||||
{
|
||||
// 如果没有日志文件,打开日志目录
|
||||
var logDir = Path.GetDirectoryName(logFilePath);
|
||||
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
|
||||
{
|
||||
OpenFolder(logDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 尝试打开配置目录
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (Directory.Exists(configDir))
|
||||
{
|
||||
OpenFolder(configDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] No log file or directory available");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
|
||||
OpenFile(logFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件
|
||||
/// </summary>
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{filePath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件夹
|
||||
/// </summary>
|
||||
private static void OpenFolder(string folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{folderPath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", folderPath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
250
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal file
250
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal 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=""
|
||||
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=""
|
||||
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>
|
||||
396
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal file
396
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal 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"))
|
||||
};
|
||||
}
|
||||
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal file
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal 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=""
|
||||
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>
|
||||
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal file
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal 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
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="400"
|
||||
d:DesignHeight="220"
|
||||
d:DesignWidth="480"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
|
||||
x:DataType="views:UpdateWindow"
|
||||
Title="阑山桌面 - 更新"
|
||||
Width="400"
|
||||
Height="220"
|
||||
Width="480"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
@@ -21,48 +21,88 @@
|
||||
<views:UpdateWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
<!-- 应用名称 -->
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="阑山桌面"
|
||||
FontSize="36"
|
||||
FontWeight="Light"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Grid.Row="0"
|
||||
Margin="0,30,0,0"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Grid>
|
||||
<!-- 顶部:应用名称和最小化按钮 -->
|
||||
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="阑山桌面"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
CornerRadius="4"
|
||||
Padding="6,2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="Update"
|
||||
FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 状态文本 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Row="1"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,16,0,0"
|
||||
Text="正在更新,请稍候..." />
|
||||
<!-- 最小化按钮 -->
|
||||
<Button x:Name="MinimizeButton"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Background="Transparent"
|
||||
BorderThickness="0">
|
||||
<TextBlock Text=""
|
||||
FontSize="12"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="2"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="3"
|
||||
Width="200"
|
||||
Margin="0,16,0,0"
|
||||
IsIndeterminate="True"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<TextBlock x:Name="DetailText"
|
||||
Grid.Row="3"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,12,0,24"
|
||||
Text="" />
|
||||
<!-- 底部区域:进度条和状态 -->
|
||||
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 第一行:左下角状态,右下角百分比 -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 左下角:状态文字 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="0"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Opacity="0.8"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Text="正在更新,请稍候..." />
|
||||
|
||||
<!-- 右下角:百分比 -->
|
||||
<TextBlock x:Name="PercentText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Opacity="0.8"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Text="0%" />
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="True"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -12,6 +12,22 @@ public partial class UpdateWindow : Window
|
||||
public UpdateWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
InitializeEventHandlers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化事件处理程序
|
||||
/// </summary>
|
||||
private void InitializeEventHandlers()
|
||||
{
|
||||
var minimizeButton = this.FindControl<Button>("MinimizeButton");
|
||||
if (minimizeButton != null)
|
||||
{
|
||||
minimizeButton.Click += (s, e) =>
|
||||
{
|
||||
this.WindowState = WindowState.Minimized;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,11 +39,11 @@ public partial class UpdateWindow : Window
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
var detailText = this.FindControl<TextBlock>("DetailText");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
|
||||
if (statusText is null || progressIndicator is null || detailText is null)
|
||||
if (statusText is null || progressIndicator is null || percentText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, DetailText={detailText != null}");
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, PercentText={percentText != null}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,23 +53,13 @@ public partial class UpdateWindow : Window
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progressPercent;
|
||||
percentText.Text = $"{progressPercent}%";
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
percentText.Text = "";
|
||||
}
|
||||
|
||||
// 根据阶段显示不同的底部提示
|
||||
detailText.Text = stage.ToLowerInvariant() switch
|
||||
{
|
||||
"verify" => "正在验证更新完整性...",
|
||||
"extract" => "正在解压更新包...",
|
||||
"apply" => "正在应用更新文件...",
|
||||
"plugins" => "正在升级插件...",
|
||||
"cleanup" => "正在清理...",
|
||||
"done" => "",
|
||||
_ => ""
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,10 +72,10 @@ public partial class UpdateWindow : Window
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
var detailText = this.FindControl<TextBlock>("DetailText");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
|
||||
if (statusText is null || progressIndicator is null || detailText is null || titleText is null)
|
||||
if (statusText is null || progressIndicator is null || percentText is null || titleText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
|
||||
return;
|
||||
@@ -77,7 +83,7 @@ public partial class UpdateWindow : Window
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = 100;
|
||||
detailText.Text = "";
|
||||
percentText.Text = "100%";
|
||||
|
||||
if (success)
|
||||
{
|
||||
|
||||
231
LanMountainDesktop.Shared.Contracts/Launcher/LoadingState.cs
Normal file
231
LanMountainDesktop.Shared.Contracts/Launcher/LoadingState.cs
Normal 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; }
|
||||
}
|
||||
@@ -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 =>
|
||||
@@ -178,6 +181,16 @@ 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, "正在初始化...");
|
||||
}
|
||||
}
|
||||
@@ -213,6 +226,41 @@ public partial class App : Application
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步向 Launcher 报告启动进度,确保关键消息可靠送达
|
||||
/// 用于 Ready 等关键状态报告
|
||||
/// </summary>
|
||||
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
|
||||
{
|
||||
if (_launcherIpcClient is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// 使用同步等待确保消息发送完成
|
||||
var task = _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
});
|
||||
|
||||
// 等待最多 5 秒,确保消息发送成功
|
||||
if (!task.Wait(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", "Report progress timeout after 5 seconds");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress synchronously: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDesignTimeTheme()
|
||||
{
|
||||
RequestedThemeVariant = ThemeVariant.Light;
|
||||
@@ -927,10 +975,36 @@ 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;
|
||||
|
||||
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)
|
||||
|
||||
380
LanMountainDesktop/Services/Loading/LoadingStateManager.cs
Normal file
380
LanMountainDesktop/Services/Loading/LoadingStateManager.cs
Normal 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; }
|
||||
}
|
||||
360
LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
Normal file
360
LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
201
LanMountainDesktop/Services/Loading/LoadingStateUsageExample.cs
Normal file
201
LanMountainDesktop/Services/Loading/LoadingStateUsageExample.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
274
LanMountainDesktop/Services/Loading/LoadingTimeoutHandler.cs
Normal file
274
LanMountainDesktop/Services/Loading/LoadingTimeoutHandler.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using System.Timers;
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user