From eeb36b71935d0e50f6689ddaf3f2fe41d2096d24 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 19:16:20 +0000 Subject: [PATCH 1/8] feat(storage): custom install directory and in-app migration (#428) Resolves #428. ### Changes - Documented Velopack `--installto` / `-t` custom installation directory flag in `docs/velopack-integration.md` and release workflow template. - Implemented `IStorageMigrationService` and `StorageMigrationService` supporting pre-flight validation (volume space calculation with safety margin, active game launch and process checks, write permissions probe) and post-install relocation of binaries, CAS storage pool, and workspaces. - Added Migration section and commands (`BrowseMigrationTargetPathCommand`, `MigrateInstallationLocationCommand`) to Settings UI and ViewModel. - Added self-healing desktop shortcut repair on application startup across platforms. - Added unit and integration tests across Core, Windows, Linux, and macOS test suites. --- .github/workflows/release.yml | 2 +- .../Constants/SettingsConstants.cs | 5 + .../Constants/StorageMigrationConstants.cs | 52 ++ .../Storage/IStorageMigrationService.cs | 37 + .../StorageMigrationPreflightResult.cs | 54 ++ .../Storage/StorageMigrationProgress.cs | 22 + .../Models/Storage/StorageMigrationRequest.cs | 29 + .../GenHub.Linux/Resources/update_genhub.sh | 2 +- .../StorageMigrationConstantsTests.cs | 39 + .../ViewModels/MainViewModelTests.cs | 2 + .../ViewModels/SettingsViewModelTests.cs | 249 +----- .../SettingsViewModelMigrationTests.cs | 184 +++++ .../Storage/StorageMigrationServiceTests.cs | 264 +++++++ .../Shared/CompositionRootAssertions.cs | 2 + GenHub/GenHub/App.axaml.cs | 39 + .../Services/StorageMigrationService.cs | 745 ++++++++++++++++++ .../Settings/ViewModels/SettingsViewModel.cs | 141 ++++ .../Settings/Views/SettingsView.axaml | 58 ++ .../Settings/Views/SettingsView.axaml.cs | 1 + .../DependencyInjection/AppServices.cs | 1 + .../SharedViewModelModule.cs | 1 + .../StorageMigrationModule.cs | 23 + docs/velopack-integration.md | 27 +- 23 files changed, 1755 insertions(+), 224 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/StorageMigrationConstants.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Storage/IStorageMigrationService.cs create mode 100644 GenHub/GenHub.Core/Models/Storage/StorageMigrationPreflightResult.cs create mode 100644 GenHub/GenHub.Core/Models/Storage/StorageMigrationProgress.cs create mode 100644 GenHub/GenHub.Core/Models/Storage/StorageMigrationRequest.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/StorageMigrationConstantsTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Settings/SettingsViewModelMigrationTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/StorageMigrationServiceTests.cs create mode 100644 GenHub/GenHub/Common/Services/StorageMigrationService.cs create mode 100644 GenHub/GenHub/Infrastructure/DependencyInjection/StorageMigrationModule.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d424e2c1..4fb186a0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -367,6 +367,6 @@ jobs: ${{ steps.info.outputs.CHANGELOG }} ### 🔧 Assets - - **Installer:** [GenHub-win-Setup.exe](https://github.com/${{ github.repository }}/releases/download/v${{ steps.info.outputs.VERSION }}/GenHub-win-Setup.exe) + - **Installer:** [GenHub-win-Setup.exe](https://github.com/${{ github.repository }}/releases/download/v${{ steps.info.outputs.VERSION }}/GenHub-win-Setup.exe) *(supports custom install directory via `--installto ` / `-t `)* - **Portable:** [GenHub-${{ steps.info.outputs.VERSION }}-win-portable.zip](https://github.com/${{ github.repository }}/releases/download/v${{ steps.info.outputs.VERSION }}/GenHub-${{ steps.info.outputs.VERSION }}-win-portable.zip) files: final-assets/* diff --git a/GenHub/GenHub.Core/Constants/SettingsConstants.cs b/GenHub/GenHub.Core/Constants/SettingsConstants.cs index ac1d945c9..ddb6b8b86 100644 --- a/GenHub/GenHub.Core/Constants/SettingsConstants.cs +++ b/GenHub/GenHub.Core/Constants/SettingsConstants.cs @@ -25,6 +25,11 @@ public static class SettingsConstants /// public const string SectionDataDirectories = "data-directories"; + /// + /// Section ID for Migrate Installation. + /// + public const string SectionMigrateInstallation = "migrate-installation"; + /// /// Section ID for Logs. /// diff --git a/GenHub/GenHub.Core/Constants/StorageMigrationConstants.cs b/GenHub/GenHub.Core/Constants/StorageMigrationConstants.cs new file mode 100644 index 000000000..af1ffc1ff --- /dev/null +++ b/GenHub/GenHub.Core/Constants/StorageMigrationConstants.cs @@ -0,0 +1,52 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for the storage and installation migration feature. +/// +public static class StorageMigrationConstants +{ + /// + /// Update script resource name for Windows. + /// + public const string WindowsUpdateScriptName = "update_genhub.ps1"; + + /// + /// Update script resource name for Linux. + /// + public const string LinuxUpdateScriptName = "update_genhub.sh"; + + /// + /// Safety margin in bytes added to disk space calculations during migration preflight (50 MB). + /// + public const long DiskSpaceSafetyMarginBytes = 50 * 1024 * 1024; + + /// + /// Preflight stage name. + /// + public const string StagePreflight = "Preflight Validation"; + + /// + /// Staging data stage name. + /// + public const string StageStagingData = "Relocating Application Data"; + + /// + /// Relocating CAS storage and workspace stage name. + /// + public const string StageRelocatingStorage = "Relocating CAS and Workspaces"; + + /// + /// Preparing binary migration stage name. + /// + public const string StagePreparingBinaries = "Preparing Binary Migration"; + + /// + /// Launching migration assistant stage name. + /// + public const string StageLaunchingAssistant = "Launching Migration Assistant"; + + /// + /// Finalizing stage name. + /// + public const string StageFinalizing = "Finalizing Migration"; +} diff --git a/GenHub/GenHub.Core/Interfaces/Storage/IStorageMigrationService.cs b/GenHub/GenHub.Core/Interfaces/Storage/IStorageMigrationService.cs new file mode 100644 index 000000000..1d5282788 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Storage/IStorageMigrationService.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Storage; + +namespace GenHub.Core.Interfaces.Storage; + +/// +/// Provides backend operations for validating and migrating the GenHub installation and its storage locations. +/// +public interface IStorageMigrationService +{ + /// + /// Performs pre-flight checks before an installation migration is executed. + /// + /// The destination directory where GenHub should be relocated. + /// Whether the user also intends to relocate CAS storage and workspaces. + /// A token to monitor for cancellation requests. + /// An operation result containing the pre-flight check outcomes. + Task> ValidatePreflightAsync( + string targetPath, + bool relocateCasAndWorkspace, + CancellationToken cancellationToken = default); + + /// + /// Executes the installation migration, moving data, staging updates, and relaunching the application from the new path. + /// + /// The migration configuration request. + /// An optional progress reporter for tracking migration stages. + /// A token to monitor for cancellation requests. + /// An operation result indicating whether the migration was initiated successfully. + Task> MigrateAsync( + StorageMigrationRequest request, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Models/Storage/StorageMigrationPreflightResult.cs b/GenHub/GenHub.Core/Models/Storage/StorageMigrationPreflightResult.cs new file mode 100644 index 000000000..e3211539c --- /dev/null +++ b/GenHub/GenHub.Core/Models/Storage/StorageMigrationPreflightResult.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; + +namespace GenHub.Core.Models.Storage; + +/// +/// Represents the outcome of pre-flight validation checks before an installation migration is executed. +/// +public class StorageMigrationPreflightResult +{ + /// + /// Gets or sets a value indicating whether all pre-flight checks passed and migration is safe to proceed. + /// + public bool IsValid { get; set; } + + /// + /// Gets or sets the estimated disk space required for the migration in bytes. + /// + public long RequiredBytes { get; set; } + + /// + /// Gets or sets the available free disk space on the target volume in bytes. + /// + public long AvailableBytes { get; set; } + + /// + /// Gets or sets a value indicating whether the target drive has sufficient free space for the migration. + /// + public bool HasSufficientSpace { get; set; } + + /// + /// Gets or sets a value indicating whether GenHub has write permissions in the target location. + /// + public bool HasWritePermission { get; set; } + + /// + /// Gets or sets a value indicating whether active game instances or locking processes are currently running. + /// + public bool HasActiveProcesses { get; set; } + + /// + /// Gets or sets the list of active process names or launch descriptions detected during pre-flight. + /// + public IReadOnlyList ActiveProcessNames { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the selected target path is inside the current application directory. + /// + public bool IsTargetInsideApplicationDirectory { get; set; } + + /// + /// Gets or sets the detailed error or warning message if pre-flight checks failed. + /// + public string? ErrorMessage { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Storage/StorageMigrationProgress.cs b/GenHub/GenHub.Core/Models/Storage/StorageMigrationProgress.cs new file mode 100644 index 000000000..9702426a6 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Storage/StorageMigrationProgress.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Models.Storage; + +/// +/// Represents progress updates during an installation and storage migration operation. +/// +public class StorageMigrationProgress +{ + /// + /// Gets or sets the name of the current migration stage. + /// + public string Stage { get; set; } = string.Empty; + + /// + /// Gets or sets the progress completion percentage (0 - 100). + /// + public double Percentage { get; set; } + + /// + /// Gets or sets a descriptive status message for the current operation. + /// + public string Message { get; set; } = string.Empty; +} diff --git a/GenHub/GenHub.Core/Models/Storage/StorageMigrationRequest.cs b/GenHub/GenHub.Core/Models/Storage/StorageMigrationRequest.cs new file mode 100644 index 000000000..49e4435ef --- /dev/null +++ b/GenHub/GenHub.Core/Models/Storage/StorageMigrationRequest.cs @@ -0,0 +1,29 @@ +namespace GenHub.Core.Models.Storage; + +/// +/// Represents a request to migrate the GenHub installation and its storage components to a new target directory. +/// +public class StorageMigrationRequest +{ + /// + /// Gets or sets the target installation directory path. + /// + public string TargetPath { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether to also relocate the CAS storage pool and workspaces to the new target directory. + /// + public bool RelocateCasAndWorkspace { get; set; } + + /// + /// Gets or sets a value indicating whether to exit the application upon successfully launching the migration assistant. + /// Default is in production; can be set to for unit testing. + /// + public bool ExitApplicationOnSuccess { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to start the detached helper updater process. + /// Default is in production; can be set to for unit testing. + /// + public bool LaunchHelperProcess { get; set; } = true; +} diff --git a/GenHub/GenHub.Linux/Resources/update_genhub.sh b/GenHub/GenHub.Linux/Resources/update_genhub.sh index 8cb7b1145..a8215a994 100644 --- a/GenHub/GenHub.Linux/Resources/update_genhub.sh +++ b/GenHub/GenHub.Linux/Resources/update_genhub.sh @@ -33,7 +33,7 @@ if kill -0 $PROCESS_ID 2>/dev/null; then fi write_log "Ensuring all GenHub processes are closed..." -+pkill -f "^$CURRENT_EXE\$" || true +pkill -f "^$CURRENT_EXE\$" || true sleep 2 write_log "Starting file replacement..." diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/StorageMigrationConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/StorageMigrationConstantsTests.cs new file mode 100644 index 000000000..668cbf128 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/StorageMigrationConstantsTests.cs @@ -0,0 +1,39 @@ +using GenHub.Core.Constants; +using Xunit; + +namespace GenHub.Tests.Core.Constants; + +/// +/// Tests for . +/// +public class StorageMigrationConstantsTests +{ + /// + /// Verifies that all storage migration constants have valid and expected values. + /// + [Fact] + public void StorageMigrationConstants_HaveExpectedValues() + { + Assert.Multiple(() => + { + Assert.Equal("update_genhub.ps1", StorageMigrationConstants.WindowsUpdateScriptName); + Assert.Equal("update_genhub.sh", StorageMigrationConstants.LinuxUpdateScriptName); + Assert.True(StorageMigrationConstants.DiskSpaceSafetyMarginBytes > 0); + Assert.Equal("Preflight Validation", StorageMigrationConstants.StagePreflight); + Assert.Equal("Relocating Application Data", StorageMigrationConstants.StageStagingData); + Assert.Equal("Relocating CAS and Workspaces", StorageMigrationConstants.StageRelocatingStorage); + Assert.Equal("Preparing Binary Migration", StorageMigrationConstants.StagePreparingBinaries); + Assert.Equal("Launching Migration Assistant", StorageMigrationConstants.StageLaunchingAssistant); + Assert.Equal("Finalizing Migration", StorageMigrationConstants.StageFinalizing); + }); + } + + /// + /// Verifies that the safety margin is reasonable (at least 50MB). + /// + [Fact] + public void StorageMigrationConstants_SafetyMargin_IsReasonable() + { + Assert.True(StorageMigrationConstants.DiskSpaceSafetyMarginBytes >= 50 * 1024 * 1024L); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs index c476236bf..208f34ffd 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs @@ -223,6 +223,7 @@ private static (SettingsViewModel SettingsVm, Mock UserSet var mockStorageLocationService = new Mock(); var mockUserDataTracker = new Mock(); var mockDialogService = new Mock(); + var mockStorageMigrationService = new Mock(); var mockGitHubTokenStorage = new Mock(); var settingsVm = new SettingsViewModel( @@ -239,6 +240,7 @@ private static (SettingsViewModel SettingsVm, Mock UserSet mockStorageLocationService.Object, mockUserDataTracker.Object, mockDialogService.Object, + mockStorageMigrationService.Object, themeService: null, gitHubTokenStorage: mockGitHubTokenStorage.Object); return (settingsVm, mockUserSettings); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index a667962d9..543f70e7c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -41,6 +41,7 @@ public class SettingsViewModelTests private readonly Mock _mockStorageLocationService; private readonly Mock _mockUserDataTracker; private readonly Mock _mockDialogService; + private readonly Mock _mockStorageMigrationService; private readonly UserSettings _defaultSettings; /// @@ -61,6 +62,7 @@ public SettingsViewModelTests() _mockStorageLocationService = new Mock(); _mockUserDataTracker = new Mock(); _mockDialogService = new Mock(); + _mockStorageMigrationService = new Mock(); _defaultSettings = new UserSettings(); _mockConfigService.Setup(x => x.Get()).Returns(_defaultSettings); @@ -87,20 +89,7 @@ public void Constructor_LoadsSettingsFromUserSettingsService() _mockConfigService.Setup(x => x.Get()).Returns(customSettings); // Act - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + var viewModel = CreateViewModel(); // Assert Assert.Equal("Emerald", viewModel.Theme); @@ -117,24 +106,9 @@ public void Constructor_LoadsSettingsFromUserSettingsService() public async Task SaveSettingsCommand_UpdatesUserSettingsServiceAsync() { // Arrange - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object) - { - Theme = "Emerald", - MaxConcurrentDownloads = 5, - }; + var viewModel = CreateViewModel(); + viewModel.Theme = "Emerald"; + viewModel.MaxConcurrentDownloads = 5; _mockConfigService.Invocations.Clear(); @@ -154,25 +128,10 @@ public async Task SaveSettingsCommand_UpdatesUserSettingsServiceAsync() public async Task ResetToDefaultsCommand_ResetsAllPropertiesAsync() { // Arrange - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object) - { - Theme = "Emerald", - MaxConcurrentDownloads = 10, - EnableDetailedLogging = true, - }; + var viewModel = CreateViewModel(); + viewModel.Theme = "Emerald"; + viewModel.MaxConcurrentDownloads = 10; + viewModel.EnableDetailedLogging = true; // Act await Task.Run(() => viewModel.ResetToDefaultsCommand.Execute(null)); @@ -202,20 +161,7 @@ public void Constructor_LoadsPeriodicUpdateSettingsFromUserSettingsService() _mockConfigService.Setup(x => x.Get()).Returns(customSettings); // Act - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + var viewModel = CreateViewModel(); // Assert Assert.False(viewModel.AutoCheckForUpdatesPeriodically); @@ -230,24 +176,9 @@ public void Constructor_LoadsPeriodicUpdateSettingsFromUserSettingsService() public async Task SaveSettingsCommand_UpdatesPeriodicUpdateSettingsAsync() { // Arrange - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object) - { - AutoCheckForUpdatesPeriodically = false, - PeriodicUpdateCheckIntervalMinutes = 45, - }; + var viewModel = CreateViewModel(); + viewModel.AutoCheckForUpdatesPeriodically = false; + viewModel.PeriodicUpdateCheckIntervalMinutes = 45; UserSettings? capturedSettings = null; _mockConfigService.Setup(x => x.Update(It.IsAny>())) @@ -273,24 +204,8 @@ public async Task SaveSettingsCommand_UpdatesPeriodicUpdateSettingsAsync() public void MaxConcurrentDownloads_SetsValueWithinBounds() { // Arrange - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object) - { - // Act & Assert - Test lower bound - MaxConcurrentDownloads = 0, - }; + var viewModel = CreateViewModel(); + viewModel.MaxConcurrentDownloads = 0; Assert.Equal(1, viewModel.MaxConcurrentDownloads); // ViewModel clamps to 1 // Act & Assert - Test upper bound @@ -309,20 +224,7 @@ public void MaxConcurrentDownloads_SetsValueWithinBounds() public void AvailableThemes_ReturnsExpectedValues() { // Arrange - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + var viewModel = CreateViewModel(); // Act var themes = viewModel.AvailableThemes.Select(t => t.Id).ToList(); @@ -340,20 +242,7 @@ public void AvailableThemes_ReturnsExpectedValues() public void AvailableWorkspaceStrategies_ReturnsAllEnumValues() { // Arrange - _ = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + _ = CreateViewModel(); // Act var strategies = SettingsViewModel.AvailableWorkspaceStrategies.ToList(); @@ -373,20 +262,7 @@ public async Task SaveSettingsCommand_HandlesUserSettingsServiceExceptionAsync() { // Arrange _mockConfigService.Setup(x => x.SaveAsync(default)).ThrowsAsync(new IOException("Disk full")); - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + var viewModel = CreateViewModel(); // Act await Task.Run(() => viewModel.SaveSettingsCommand.Execute(null)); @@ -412,20 +288,7 @@ public void Constructor_HandlesUserSettingsServiceException() _mockConfigService.Setup(x => x.Get()).Throws(new Exception("Configuration error")); // Act - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + var viewModel = CreateViewModel(); // Assert - Should not throw and use defaults Assert.Equal("Dark", viewModel.Theme); @@ -453,20 +316,7 @@ public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabledAsyn .Setup(x => x.RunGarbageCollectionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(CasGarbageCollectionResult.CreateDisabled()); - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + var viewModel = CreateViewModel(); // Act await viewModel.DeleteCasStorageCommand.ExecuteAsync(null); @@ -497,20 +347,7 @@ public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabledAsyn public async Task UninstallGenHubCommand_CallsServiceAsync() { // Arrange - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object); + var viewModel = CreateViewModel(); // Act await viewModel.UninstallGenHubCommand.ExecuteAsync(null); @@ -705,21 +542,7 @@ public async Task SelectColorThemeCommand_UpdatesSelectedThemeAndPersistsAsync() var mockThemeService = new Mock(); mockThemeService.Setup(s => s.AvailableThemes).Returns(ThemeConstants.AllThemes); - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object, - mockThemeService.Object); + var viewModel = CreateViewModel(mockThemeService.Object); // Act await viewModel.SelectColorThemeCommand.ExecuteAsync(ThemeConstants.EmeraldTheme); @@ -743,24 +566,8 @@ public async Task ResetToDefaultsCommand_ResetsThemeToDefaultThemeAsync() var mockThemeService = new Mock(); mockThemeService.Setup(s => s.AvailableThemes).Returns(ThemeConstants.AllThemes); - var viewModel = new SettingsViewModel( - _mockConfigService.Object, - _mockLogger.Object, - _mockCasService.Object, - _mockProfileManager.Object, - _mockWorkspaceManager.Object, - _mockManifestPool.Object, - _mockUpdateManager.Object, - _mockNotificationService.Object, - _mockConfigurationProvider.Object, - _mockInstallationService.Object, - _mockStorageLocationService.Object, - _mockUserDataTracker.Object, - _mockDialogService.Object, - mockThemeService.Object) - { - Theme = "Emerald", - }; + var viewModel = CreateViewModel(mockThemeService.Object); + viewModel.Theme = "Emerald"; // Act await viewModel.ResetToDefaultsCommand.ExecuteAsync(null); @@ -784,7 +591,7 @@ private void SetupDeletableData() .ReturnsAsync(OperationResult>.CreateSuccess([new ContentManifest { Name = "manifest-to-delete" }])); } - private SettingsViewModel CreateViewModel() => new( + private SettingsViewModel CreateViewModel(IThemeService? themeService = null) => new( _mockConfigService.Object, _mockLogger.Object, _mockCasService.Object, @@ -797,5 +604,7 @@ private void SetupDeletableData() _mockInstallationService.Object, _mockStorageLocationService.Object, _mockUserDataTracker.Object, - _mockDialogService.Object); + _mockDialogService.Object, + _mockStorageMigrationService.Object, + themeService); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Settings/SettingsViewModelMigrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Settings/SettingsViewModelMigrationTests.cs new file mode 100644 index 000000000..0ee4c18ed --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Settings/SettingsViewModelMigrationTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Interfaces.UserData; +using GenHub.Core.Interfaces.Workspace; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Storage; +using GenHub.Features.AppUpdate.Interfaces; +using GenHub.Features.Settings.ViewModels; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Settings; + +/// +/// Unit tests for migration commands and properties in . +/// +public class SettingsViewModelMigrationTests +{ + private readonly Mock _mockConfigService; + private readonly Mock _mockCasService; + private readonly Mock _mockProfileManager; + private readonly Mock _mockWorkspaceManager; + private readonly Mock _mockManifestPool; + private readonly Mock _mockUpdateManager; + private readonly Mock _mockNotificationService; + private readonly Mock _mockConfigurationProvider; + private readonly Mock _mockInstallationService; + private readonly Mock _mockStorageLocationService; + private readonly Mock _mockUserDataTracker; + private readonly Mock _mockDialogService; + private readonly Mock _mockStorageMigrationService; + + public SettingsViewModelMigrationTests() + { + _mockConfigService = new Mock(); + _mockCasService = new Mock(); + _mockProfileManager = new Mock(); + _mockWorkspaceManager = new Mock(); + _mockManifestPool = new Mock(); + _mockUpdateManager = new Mock(); + _mockNotificationService = new Mock(); + _mockConfigurationProvider = new Mock(); + _mockInstallationService = new Mock(); + _mockStorageLocationService = new Mock(); + _mockUserDataTracker = new Mock(); + _mockDialogService = new Mock(); + _mockStorageMigrationService = new Mock(); + + _mockConfigService.Setup(x => x.Get()).Returns(new UserSettings()); + } + + [Fact] + public async Task MigrateInstallationLocationCommand_ShowsWarning_WhenTargetPathIsEmpty() + { + var vm = CreateViewModel(); + vm.MigrationTargetPath = string.Empty; + + await vm.MigrateInstallationLocationCommand.ExecuteAsync(null); + + _mockNotificationService.Verify( + x => x.ShowWarning("Migration Target Required", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + + _mockStorageMigrationService.Verify( + x => x.ValidatePreflightAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task MigrateInstallationLocationCommand_ShowsError_WhenPreflightValidationFails() + { + var vm = CreateViewModel(); + vm.MigrationTargetPath = "/valid/path"; + + var preflightResult = OperationResult.CreateSuccess( + StorageMigrationPreflightResult.Failure("Not enough space.")); + + _mockStorageMigrationService + .Setup(x => x.ValidatePreflightAsync("/valid/path", true, It.IsAny())) + .ReturnsAsync(preflightResult); + + await vm.MigrateInstallationLocationCommand.ExecuteAsync(null); + + _mockNotificationService.Verify( + x => x.ShowError("Migration Pre-flight Failed", "Not enough space.", It.IsAny(), It.IsAny()), + Times.Once); + + Assert.False(vm.IsMigrating); + } + + [Fact] + public async Task MigrateInstallationLocationCommand_Aborts_WhenUserDeclinesConfirmation() + { + var vm = CreateViewModel(); + vm.MigrationTargetPath = "/target/folder"; + + var preflightResult = OperationResult.CreateSuccess( + StorageMigrationPreflightResult.Success(100, 1000)); + + _mockStorageMigrationService + .Setup(x => x.ValidatePreflightAsync("/target/folder", true, It.IsAny())) + .ReturnsAsync(preflightResult); + + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + + await vm.MigrateInstallationLocationCommand.ExecuteAsync(null); + + _mockStorageMigrationService.Verify( + x => x.MigrateAsync(It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Never); + + Assert.False(vm.IsMigrating); + } + + [Fact] + public async Task MigrateInstallationLocationCommand_ExecutesMigration_WhenConfirmed() + { + var vm = CreateViewModel(); + vm.MigrationTargetPath = "/target/folder"; + vm.RelocateCasAndWorkspacesWithMigration = true; + + var preflightResult = OperationResult.CreateSuccess( + StorageMigrationPreflightResult.Success(100, 1000)); + + _mockStorageMigrationService + .Setup(x => x.ValidatePreflightAsync("/target/folder", true, It.IsAny())) + .ReturnsAsync(preflightResult); + + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + + _mockStorageMigrationService + .Setup(x => x.MigrateAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + await vm.MigrateInstallationLocationCommand.ExecuteAsync(null); + + _mockStorageMigrationService.Verify( + x => x.MigrateAsync( + It.Is(r => r.TargetPath == "/target/folder" && r.RelocateCasAndWorkspace), + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + private SettingsViewModel CreateViewModel() => new( + _mockConfigService.Object, + NullLogger.Instance, + _mockCasService.Object, + _mockProfileManager.Object, + _mockWorkspaceManager.Object, + _mockManifestPool.Object, + _mockUpdateManager.Object, + _mockNotificationService.Object, + _mockConfigurationProvider.Object, + _mockInstallationService.Object, + _mockStorageLocationService.Object, + _mockUserDataTracker.Object, + _mockDialogService.Object, + _mockStorageMigrationService.Object); +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/StorageMigrationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/StorageMigrationServiceTests.cs new file mode 100644 index 000000000..06d52fa27 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/StorageMigrationServiceTests.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Common.Services; +using GenHub.Core.Configuration; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameProcesses; +using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.GameProcess; +using GenHub.Core.Models.Launching; +using GenHub.Core.Models.Storage; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Storage; + +/// +/// Unit tests for . +/// +public class StorageMigrationServiceTests : IDisposable +{ + private readonly string _tempRoot; + private readonly string _appDataDir; + private readonly string _casDir; + private readonly string _workspaceDir; + private readonly Mock _mockConfigProvider; + private readonly Mock _mockUserSettingsService; + private readonly Mock _mockWritabilityProbe; + private readonly Mock _mockLaunchRegistry; + private readonly Mock _mockGameProcessManager; + private readonly UserSettings _userSettings; + + public StorageMigrationServiceTests() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "GenHubMigrationTests_" + Guid.NewGuid().ToString("N")); + _appDataDir = Path.Combine(_tempRoot, "AppData"); + _casDir = Path.Combine(_appDataDir, "cas-pool"); + _workspaceDir = Path.Combine(_appDataDir, "workspaces"); + + Directory.CreateDirectory(_appDataDir); + Directory.CreateDirectory(_casDir); + Directory.CreateDirectory(_workspaceDir); + + // Seed some sample data + File.WriteAllText(Path.Combine(_casDir, "sample_cas.bin"), "dummy cas content"); + File.WriteAllText(Path.Combine(_workspaceDir, "sample_ws.bin"), "dummy ws content"); + + _mockConfigProvider = new Mock(); + _mockConfigProvider.Setup(x => x.GetRootAppDataPath()).Returns(_appDataDir); + _mockConfigProvider.Setup(x => x.GetCasConfiguration()).Returns(new CasConfiguration { CasRootPath = _casDir }); + + _userSettings = new UserSettings { CasRootPath = _casDir, WorkspacePath = _workspaceDir }; + _mockUserSettingsService = new Mock(); + _mockUserSettingsService.Setup(x => x.Get()).Returns(_userSettings); + + _mockWritabilityProbe = new Mock(); + _mockWritabilityProbe.Setup(x => x.CanCreateStorageAt(It.IsAny())).Returns(true); + + _mockLaunchRegistry = new Mock(); + _mockLaunchRegistry + .Setup(x => x.GetAllActiveLaunchesAsync(It.IsAny())) + .ReturnsAsync([]); + + _mockGameProcessManager = new Mock(); + _mockGameProcessManager + .Setup(x => x.GetActiveProcessesAsync(It.IsAny())) + .ReturnsAsync(new List()); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + catch + { + // Best effort cleanup + } + + GC.SuppressFinalize(this); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidatePreflightAsync_ThrowsArgumentException_WhenTargetPathIsNullOrWhitespace(string? invalidPath) + { + var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.ValidatePreflightAsync(invalidPath!)); + } + + [Fact] + public async Task ValidatePreflightAsync_Fails_WhenTargetPathIsInsideApplicationDirectory() + { + var service = CreateService(); + var currentAppDir = AppContext.BaseDirectory; + var subDirInsideApp = Path.Combine(currentAppDir, "subfolder"); + + var result = await service.ValidatePreflightAsync(subDirInsideApp); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.False(result.Data.IsValid); + Assert.True(result.Data.IsTargetInsideApplicationDirectory); + Assert.NotNull(result.Data.ErrorMessage); + } + + [Fact] + public async Task ValidatePreflightAsync_Fails_WhenTargetDirectoryIsNotWritable() + { + var service = CreateService(); + var targetPath = Path.Combine(_tempRoot, "NewInstallDir"); + + _mockWritabilityProbe.Setup(x => x.CanCreateStorageAt(targetPath)).Returns(false); + + var result = await service.ValidatePreflightAsync(targetPath); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.False(result.Data.IsValid); + Assert.False(result.Data.HasWritePermission); + Assert.Contains("permission", result.Data.ErrorMessage ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ValidatePreflightAsync_Fails_WhenActiveLaunchesExist() + { + var service = CreateService(); + var targetPath = Path.Combine(_tempRoot, "NewInstallDir"); + + _mockLaunchRegistry + .Setup(x => x.GetAllActiveLaunchesAsync(It.IsAny())) + .ReturnsAsync([new ActiveLaunchEntry { ProfileId = "profile-1", ProcessId = 1234 }]); + + var result = await service.ValidatePreflightAsync(targetPath); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.False(result.Data.IsValid); + Assert.True(result.Data.HasActiveProcesses); + Assert.Contains("active game", result.Data.ErrorMessage ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ValidatePreflightAsync_Fails_WhenGameProcessesAreActive() + { + var service = CreateService(); + var targetPath = Path.Combine(_tempRoot, "NewInstallDir"); + + _mockGameProcessManager + .Setup(x => x.GetActiveProcessesAsync(It.IsAny())) + .ReturnsAsync([new GameProcessInfo { ProcessId = 5678, ExecutablePath = "game.dat" }]); + + var result = await service.ValidatePreflightAsync(targetPath); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.False(result.Data.IsValid); + Assert.True(result.Data.HasActiveProcesses); + } + + [Fact] + public async Task ValidatePreflightAsync_Succeeds_WhenTargetIsValid() + { + var service = CreateService(); + var targetPath = Path.Combine(_tempRoot, "NewInstallDir"); + + var result = await service.ValidatePreflightAsync(targetPath, relocateCasAndWorkspace: true); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.True(result.Data.IsValid); + Assert.True(result.Data.HasWritePermission); + Assert.False(result.Data.HasActiveProcesses); + Assert.False(result.Data.IsTargetInsideApplicationDirectory); + } + + [Fact] + public async Task MigrateAsync_ThrowsArgumentNullException_WhenRequestIsNull() + { + var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.MigrateAsync(null!)); + } + + [Fact] + public async Task MigrateAsync_Fails_WhenPreflightValidationFails() + { + var service = CreateService(); + var targetPath = Path.Combine(_tempRoot, "NewInstallDir"); + + _mockWritabilityProbe.Setup(x => x.CanCreateStorageAt(targetPath)).Returns(false); + + var request = new StorageMigrationRequest + { + TargetPath = targetPath, + RelocateCasAndWorkspace = false, + ExitApplicationOnSuccess = false, + LaunchHelperProcess = false, + }; + + var result = await service.MigrateAsync(request); + + Assert.False(result.Success); + Assert.NotNull(result.FirstError); + } + + [Fact] + public async Task MigrateAsync_RelocatesCasAndWorkspaces_WhenRequested() + { + var service = CreateService(); + var targetPath = Path.Combine(_tempRoot, "NewInstallDir"); + Directory.CreateDirectory(targetPath); + + var request = new StorageMigrationRequest + { + TargetPath = targetPath, + RelocateCasAndWorkspace = true, + ExitApplicationOnSuccess = false, + LaunchHelperProcess = false, + }; + + var result = await service.MigrateAsync(request); + + Assert.True(result.Success); + + var expectedNewCas = Path.Combine(targetPath, DirectoryNames.CasPool); + var expectedNewWs = Path.Combine(targetPath, DirectoryNames.Workspaces); + + Assert.True(Directory.Exists(expectedNewCas)); + Assert.True(File.Exists(Path.Combine(expectedNewCas, "sample_cas.bin"))); + + Assert.True(Directory.Exists(expectedNewWs)); + Assert.True(File.Exists(Path.Combine(expectedNewWs, "sample_ws.bin"))); + + _mockUserSettingsService.Verify(x => x.Update(It.IsAny>()), Times.Once); + _mockUserSettingsService.Verify(x => x.SaveAsync(It.IsAny()), Times.Once); + } + + private StorageMigrationService CreateService() + { + return new StorageMigrationService( + _mockConfigProvider.Object, + _mockUserSettingsService.Object, + _mockWritabilityProbe.Object, + _mockLaunchRegistry.Object, + _mockGameProcessManager.Object, + NullLogger.Instance); + } +} diff --git a/GenHub/GenHub.Tests/Shared/CompositionRootAssertions.cs b/GenHub/GenHub.Tests/Shared/CompositionRootAssertions.cs index eef502dd3..bb4c4d8be 100644 --- a/GenHub/GenHub.Tests/Shared/CompositionRootAssertions.cs +++ b/GenHub/GenHub.Tests/Shared/CompositionRootAssertions.cs @@ -7,6 +7,7 @@ using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Interfaces.Shortcuts; +using GenHub.Core.Interfaces.Storage; using GenHub.Core.Interfaces.Workspace; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.Settings.ViewModels; @@ -48,6 +49,7 @@ public static class CompositionRootAssertions typeof(IFileOperationsService), typeof(IGamePathProvider), typeof(IShortcutService), + typeof(IStorageMigrationService), typeof(ISymlinkCapabilityProvider), typeof(IVelopackUpdateManager), ]; diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 434d14a6b..8e3e28b1b 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -12,6 +12,7 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Shortcuts; using GenHub.Core.Models.Enums; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -73,6 +74,9 @@ public override void OnFrameworkInitializationCompleted() // Handle startup arguments sequentially (launch profile, then subscription if present) SafeFireAndForget(HandleStartupArgsAsync(desktop.Args, mainWindow), nameof(HandleStartupArgsAsync)); + + // Repair desktop shortcuts if application executable has moved/relocated + SafeFireAndForget(RepairShortcutsAsync(), nameof(RepairShortcutsAsync)); } base.OnFrameworkInitializationCompleted(); @@ -354,4 +358,39 @@ private async Task HandleSubscriptionUrlAsync(string subscriptionUrl, MainWindow logger?.LogError(ex, "Exception while handling subscription URL {Url}", subscriptionUrl); } } + + private async Task RepairShortcutsAsync() + { + if (OperatingSystem.IsMacOS()) + { + return; + } + + try + { + var shortcutService = _serviceProvider.GetService(); + var profileManager = _serviceProvider.GetService(); + if (shortcutService == null || profileManager == null) + { + return; + } + + var profilesResult = await profileManager.GetAllProfilesAsync(); + if (profilesResult.Success && profilesResult.Data != null) + { + foreach (var profile in profilesResult.Data) + { + if (await shortcutService.ShortcutExistsAsync(profile)) + { + await shortcutService.CreateDesktopShortcutAsync(profile); + } + } + } + } + catch (Exception ex) + { + var logger = _serviceProvider.GetService>(); + logger?.LogWarning(ex, "Failed to repair desktop shortcuts during startup"); + } + } } diff --git a/GenHub/GenHub/Common/Services/StorageMigrationService.cs b/GenHub/GenHub/Common/Services/StorageMigrationService.cs new file mode 100644 index 000000000..aa0cf7f53 --- /dev/null +++ b/GenHub/GenHub/Common/Services/StorageMigrationService.cs @@ -0,0 +1,745 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Storage; +using Microsoft.Extensions.Logging; + +namespace GenHub.Common.Services; + +/// +/// Service that manages pre-flight validation and post-install relocation of the GenHub installation directory, +/// CAS pools, workspaces, and application data. +/// +public class StorageMigrationService( + IConfigurationProviderService configurationProvider, + IUserSettingsService userSettingsService, + ICasPoolManager casPoolManager, + ILaunchRegistry launchRegistry, + IGameProcessManager gameProcessManager, + IStorageWritabilityProbe writabilityProbe, + ILogger logger) : IStorageMigrationService +{ + /// + public async Task> ValidatePreflightAsync( + string targetPath, + bool relocateCasAndWorkspace, + CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrWhiteSpace(targetPath)) + { + return OperationResult.CreateSuccess(new StorageMigrationPreflightResult + { + IsValid = false, + ErrorMessage = "Target installation directory path cannot be empty.", + }); + } + + var normalizedTarget = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetPath)); + var appBaseDir = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory)); + var sourceRoot = GetSourceRootDirectory(); + + // Check if target is same as current installation root + if (normalizedTarget.Equals(sourceRoot, PathHelper.PathComparison) || + normalizedTarget.Equals(appBaseDir, PathHelper.PathComparison)) + { + return OperationResult.CreateSuccess(new StorageMigrationPreflightResult + { + IsValid = false, + ErrorMessage = "The target directory is the same as the current installation directory.", + }); + } + + // Check if target is inside the current application directory + var isTargetInsideApp = IsInsideDirectory(normalizedTarget, sourceRoot) || IsInsideDirectory(normalizedTarget, appBaseDir); + if (isTargetInsideApp) + { + return OperationResult.CreateSuccess(new StorageMigrationPreflightResult + { + IsValid = false, + IsTargetInsideApplicationDirectory = true, + ErrorMessage = "The target directory cannot be located inside the current installation directory.", + }); + } + + // Check if current installation directory is inside target (e.g. target is root or parent folder) + if (IsInsideDirectory(sourceRoot, normalizedTarget) || IsInsideDirectory(appBaseDir, normalizedTarget)) + { + return OperationResult.CreateSuccess(new StorageMigrationPreflightResult + { + IsValid = false, + ErrorMessage = "The target directory cannot be a parent of the current installation directory.", + }); + } + + // Check write permission at target + var hasWritePermission = writabilityProbe.CanCreateStorageAt(normalizedTarget); + + // Check for active game launches and running game processes + var activeLaunches = (await launchRegistry.GetAllActiveLaunchesAsync()).ToList(); + var activeProcessesResult = await gameProcessManager.GetActiveProcessesAsync(cancellationToken); + var activeProcesses = activeProcessesResult.Success && activeProcessesResult.Data != null + ? activeProcessesResult.Data + : []; + + var hasActiveProcesses = activeLaunches.Count > 0 || activeProcesses.Count > 0; + var processNames = new List(); + + foreach (var launch in activeLaunches) + { + processNames.Add($"Launch: {launch.ProfileId}"); + } + + foreach (var proc in activeProcesses) + { + processNames.Add($"Process: {proc.ProcessName} (PID: {proc.ProcessId})"); + } + + // Calculate required disk space + long requiredBytes = CalculateDirectorySize(sourceRoot); + + if (relocateCasAndWorkspace) + { + var casRoot = configurationProvider.GetCasConfiguration().CasRootPath; + if (!string.IsNullOrWhiteSpace(casRoot) && Directory.Exists(casRoot) && !IsInsideDirectory(casRoot, sourceRoot)) + { + requiredBytes += CalculateDirectorySize(casRoot); + } + + var workspaceRoot = userSettingsService.Get().WorkspacePath; + if (!string.IsNullOrWhiteSpace(workspaceRoot) && Directory.Exists(workspaceRoot) && !IsInsideDirectory(workspaceRoot, sourceRoot)) + { + requiredBytes += CalculateDirectorySize(workspaceRoot); + } + } + + requiredBytes += StorageMigrationConstants.DiskSpaceSafetyMarginBytes; + + // Get available free disk space on the target volume + long availableBytes = GetAvailableFreeSpace(normalizedTarget); + var hasSufficientSpace = availableBytes >= requiredBytes; + + // Compose error message if any check failed + string? errorMessage = null; + if (!hasWritePermission) + { + errorMessage = "The target directory is not writable or cannot be created."; + } + else if (hasActiveProcesses) + { + errorMessage = "Active game instances or launches are currently running. Please close all running games before migrating."; + } + else if (!hasSufficientSpace) + { + var reqMb = requiredBytes / ConversionConstants.BytesPerMegabyte; + var availMb = availableBytes / ConversionConstants.BytesPerMegabyte; + errorMessage = $"Insufficient free disk space on target volume. Required: {reqMb} MB, Available: {availMb} MB."; + } + + var isValid = hasWritePermission && !hasActiveProcesses && hasSufficientSpace && !isTargetInsideApp; + + var result = new StorageMigrationPreflightResult + { + IsValid = isValid, + RequiredBytes = requiredBytes, + AvailableBytes = availableBytes, + HasSufficientSpace = hasSufficientSpace, + HasWritePermission = hasWritePermission, + HasActiveProcesses = hasActiveProcesses, + ActiveProcessNames = processNames, + IsTargetInsideApplicationDirectory = isTargetInsideApp, + ErrorMessage = errorMessage, + }; + + return OperationResult.CreateSuccess(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to perform migration pre-flight checks for target: {TargetPath}", targetPath); + return OperationResult.CreateFailure($"Pre-flight validation error: {ex.Message}"); + } + } + + /// + public async Task> MigrateAsync( + StorageMigrationRequest request, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + logger.LogInformation( + "Starting installation migration to {TargetPath} (RelocateStorage: {RelocateStorage})", + request.TargetPath, + request.RelocateCasAndWorkspace); + + // Phase 1: Pre-flight validation + progress?.Report(new StorageMigrationProgress + { + Stage = StorageMigrationConstants.StagePreflight, + Percentage = 10, + Message = "Validating target directory and pre-flight constraints...", + }); + + var preflight = await ValidatePreflightAsync(request.TargetPath, request.RelocateCasAndWorkspace, cancellationToken); + if (!preflight.Success || preflight.Data?.IsValid != true) + { + var error = preflight.Data?.ErrorMessage ?? preflight.FirstError ?? "Pre-flight validation failed."; + logger.LogError("Migration pre-flight validation failed: {Error}", error); + return OperationResult.CreateFailure(error); + } + + var targetRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(request.TargetPath)); + var sourceRoot = GetSourceRootDirectory(); + + // Phase 2: Relocate CAS and Workspaces if requested + if (request.RelocateCasAndWorkspace) + { + progress?.Report(new StorageMigrationProgress + { + Stage = StorageMigrationConstants.StageRelocatingStorage, + Percentage = 25, + Message = "Relocating CAS storage pool and game workspaces...", + }); + + var currentCasRoot = configurationProvider.GetCasConfiguration().CasRootPath; + var currentWorkspaceRoot = userSettingsService.Get().WorkspacePath; + + var targetDataDir = Path.Combine(targetRoot, DirectoryNames.Data); + var targetCasRoot = Path.Combine(targetDataDir, DirectoryNames.CasPool); + var targetWorkspaceRoot = Path.Combine(targetDataDir, DirectoryNames.Workspaces); + + // Move CAS pool if existing and not already inside source root + if (!string.IsNullOrWhiteSpace(currentCasRoot) && Directory.Exists(currentCasRoot) && !IsInsideDirectory(currentCasRoot, sourceRoot)) + { + logger.LogInformation("Moving CAS storage pool from {Source} to {Target}", currentCasRoot, targetCasRoot); + MigrateDirectorySafely(currentCasRoot, targetCasRoot); + } + + progress?.Report(new StorageMigrationProgress + { + Stage = StorageMigrationConstants.StageRelocatingStorage, + Percentage = 45, + Message = "Relocating game workspaces...", + }); + + // Move workspaces if existing and not already inside source root + if (!string.IsNullOrWhiteSpace(currentWorkspaceRoot) && Directory.Exists(currentWorkspaceRoot) && !IsInsideDirectory(currentWorkspaceRoot, sourceRoot)) + { + logger.LogInformation("Moving workspaces from {Source} to {Target}", currentWorkspaceRoot, targetWorkspaceRoot); + MigrateDirectorySafely(currentWorkspaceRoot, targetWorkspaceRoot); + } + + // Update and persist settings + await userSettingsService.TryUpdateAndSaveAsync(settings => + { + settings.CasConfiguration.CasRootPath = targetCasRoot; + settings.WorkspacePath = targetWorkspaceRoot; + settings.ApplicationDataPath = targetDataDir; + return true; + }); + + // Reinitialize CAS pool with the new path + casPoolManager.ReinitializeInstallationPool(); + } + + // Phase 3: Prepare binary migration and updater script + progress?.Report(new StorageMigrationProgress + { + Stage = StorageMigrationConstants.StagePreparingBinaries, + Percentage = 65, + Message = "Staging binary migration helper script...", + }); + + var scriptPath = PrepareMigrationScript(sourceRoot, targetRoot); + + // Phase 4: Launch helper process + progress?.Report(new StorageMigrationProgress + { + Stage = StorageMigrationConstants.StageLaunchingAssistant, + Percentage = 85, + Message = "Launching migration assistant process...", + }); + + if (request.LaunchHelperProcess) + { + LaunchHelperProcess(scriptPath); + } + + // Phase 5: Finalize and exit application + progress?.Report(new StorageMigrationProgress + { + Stage = StorageMigrationConstants.StageFinalizing, + Percentage = 100, + Message = "Migration staged successfully. GenHub will now restart from the new location.", + }); + + if (request.ExitApplicationOnSuccess) + { + ExitApplication(); + } + + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + logger.LogError(ex, "Installation migration failed unexpectedly for target {TargetPath}", request.TargetPath); + return OperationResult.CreateFailure($"Migration failed: {ex.Message}"); + } + } + + /// + /// Gets the top-level Velopack installation root directory or falls back to AppContext.BaseDirectory. + /// + /// The source root directory path. + internal static string GetSourceRootDirectory() + { + var appBaseDir = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory)); + var parentDir = Directory.GetParent(appBaseDir)?.FullName; + + if (parentDir != null) + { + // Check for Velopack markers (Update.exe, packages dir, app-* directories, or companion executable) + var hasUpdateExe = File.Exists(Path.Combine(parentDir, "Update.exe")); + var hasPackagesDir = Directory.Exists(Path.Combine(parentDir, "packages")); + var hasAppDirs = Directory.GetDirectories(parentDir, "app-*").Length > 0; + + if (hasUpdateExe || hasPackagesDir || hasAppDirs) + { + return parentDir; + } + } + + return appBaseDir; + } + + /// + /// Calculates the relative path of the current process executable from the given root directory. + /// + /// The source root directory. + /// The relative executable path. + internal static string GetRelativeExecutablePath(string sourceRoot) + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + return OperatingSystem.IsWindows() ? "GenHub.Windows.exe" : "GenHub.Linux"; + } + + try + { + return Path.GetRelativePath(sourceRoot, processPath); + } + catch + { + return Path.GetFileName(processPath); + } + } + + /// + /// Checks whether a path is equal to or contained within a parent directory. + /// + /// The path to test. + /// The parent directory path. + /// if the path is inside or equal to the parent directory; otherwise, . + internal static bool IsInsideDirectory(string path, string parentDirectory) + { + if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(parentDirectory)) + { + return false; + } + + var normalizedPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + var normalizedParent = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentDirectory)); + + return normalizedPath.Equals(normalizedParent, PathHelper.PathComparison) || + normalizedPath.StartsWith(normalizedParent + Path.DirectorySeparatorChar, PathHelper.PathComparison); + } + + /// + /// Calculates the total size of all files in a directory in bytes. + /// + /// The directory path. + /// Total size in bytes. + internal static long CalculateDirectorySize(string directoryPath) + { + if (!Directory.Exists(directoryPath)) + { + return 0; + } + + try + { + var dirInfo = new DirectoryInfo(directoryPath); + return dirInfo.EnumerateFiles("*", SearchOption.AllDirectories).Sum(fi => fi.Length); + } + catch + { + return 0; + } + } + + /// + /// Gets the available free space on the drive containing the given path. + /// + /// The filesystem path. + /// Available free space in bytes. + internal static long GetAvailableFreeSpace(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root)) + { + var drive = new DriveInfo(root); + if (drive.IsReady) + { + return drive.AvailableFreeSpace; + } + } + } + catch + { + // Ignore exceptions and assume ample space + } + + return long.MaxValue; + } + + /// + /// Safely moves a directory with rollback on copy failure. + /// + /// The source directory path. + /// The destination directory path. + internal static void MigrateDirectorySafely(string sourceDir, string destDir) + { + if (!Directory.Exists(sourceDir)) + { + return; + } + + if (Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + else + { + try + { + Directory.Move(sourceDir, destDir); + return; + } + catch (IOException) + { + // Move across volumes or locked directory fallback to copy-then-delete + Directory.CreateDirectory(destDir); + } + } + + CopyDirectoryRecursive(sourceDir, destDir); + TryDeleteDirectory(sourceDir); + } + + private static void CopyDirectoryRecursive(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + + foreach (var file in Directory.GetFiles(sourceDir)) + { + var destFile = Path.Combine(destDir, Path.GetFileName(file)); + File.Copy(file, destFile, overwrite: true); + } + + foreach (var subDir in Directory.GetDirectories(sourceDir)) + { + var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir)); + CopyDirectoryRecursive(subDir, destSubDir); + } + } + + private static void TryDeleteDirectory(string directoryPath) + { + try + { + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, recursive: true); + } + } + catch + { + // Best effort cleanup + } + } + + private string PrepareMigrationScript(string sourceRoot, string targetRoot) + { + var isWindows = OperatingSystem.IsWindows(); + var scriptName = isWindows + ? StorageMigrationConstants.WindowsUpdateScriptName + : StorageMigrationConstants.LinuxUpdateScriptName; + + var scriptTemplate = GetScriptResource(scriptName) ?? GetFallbackScriptTemplate(isWindows); + + var relativeExe = GetRelativeExecutablePath(sourceRoot); + var targetExe = Path.Combine(targetRoot, relativeExe); + + var logFile = Path.Combine(Path.GetTempPath(), $"genhub_migration_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log"); + var backupDir = Path.Combine(Path.GetTempPath(), $"genhub_migration_backup_{Guid.NewGuid():N}"); + + var scriptContent = scriptTemplate + .Replace("{{LOG_FILE}}", logFile, StringComparison.Ordinal) + .Replace("{{PROCESS_ID}}", Environment.ProcessId.ToString(), StringComparison.Ordinal) + .Replace("{{SOURCE_DIR}}", sourceRoot, StringComparison.Ordinal) + .Replace("{{TARGET_DIR}}", targetRoot, StringComparison.Ordinal) + .Replace("{{CURRENT_EXE}}", targetExe, StringComparison.Ordinal) + .Replace("{{BACKUP_DIR}}", backupDir, StringComparison.Ordinal); + + var tempDir = Path.Combine(Path.GetTempPath(), $"genhub_migrate_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + var scriptFilePath = Path.Combine(tempDir, scriptName); + File.WriteAllText(scriptFilePath, scriptContent); + + if (!isWindows) + { + try + { + File.SetUnixFileMode( + scriptFilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to set Unix permissions on migration script {Path}", scriptFilePath); + } + } + + logger.LogInformation("Migration script generated at {ScriptPath}", scriptFilePath); + return scriptFilePath; + } + + private string? GetScriptResource(string scriptName) + { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var resourceNames = assembly.GetManifestResourceNames(); + var match = resourceNames.FirstOrDefault(n => n.EndsWith(scriptName, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + using var stream = assembly.GetManifestResourceStream(match); + if (stream != null) + { + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + } + } + catch + { + // Continue searching other assemblies + } + } + + return null; + } + + private static string GetFallbackScriptTemplate(bool isWindows) + { + if (isWindows) + { + return @"# GenHub Windows Update PowerShell Script +$ErrorActionPreference = 'SilentlyContinue' +$LogFile = ""{{LOG_FILE}}"" +$ProcessId = {{PROCESS_ID}} +$SourceDir = ""{{SOURCE_DIR}}"" +$TargetDir = ""{{TARGET_DIR}}"" +$CurrentExe = ""{{CURRENT_EXE}}"" +$BackupDir = ""{{BACKUP_DIR}}"" + +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + ""[$timestamp] $Message"" | Out-File -FilePath $LogFile -Append -Encoding UTF8 +} + +Write-Log ""GenHub Migration Script Started"" +Wait-Process -Id $ProcessId -Timeout 60 -ErrorAction SilentlyContinue +$process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue +if ($process) { + Stop-Process -Id $ProcessId -Force + Start-Sleep -Seconds 2 +} +Get-Process -Name ""GenHub*"" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 2 + +try { + New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null + if (Test-Path $TargetDir) { + Copy-Item -Path ""$TargetDir\*"" -Destination $BackupDir -Recurse -Force + } + Copy-Item -Path ""$SourceDir\*"" -Destination $TargetDir -Recurse -Force + Write-Log ""Migration completed successfully"" + if (Test-Path $CurrentExe) { + $exeDir = Split-Path -Path $CurrentExe -Parent + Start-Process -FilePath $CurrentExe -WorkingDirectory $exeDir + } +} +catch { + Write-Log ""Migration failed: $($_.Exception.Message)"" + if (Test-Path $BackupDir) { + Copy-Item -Path ""$BackupDir\*"" -Destination $TargetDir -Recurse -Force + } +} +finally { + if (Test-Path $SourceDir) { + Remove-Item -Path $SourceDir -Recurse -Force + } + $updaterDir = Split-Path -Path $MyInvocation.MyCommand.Path -Parent + Start-Sleep -Seconds 2 + if (Test-Path $updaterDir) { + Remove-Item -Path $updaterDir -Recurse -Force + } +} +"; + } + + return @"#!/bin/bash +LOG_FILE=""{{LOG_FILE}}"" +PROCESS_ID={{PROCESS_ID}} +SOURCE_DIR=""{{SOURCE_DIR}}"" +TARGET_DIR=""{{TARGET_DIR}}"" +CURRENT_EXE=""{{CURRENT_EXE}}"" +BACKUP_DIR=""{{BACKUP_DIR}}"" + +write_log() { + echo ""[$(date '+%Y-%m-%d %H:%M:%S')] $1"" >> ""$LOG_FILE"" +} + +write_log ""GenHub Linux Migration Script Started"" +for i in {1..60}; do + if ! kill -0 $PROCESS_ID 2>/dev/null; then + break + fi + sleep 1 +done + +if kill -0 $PROCESS_ID 2>/dev/null; then + kill -TERM $PROCESS_ID 2>/dev/null + sleep 2 + kill -KILL $PROCESS_ID 2>/dev/null +fi + +pkill -f ""^$CURRENT_EXE\$"" || true +sleep 2 + +mkdir -p ""$BACKUP_DIR"" +if [ -d ""$TARGET_DIR"" ]; then + cp -r ""$TARGET_DIR""/* ""$BACKUP_DIR"" 2>/dev/null || true +fi + +if ! cp -r ""$SOURCE_DIR""/* ""$TARGET_DIR"" 2>&1; then + write_log ""Error: Failed to copy migration files"" + if [ -d ""$BACKUP_DIR"" ]; then + cp -r ""$BACKUP_DIR""/* ""$TARGET_DIR"" 2>/dev/null || true + fi + exit 1 +fi + +if [ -f ""$CURRENT_EXE"" ]; then + EXE_DIR=$(dirname ""$CURRENT_EXE"") + EXE_NAME=$(basename ""$CURRENT_EXE"") + cd ""$EXE_DIR"" + if [ ! -x ""$EXE_NAME"" ]; then + chmod +x ""$EXE_NAME"" + fi + nohup ""./$EXE_NAME"" > /dev/null 2>&1 & +fi + +rm -rf ""$SOURCE_DIR"" 2>/dev/null || true +UPDATER_DIR=$(dirname ""$0"") +sleep 2 +rm -rf ""$UPDATER_DIR"" 2>/dev/null || true +"; + } + + private void LaunchHelperProcess(string scriptPath) + { + try + { + ProcessStartInfo startInfo; + if (OperatingSystem.IsWindows()) + { + startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-ExecutionPolicy Bypass -NoProfile -File \"{scriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + }; + } + else + { + startInfo = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"\"{scriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + }; + } + + Process.Start(startInfo); + logger.LogInformation("Started detached helper migration process: {ScriptPath}", scriptPath); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start helper migration process for {ScriptPath}", scriptPath); + throw; + } + } + + private void ExitApplication() + { + try + { + logger.LogInformation("Exiting GenHub to allow migration helper script to proceed."); + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(0); + } + else + { + Environment.Exit(0); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Exception during application exit for migration; calling Environment.Exit"); + Environment.Exit(0); + } + } +} diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index d411e72e7..99f9d5ece 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -26,6 +26,7 @@ using GenHub.Core.Messages; using GenHub.Core.Models.AppUpdate; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Storage; using GenHub.Core.Models.Theming; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.Settings.Models; @@ -64,6 +65,7 @@ public partial class SettingsViewModel : ObservableObject, IDisposable new(SettingsConstants.SectionDownloads, "Downloads", "M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"), new(SettingsConstants.SectionAppearance, "Appearance", "M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"), new(SettingsConstants.SectionDataDirectories, "Data Directories", "M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z"), + new(SettingsConstants.SectionMigrateInstallation, "Migrate Installation", "M20,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8A2,2 0 0,0 20,6M12,17L8,13H11V9H13V13H16L12,17Z"), new(SettingsConstants.SectionLogs, "Logs", "M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"), new(SettingsConstants.SectionPerformance, "Performance", "M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z"), new(SettingsConstants.SectionCas, "CAS Storage", "M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z"), @@ -89,6 +91,7 @@ public partial class SettingsViewModel : ObservableObject, IDisposable private readonly IStorageLocationService _storageLocationService; private readonly IUserDataTracker _userDataTracker; private readonly IDialogService _dialogService; + private readonly IStorageMigrationService _storageMigrationService; private readonly IThemeService? _themeService; private bool _isViewVisible; @@ -229,6 +232,21 @@ public partial class SettingsViewModel : ObservableObject, IDisposable [ObservableProperty] private string _patStatusMessage = string.Empty; + [ObservableProperty] + private string _migrationTargetPath = string.Empty; + + [ObservableProperty] + private bool _relocateCasAndWorkspacesWithMigration; + + [ObservableProperty] + private bool _isMigrating; + + [ObservableProperty] + private string _migrationStatusText = string.Empty; + + [ObservableProperty] + private double _migrationProgressPercentage; + /// /// Initializes a new instance of the class. /// @@ -245,6 +263,7 @@ public partial class SettingsViewModel : ObservableObject, IDisposable /// Storage location service. /// User data tracker service. /// Dialog service used to confirm destructive actions. + /// Storage and installation migration service. /// Theme service for dynamic accent theming. /// GitHub token storage. public SettingsViewModel( @@ -261,6 +280,7 @@ public SettingsViewModel( IStorageLocationService storageLocationService, IUserDataTracker userDataTracker, IDialogService dialogService, + IStorageMigrationService storageMigrationService, IThemeService? themeService = null, IGitHubTokenStorage? gitHubTokenStorage = null) { @@ -277,6 +297,7 @@ public SettingsViewModel( _storageLocationService = storageLocationService ?? throw new ArgumentNullException(nameof(storageLocationService)); _userDataTracker = userDataTracker ?? throw new ArgumentNullException(nameof(userDataTracker)); _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); + _storageMigrationService = storageMigrationService ?? throw new ArgumentNullException(nameof(storageMigrationService)); _themeService = themeService; _gitHubTokenStorage = gitHubTokenStorage; @@ -778,6 +799,126 @@ private async Task BrowseCasRootPath() } } + [RelayCommand] + private async Task BrowseMigrationTargetPath() + { + try + { + _logger.LogDebug("Browse migration target path requested"); + + var lifetime = Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime; + var mainWindow = lifetime?.MainWindow; + var topLevel = mainWindow != null ? TopLevel.GetTopLevel(mainWindow) : null; + if (topLevel != null) + { + var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Select Destination Folder for GenHub Migration", + AllowMultiple = false, + }); + + if (folders.Count > 0) + { + MigrationTargetPath = folders[0].Path.LocalPath; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while browsing for migration target path"); + } + } + + [RelayCommand] + private async Task MigrateInstallationLocation() + { + if (IsMigrating) + { + return; + } + + try + { + if (string.IsNullOrWhiteSpace(MigrationTargetPath)) + { + _notificationService.ShowWarning("Migration Target Required", "Please select a target directory for migration.", 4000); + return; + } + + _logger.LogInformation("Starting migration to {TargetPath} (RelocateStorage: {Relocate})", MigrationTargetPath, RelocateCasAndWorkspacesWithMigration); + + IsMigrating = true; + MigrationStatusText = "Validating target directory..."; + MigrationProgressPercentage = 5; + + var preflight = await _storageMigrationService.ValidatePreflightAsync( + MigrationTargetPath, + RelocateCasAndWorkspacesWithMigration); + + if (!preflight.Success || preflight.Data?.IsValid != true) + { + var errorMessage = preflight.Data?.ErrorMessage ?? preflight.FirstError ?? "Pre-flight validation failed."; + _logger.LogWarning("Migration pre-flight checks failed: {ErrorMessage}", errorMessage); + _notificationService.ShowError("Migration Pre-flight Failed", errorMessage, 6000); + IsMigrating = false; + MigrationStatusText = string.Empty; + MigrationProgressPercentage = 0; + return; + } + + var confirmMessage = $"Are you sure you want to migrate GenHub to:\n{MigrationTargetPath}\n\n" + + (RelocateCasAndWorkspacesWithMigration ? "Your CAS storage pool and workspaces will also be relocated.\n\n" : string.Empty) + + "GenHub will close and restart automatically from the new location."; + + var confirmed = await _dialogService.ShowConfirmationAsync( + "Confirm Installation Migration", + confirmMessage, + "Migrate & Restart", + "Cancel"); + + if (!confirmed) + { + _logger.LogInformation("User cancelled installation migration."); + IsMigrating = false; + MigrationStatusText = string.Empty; + MigrationProgressPercentage = 0; + return; + } + + var progressReporter = new Progress(p => + { + MigrationStatusText = $"{p.Stage}: {p.Message}"; + MigrationProgressPercentage = p.Percentage; + }); + + var request = new StorageMigrationRequest + { + TargetPath = MigrationTargetPath, + RelocateCasAndWorkspace = RelocateCasAndWorkspacesWithMigration, + ExitApplicationOnSuccess = true, + LaunchHelperProcess = true, + }; + + var migrationResult = await _storageMigrationService.MigrateAsync(request, progressReporter); + if (!migrationResult.Success) + { + var error = migrationResult.FirstError ?? "Migration operation failed."; + _logger.LogError("Installation migration failed: {Error}", error); + _notificationService.ShowError("Migration Failed", error, 6000); + IsMigrating = false; + MigrationStatusText = $"Migration failed: {error}"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during installation migration"); + _notificationService.ShowError("Migration Error", ex.Message, 6000); + IsMigrating = false; + MigrationStatusText = string.Empty; + MigrationProgressPercentage = 0; + } + } + private bool ValidateSettings() { // Validate max concurrent downloads diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml index 07543eee6..9595b5d84 100644 --- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml +++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml @@ -421,6 +421,64 @@ + + + + + + + + + + + + + + + + + + +