Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
4b897831de changed.优化了更新体验 2026-04-18 00:49:03 +08:00
lincube
9283da5940 changed.调整了启动逻辑,优化了更新页面。 2026-04-17 22:33:41 +08:00
17 changed files with 1777 additions and 358 deletions

View File

@@ -219,8 +219,10 @@ jobs:
Move-Item -Path $newStructure -Destination $publishDir -Force Move-Item -Path $newStructure -Destination $publishDir -Force
shell: pwsh shell: pwsh
- name: Install Inno Setup - name: Install Inno Setup and 7z
run: choco install innosetup -y --no-progress run: |
choco install innosetup -y --no-progress
choco install 7zip -y --no-progress
shell: pwsh shell: pwsh
- name: Build Installer - name: Build Installer
@@ -314,18 +316,41 @@ jobs:
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh shell: pwsh
- name: Generate Delta Package - name: Create App Package
if: matrix.self_contained == true && matrix.arch == 'x64' if: matrix.self_contained == true && matrix.arch == 'x64'
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$publishDir = "publish/windows-${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version" $appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir $currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output" $outputDir = "delta-output"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null 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 $previousVersion = $null
$previousAppPath = $null $previousAppPath = $null
try { try {
@@ -336,128 +361,73 @@ jobs:
$previousVersion = $previousRelease.tag_name.TrimStart('v','V') $previousVersion = $previousRelease.tag_name.TrimStart('v','V')
Write-Host "Previous release version: $previousVersion" Write-Host "Previous release version: $previousVersion"
# Try to download update.zip from previous release for diff # 下载旧版本的 app-{version}-win-{arch}.zip
$prevUpdateZip = $previousRelease.assets | Where-Object { $_.name -eq "update.zip" } | Select-Object -First 1 $prevAppZip = $previousRelease.assets | Where-Object { $_.name -eq "app-$previousVersion-win-$arch.zip" } | Select-Object -First 1
if ($prevUpdateZip) { if ($prevAppZip) {
Write-Host "Found update.zip in previous release - extracting for diff..." Write-Host "Found app-$previousVersion-win-$arch.zip in previous release - downloading for diff..."
$prevZipDest = Join-Path $outputDir "prev-update.zip" $prevAppZipDest = Join-Path $outputDir "prev-app.zip"
Invoke-WebRequest -Uri $prevUpdateZip.browser_download_url -OutFile $prevZipDest -Headers $headers Invoke-WebRequest -Uri $prevAppZip.browser_download_url -OutFile $prevAppZipDest -Headers $headers
# 解压 app-{version}.zip
$previousAppPath = Join-Path $outputDir "prev-app" $previousAppPath = Join-Path $outputDir "prev-app"
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
Expand-Archive -Path $prevZipDest -DestinationPath $previousAppPath -Force Expand-Archive -Path $prevAppZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevZipDest -Force Remove-Item -Path $prevAppZipDest -Force -ErrorAction SilentlyContinue
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count if ($previousAppPath -and (Test-Path $previousAppPath)) {
Write-Host "Extracted $prevFileCount files from previous version for diff" $prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
}
} else { } 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 { } catch {
Write-Host "Could not fetch previous release: $_" Write-Host "Could not fetch previous release: $_"
} }
# --- Generate file manifest with diff against previous version --- # --- Generate delta package using the script ---
Write-Host "Generating update package for version $version..." if ($previousAppPath -and (Test-Path $previousAppPath) -and $previousVersion) {
$files = Get-ChildItem -Path $currentAppPath -Recurse -File Write-Host "Generating delta package from $previousVersion to $version..."
$fileEntries = [System.Collections.ArrayList]::new() & $scriptPath `
$changedFiles = [System.Collections.ArrayList]::new() -PreviousVersion $previousVersion `
$reusedCount = 0 -CurrentVersion $version `
$addedCount = 0 -PreviousDir $previousAppPath `
$replacedCount = 0 -CurrentDir $currentAppPath `
$deletedCount = 0 -OutputDir $outputDir
# Build hash map of previous version files for quick lookup if ($LASTEXITCODE -ne 0) {
$prevHashMap = @{} Write-Error "Generate-DeltaPackage.ps1 failed"
if ($previousAppPath -and (Test-Path $previousAppPath)) { exit 1
$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()
} }
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 { } else {
# No changed files - create a minimal zip Write-Host "No previous version available - generating full package..."
$emptyMarker = Join-Path $tempDir ".no-changes" # Generate a "full" delta package (all files as "add")
Set-Content -Path $emptyMarker -Value "" & $scriptPath `
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal -PreviousVersion "0.0.0" `
} -CurrentVersion $version `
Remove-Item -Path $tempDir -Recurse -Force -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 # Clean up previous version extraction
if ($previousAppPath -and (Test-Path $previousAppPath)) { if ($previousAppPath -and (Test-Path $previousAppPath)) {
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue 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 shell: pwsh
- name: Sign File Map - name: Sign File Map
@@ -512,6 +482,7 @@ jobs:
delta-output/files.json delta-output/files.json
delta-output/files.json.sig delta-output/files.json.sig
delta-output/update.zip delta-output/update.zip
delta-output/app-*.zip
if-no-files-found: error if-no-files-found: error
retention-days: 90 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/ \; 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) # 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/ \; 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 ""
echo "Files ready for release:" echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files" ls -lh release-files/ || echo "No files found in release-files"

View File

@@ -13,6 +13,10 @@ public partial class App : Application
{ {
public override void Initialize() public override void Initialize()
{ {
// 初始化日志记录器
Logger.Initialize();
Logger.Info("Launcher starting...");
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
@@ -50,27 +54,12 @@ public partial class App : Application
} }
else 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 窗口,确保应用程序不会立即退出 // 先显示 Splash 窗口,确保应用程序不会立即退出
var splashWindow = new SplashWindow(); var splashWindow = new SplashWindow();
splashWindow.Show(); splashWindow.Show();
// 启动协调器流程 // 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
_ = RunCoordinatorWithSplashAsync(desktop, coordinator, splashWindow); _ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
} }
} }
@@ -211,14 +200,30 @@ public partial class App : Application
private static async Task RunCoordinatorWithSplashAsync( private static async Task RunCoordinatorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop, IClassicDesktopStyleApplicationLifetime desktop,
LauncherFlowCoordinator coordinator, CommandContext context,
SplashWindow splashWindow) SplashWindow splashWindow)
{ {
LauncherResult result; LauncherResult result;
ErrorWindow? errorWindow = null; ErrorWindow? errorWindow = null;
LauncherFlowCoordinator? coordinator = null;
try 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); result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
@@ -344,11 +349,11 @@ public partial class App : Application
} }
} }
// 3. 清理旧版本 // 3. 清理旧版本保留至少3个版本以支持回滚
if (success) if (success)
{ {
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90)); await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
deploymentLocator.CleanupDestroyedDeployments(); deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Services;
@@ -17,44 +18,65 @@ internal sealed class DeploymentLocator
public string? FindCurrentDeploymentDirectory() public string? FindCurrentDeploymentDirectory()
{ {
var candidates = Directory.Exists(_appRoot) Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
// 过滤掉无效的部署目录 if (!Directory.Exists(_appRoot))
var validCandidates = candidates
.Where(path =>
!File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除
!File.Exists(Path.Combine(path, ".partial"))) // 排除未完成
.ToList();
// 优先选择带 .current 标记的版本
var withMarkers = validCandidates
.Where(path => File.Exists(Path.Combine(path, ".current")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
if (withMarkers.Count > 0)
{ {
return withMarkers[0].Path; Console.WriteLine("[DeploymentLocator] App root directory does not exist");
return null;
} }
// 如果没有 .current 标记,选择最新版本 var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var byVersion = validCandidates
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
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() 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 try
{ {
var candidates = Directory.Exists(_appRoot) Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
var destroyedDirs = candidates if (!Directory.Exists(_appRoot))
.Where(path => File.Exists(Path.Combine(path, ".destroy"))); {
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 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 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) public static Version ParseVersionFromDirectory(string path)
{ {
var text = ParseVersionTextFromDirectory(path); var text = ParseVersionTextFromDirectory(path);

View File

@@ -4,50 +4,67 @@ using System.Text.Json;
namespace LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Services;
/// <summary> /// <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> /// </summary>
public string? ResolveHostExecutablePath() internal sealed class FlexibleHostLocator
{ {
var executable = GetExecutableName(); private readonly HostDiscoveryOptions _options;
var searchContext = new SearchContext private readonly string _appRoot;
{ private readonly DeploymentLocator _deploymentLocator;
ExecutableName = executable,
AppRoot = _appRoot,
Options = _options
};
// ========== 第一阶段:标准路径查找(快速路径)========== public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
var envPath = GetPathFromEnvironment();
if (!string.IsNullOrWhiteSpace(envPath))
{ {
var validated = ValidateAndReturn(envPath, "environment variable"); _appRoot = appRoot;
if (validated != null) return validated; _options = options ?? new HostDiscoveryOptions();
_deploymentLocator = new DeploymentLocator(appRoot);
} }
// 2. 搜索部署目录app-*- 生产环境标准路径 /// <summary>
var deploymentPath = SearchDeploymentDirectories(searchContext); /// 解析主程序可执行文件路径
if (!string.IsNullOrWhiteSpace(deploymentPath)) /// </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. 使用 DeploymentLocatorClassIsland 风格的简洁查询 - 优先)
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); var portablePath = SearchPortableLocation(searchContext);
if (!string.IsNullOrWhiteSpace(portablePath)) if (!string.IsNullOrWhiteSpace(portablePath))
{ {
@@ -56,7 +73,7 @@ internal sealed class FlexibleHostLocator
// ========== 第二阶段:灵活查找(标准路径找不到时)========== // ========== 第二阶段:灵活查找(标准路径找不到时)==========
// 4. 检查配置文件中的路径 - 用户自定义配置 // 5. 检查配置文件中的路径 - 用户自定义配置
var configPath = GetPathFromConfigFile(); var configPath = GetPathFromConfigFile();
if (!string.IsNullOrWhiteSpace(configPath)) if (!string.IsNullOrWhiteSpace(configPath))
{ {
@@ -71,7 +88,7 @@ internal sealed class FlexibleHostLocator
return nearbyPath; return nearbyPath;
} }
// 6. 开发模式:检查保存的自定义路径 // 7. 开发模式:检查保存的自定义路径
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled()) if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
{ {
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath(); var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
@@ -82,21 +99,21 @@ internal sealed class FlexibleHostLocator
} }
} }
// 7. 搜索标准开发路径 // 8. 搜索标准开发路径
var devPath = SearchDevelopmentPaths(searchContext); var devPath = SearchDevelopmentPaths(searchContext);
if (!string.IsNullOrWhiteSpace(devPath)) if (!string.IsNullOrWhiteSpace(devPath))
{ {
return devPath; return devPath;
} }
// 8. 搜索额外的配置路径 // 9. 搜索额外的配置路径
var additionalPath = SearchAdditionalPaths(searchContext); var additionalPath = SearchAdditionalPaths(searchContext);
if (!string.IsNullOrWhiteSpace(additionalPath)) if (!string.IsNullOrWhiteSpace(additionalPath))
{ {
return additionalPath; return additionalPath;
} }
// 9. 递归搜索(如果启用) // 10. 递归搜索(如果启用)
if (_options.RecursiveSearch) if (_options.RecursiveSearch)
{ {
var recursivePath = SearchRecursively(searchContext); var recursivePath = SearchRecursively(searchContext);

View File

@@ -38,8 +38,20 @@ internal sealed class LauncherFlowCoordinator
{ {
try try
{ {
// 清理待删除的旧版本 // 清理旧版本保留至少3个版本
_deploymentLocator.CleanupDestroyedDeployments(); _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 窗口或创建新的 // 使用传入的 Splash 窗口或创建新的
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() => var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
@@ -341,6 +353,69 @@ internal sealed class LauncherFlowCoordinator
return (result, customPath); 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) private static void EnsureExecutable(string path)
{ {
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())

View File

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

View File

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

View File

@@ -6,29 +6,99 @@ internal sealed class OobeStateService
public OobeStateService(string appRoot) public OobeStateService(string appRoot)
{ {
// 将 OOBE 状态文件存储在用户可写的 LocalApplicationData 目录中, // 优先使用 LocalApplicationData(用户目录,普通用户一定有权限)
// 而不是安装目录Program Files 下普通用户没有写入权限)。 string? stateDir = null;
var appDataDir = Path.Combine( Exception? lastException = null;
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop"); // 策略1: LocalApplicationData首选用户目录普通用户一定有写权限
var stateDir = Path.Combine(appDataDir, ".launcher", "state"); try
Directory.CreateDirectory(stateDir); {
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"); _markerPath = Path.Combine(stateDir, "first_run_completed");
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
} }
public bool IsFirstRun() 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() public void MarkCompleted()
{ {
var dir = Path.GetDirectoryName(_markerPath); try
if (!string.IsNullOrWhiteSpace(dir))
{ {
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
}
} }
} }

View File

@@ -217,6 +217,7 @@ internal sealed class UpdateEngineService
snapshot.Status = "applied"; snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot); SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts(); CleanupIncomingArtifacts();
// 清理旧版本但保留最近3个版本以支持回滚
CleanupDestroyedDeployments(); CleanupDestroyedDeployments();
return new LauncherResult return new LauncherResult

View File

@@ -76,21 +76,30 @@
<Border Grid.Row="1" <Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="24,16"> Padding="24,16">
<StackPanel Orientation="Horizontal" <Grid ColumnDefinitions="*,Auto">
HorizontalAlignment="Right" <Button x:Name="OpenLogButton"
Spacing="8"> Grid.Column="0"
<Button x:Name="ExitButton" Content="打开日志"
Content="退出" Width="100"
Width="80"
Height="32"
FontSize="13"/>
<Button x:Name="RetryButton"
Content="重试"
Width="80"
Height="32" Height="32"
FontSize="13" FontSize="13"
Theme="{DynamicResource AccentButtonTheme}"/> HorizontalAlignment="Left"/>
</StackPanel> <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> </Border>
</Grid> </Grid>
</Window> </Window>

View File

@@ -2,6 +2,8 @@ using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LanMountainDesktop.Launcher.Services;
using System.Diagnostics;
namespace LanMountainDesktop.Launcher.Views; namespace LanMountainDesktop.Launcher.Views;
@@ -66,6 +68,7 @@ public partial class ErrorWindow : Window
// 按钮事件 // 按钮事件
var retryButton = this.FindControl<Button>("RetryButton"); var retryButton = this.FindControl<Button>("RetryButton");
var exitButton = this.FindControl<Button>("ExitButton"); var exitButton = this.FindControl<Button>("ExitButton");
var openLogButton = this.FindControl<Button>("OpenLogButton");
if (retryButton is not null) if (retryButton is not null)
{ {
@@ -86,6 +89,16 @@ public partial class ErrorWindow : Window
{ {
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!"); 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"); 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>
/// 保存开发模式状态(内部方法) /// 保存开发模式状态(内部方法)
/// </summary> /// </summary>
@@ -217,17 +285,20 @@ public partial class ErrorWindow : Window
{ {
try try
{ {
var devModeFile = GetDevModeFilePath(); var configDir = GetConfigBaseDirectory();
var dir = Path.GetDirectoryName(devModeFile); if (!EnsureConfigDirectory(configDir))
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{ {
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"); File.WriteAllText(devModeFile, enabled ? "1" : "0");
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
} }
catch (Exception ex) 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 try
{ {
var devModeFile = GetDevModeFilePath(); var configDir = GetConfigBaseDirectory();
var devModeFile = Path.Combine(configDir, "devmode.config");
if (File.Exists(devModeFile)) if (File.Exists(devModeFile))
{ {
var content = File.ReadAllText(devModeFile).Trim(); 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) 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; 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>
/// 保存自定义主程序路径(内部方法) /// 保存自定义主程序路径(内部方法)
/// </summary> /// </summary>
@@ -268,17 +334,20 @@ public partial class ErrorWindow : Window
{ {
try try
{ {
var hostPathFile = GetCustomHostPathFilePath(); var configDir = GetConfigBaseDirectory();
var dir = Path.GetDirectoryName(hostPathFile); if (!EnsureConfigDirectory(configDir))
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{ {
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); File.WriteAllText(hostPathFile, path ?? string.Empty);
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
} }
catch (Exception ex) 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 try
{ {
var hostPathFile = GetCustomHostPathFilePath(); var configDir = GetConfigBaseDirectory();
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
if (File.Exists(hostPathFile)) if (File.Exists(hostPathFile))
{ {
var content = File.ReadAllText(hostPathFile).Trim(); var content = File.ReadAllText(hostPathFile).Trim();
// 验证路径是否仍然有效 // 验证路径是否仍然有效
if (!string.IsNullOrEmpty(content) && File.Exists(content)) if (!string.IsNullOrEmpty(content) && File.Exists(content))
{ {
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
return content; return content;
} }
// 路径已失效,清理配置文件 // 路径已失效,清理配置文件
try if (!string.IsNullOrEmpty(content))
{ {
File.Delete(hostPathFile); Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
Console.WriteLine("Custom host path is no longer valid, cleared saved path."); try
} {
catch (Exception clearEx) File.Delete(hostPathFile);
{ Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
Console.Error.WriteLine($"Failed to clear invalid host path: {clearEx.Message}"); }
catch (Exception clearEx)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
}
} }
} }
} }
catch (Exception ex) 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; 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>
/// 检查是否启用了开发模式(静态方法,启动时调用) /// 检查是否启用了开发模式(静态方法,启动时调用)
/// </summary> /// </summary>
@@ -351,6 +419,110 @@ public partial class ErrorWindow : Window
{ {
_completionSource.TrySetResult(ErrorWindowResult.Exit); _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> /// <summary>

View File

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

View File

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

View File

@@ -4,13 +4,13 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views" xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignWidth="400" d:DesignWidth="480"
d:DesignHeight="220" d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow" x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
x:DataType="views:UpdateWindow" x:DataType="views:UpdateWindow"
Title="阑山桌面 - 更新" Title="阑山桌面 - 更新"
Width="400" Width="480"
Height="220" Height="320"
CanResize="False" CanResize="False"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
SystemDecorations="None" SystemDecorations="None"
@@ -21,48 +21,88 @@
<views:UpdateWindow /> <views:UpdateWindow />
</Design.DataContext> </Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto,Auto"> <Grid>
<!-- 应用名称 --> <!-- 顶部:应用名称和最小化按钮 -->
<TextBlock x:Name="TitleText" <Grid VerticalAlignment="Top" Margin="24,24,24,0">
Text="阑山桌面" <StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
FontSize="36" <TextBlock x:Name="TitleText"
FontWeight="Light" Text="阑山桌面"
VerticalAlignment="Center" FontSize="24"
HorizontalAlignment="Center" FontWeight="SemiBold"
Grid.Row="0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
Margin="0,30,0,0" <Border Background="{DynamicResource AccentFillColorDefaultBrush}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> CornerRadius="4"
Padding="6,2"
VerticalAlignment="Center">
<TextBlock Text="Update"
FontSize="11"
FontWeight="SemiBold"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
</Border>
</StackPanel>
<!-- 状态文本 --> <!-- 最小化按钮 -->
<TextBlock x:Name="StatusText" <Button x:Name="MinimizeButton"
Grid.Row="1" HorizontalAlignment="Right"
FontSize="13" VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Width="32"
HorizontalAlignment="Center" Height="32"
VerticalAlignment="Center" Background="Transparent"
Margin="0,16,0,0" BorderThickness="0">
Text="正在更新,请稍候..." /> <TextBlock Text="&#xE921;"
FontSize="12"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Button>
</Grid>
<!-- 进度条 --> <!-- 底部区域:进度条和状态 -->
<ProgressBar x:Name="ProgressIndicator" <Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
Grid.Row="2" <Grid.RowDefinitions>
Minimum="0" <RowDefinition Height="Auto"/>
Maximum="100" <RowDefinition Height="Auto"/>
Value="0" </Grid.RowDefinitions>
Height="3"
Width="200" <!-- 第一行:左下角状态,右下角百分比 -->
Margin="0,16,0,0" <Grid Grid.Row="0" Margin="0,0,0,8">
IsIndeterminate="True" <Grid.ColumnDefinitions>
Foreground="{DynamicResource AccentFillColorDefaultBrush}" <ColumnDefinition Width="*"/>
Background="{DynamicResource ControlStrokeColorDefaultBrush}" /> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 底部提示 -->
<TextBlock x:Name="DetailText" <!-- 左下角:状态文字 -->
Grid.Row="3" <TextBlock x:Name="StatusText"
FontSize="11" Grid.Column="0"
Foreground="{DynamicResource TextFillColorTertiaryBrush}" FontSize="11"
HorizontalAlignment="Center" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,12,0,24" Opacity="0.8"
Text="" /> 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> </Grid>
</Window> </Window>

View File

@@ -12,6 +12,22 @@ public partial class UpdateWindow : Window
public UpdateWindow() public UpdateWindow()
{ {
AvaloniaXamlLoader.Load(this); 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> /// <summary>
@@ -23,11 +39,11 @@ public partial class UpdateWindow : Window
{ {
var statusText = this.FindControl<TextBlock>("StatusText"); var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator"); 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; return;
} }
@@ -37,23 +53,13 @@ public partial class UpdateWindow : Window
{ {
progressIndicator.IsIndeterminate = false; progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progressPercent; progressIndicator.Value = progressPercent;
percentText.Text = $"{progressPercent}%";
} }
else else
{ {
progressIndicator.IsIndeterminate = true; 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 statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator"); var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
var detailText = this.FindControl<TextBlock>("DetailText"); var percentText = this.FindControl<TextBlock>("PercentText");
var titleText = this.FindControl<TextBlock>("TitleText"); 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"); Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
return; return;
@@ -77,7 +83,7 @@ public partial class UpdateWindow : Window
progressIndicator.IsIndeterminate = false; progressIndicator.IsIndeterminate = false;
progressIndicator.Value = 100; progressIndicator.Value = 100;
detailText.Text = ""; percentText.Text = "100%";
if (success) if (success)
{ {

View File

@@ -82,6 +82,7 @@ public sealed class UpdateWorkflowService
/// <summary> /// <summary>
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip). /// 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> /// </summary>
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release) 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); var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
return assetNames.Contains(DeltaManifestFileName)
&& assetNames.Contains(DeltaSignatureFileName) // Check for exact matches first (preferred)
&& assetNames.Contains(DeltaArchiveFileName); 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> /// <summary>
@@ -132,6 +191,24 @@ public sealed class UpdateWorkflowService
var downloadSource = state.UpdateDownloadSource; var downloadSource = state.UpdateDownloadSource;
var downloadThreads = state.UpdateDownloadThreads; 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) var requiredAssets = new Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
{ {
[DeltaManifestFileName] = null!, [DeltaManifestFileName] = null!,
@@ -141,9 +218,14 @@ public sealed class UpdateWorkflowService
foreach (var asset in checkResult.Release.Assets) foreach (var asset in checkResult.Release.Assets)
{ {
if (requiredAssets.ContainsKey(asset.Name)) // Match by actual asset name
foreach (var (key, actualName) in assetMap)
{ {
requiredAssets[asset.Name] = asset; if (string.Equals(asset.Name, actualName, StringComparison.OrdinalIgnoreCase))
{
requiredAssets[key] = asset;
break;
}
} }
} }

View File

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