From 4b897831de0ab0989987ef23773080cea0931927 Mon Sep 17 00:00:00 2001 From: lincube Date: Sat, 18 Apr 2026 00:49:03 +0800 Subject: [PATCH] =?UTF-8?q?changed.=E4=BC=98=E5=8C=96=E4=BA=86=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 187 ++++------ .../Services/LauncherFlowCoordinator.cs | 75 ++++ .../Services/LegacyVersionDetector.cs | 341 ++++++++++++++++++ .../Views/MigrationPromptWindow.axaml | 149 ++++++++ .../Views/MigrationPromptWindow.axaml.cs | 157 ++++++++ .../Services/UpdateWorkflowService.cs | 92 ++++- scripts/Generate-DeltaPackage.ps1 | 62 +++- 7 files changed, 939 insertions(+), 124 deletions(-) create mode 100644 LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs create mode 100644 LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml create mode 100644 LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a24016..ef6a4ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index cd0ef15..11e2822 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -41,6 +41,18 @@ internal sealed class LauncherFlowCoordinator // 清理旧版本,保留至少3个版本 _deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); + // 检测老版本安装(首次运行时) + if (_oobeStateService.IsFirstRun()) + { + var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation(); + if (legacyInfo != null) + { + var migrationResult = await ShowMigrationPromptAsync(legacyInfo); + // 无论用户选择什么,都继续启动流程 + Console.WriteLine($"[LauncherFlowCoordinator] Migration prompt result: {migrationResult}"); + } + } + // 使用传入的 Splash 窗口或创建新的 var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() => { @@ -341,6 +353,69 @@ internal sealed class LauncherFlowCoordinator return (result, customPath); } + /// + /// 显示迁移提示窗口 + /// + private async Task 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()) diff --git a/LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs b/LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs new file mode 100644 index 0000000..010edf3 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs @@ -0,0 +1,341 @@ +using System.Diagnostics; +using Microsoft.Win32; + +namespace LanMountainDesktop.Launcher.Services; + +/// +/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装 +/// +internal sealed class LegacyVersionDetector +{ + private const string LegacyAppName = "LanMountainDesktop"; + private const string LegacyExeName = "LanMountainDesktop.exe"; + + /// + /// 检测是否存在老版本安装 + /// + 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; + } + + /// + /// 从注册表检测安装信息 + /// + 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; + } + + /// + /// 从常见安装路径检测 + /// + 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; + } + + /// + /// 检测便携版安装 + /// + 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; + } + + /// + /// 查找卸载程序 + /// + 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; + } + + /// + /// 获取文件版本 + /// + private static string? TryGetFileVersion(string filePath) + { + try + { + var versionInfo = FileVersionInfo.GetVersionInfo(filePath); + return versionInfo.FileVersion; + } + catch + { + return null; + } + } + + /// + /// 判断是否为老版本(版本号 < 1.0.0) + /// + 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; + } + + /// + /// 打开卸载界面 + /// + 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 { } + } + } + + /// + /// 在资源管理器中显示老版本位置 + /// + 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}"); + } + } +} + +/// +/// 老版本信息 +/// +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; } +} + +/// +/// 老版本安装类型 +/// +public enum LegacyInstallType +{ + Registry, // 注册表安装版 + CommonPath, // 常见路径安装 + Portable // 便携版 +} diff --git a/LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml b/LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml new file mode 100644 index 0000000..25969e8 --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +