diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 57e6ac7..495e3f8 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -34,6 +34,7 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(PendingUpgrade))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(OobeStateFile))] +[JsonSerializable(typeof(DataLocationConfig))] [JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubAsset))] [JsonSerializable(typeof(List))] diff --git a/LanMountainDesktop.Launcher/Models/DataLocationModels.cs b/LanMountainDesktop.Launcher/Models/DataLocationModels.cs new file mode 100644 index 0000000..89cad9b --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/DataLocationModels.cs @@ -0,0 +1,23 @@ +namespace LanMountainDesktop.Launcher.Models; + +internal enum DataLocationMode +{ + System, + Portable +} + +internal sealed class DataLocationConfig +{ + public string DataLocationMode { get; set; } = "System"; + + public string? SystemDataPath { get; set; } + + public string? PortableDataPath { get; set; } +} + +internal sealed class DataLocationPromptResult +{ + public DataLocationMode SelectedMode { get; init; } + + public bool MigrateExistingData { get; init; } +} diff --git a/LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs b/LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs new file mode 100644 index 0000000..1abf73d --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs @@ -0,0 +1,67 @@ +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Views; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class DataLocationOobeStep : IOobeStep +{ + private readonly DataLocationResolver _resolver; + + public DataLocationOobeStep(DataLocationResolver resolver) + { + _resolver = resolver; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var existingConfig = _resolver.LoadConfig(); + if (existingConfig is not null) + { + Logger.Info("DataLocation OOBE step skipped: config already exists."); + return; + } + + DataLocationPromptWindow? window = null; + await Dispatcher.UIThread.InvokeAsync(() => + { + window = new DataLocationPromptWindow(_resolver); + window.Show(); + }); + + if (window is null) + { + Logger.Warn("DataLocation OOBE step failed: window could not be created."); + return; + } + + try + { + var result = await window.WaitForChoiceAsync().ConfigureAwait(false); + if (result is null) + { + Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location."); + _resolver.ApplyLocationChoice(DataLocationMode.System, false); + } + else + { + var success = _resolver.ApplyLocationChoice(result.SelectedMode, result.MigrateExistingData); + Logger.Info( + $"DataLocation OOBE step: user selected '{result.SelectedMode}'. " + + $"Migrate={result.MigrateExistingData}; Success={success}."); + } + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => + { + if (window.IsVisible) + { + window.Close(); + } + }); + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs b/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs new file mode 100644 index 0000000..ae82021 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs @@ -0,0 +1,244 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class DataLocationResolver +{ + private const string ConfigFileName = "data-location.config.json"; + private const string PortableDataFolderName = "AppData"; + + private readonly string _appRoot; + private readonly string _configPath; + private readonly string _defaultSystemDataPath; + + public DataLocationResolver(string appRoot) + { + _appRoot = Path.GetFullPath(appRoot); + _configPath = Path.Combine(_appRoot, ConfigFileName); + _defaultSystemDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop"); + } + + public string AppRoot => _appRoot; + + public string ConfigPath => _configPath; + + public string DefaultSystemDataPath => _defaultSystemDataPath; + + public string DefaultPortableDataPath => Path.Combine(_appRoot, PortableDataFolderName); + + public bool IsPortableModeAllowed() + { + try + { + var testFile = Path.Combine(_appRoot, $".write-test-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(testFile, string.Empty); + File.Delete(testFile); + return true; + } + catch + { + return false; + } + } + + public DataLocationConfig? LoadConfig() + { + try + { + if (!File.Exists(_configPath)) + { + return null; + } + + var json = File.ReadAllText(_configPath); + return JsonSerializer.Deserialize(json, AppJsonContext.Default.DataLocationConfig); + } + catch (Exception ex) + { + Logger.Warn($"Failed to load data location config from '{_configPath}'. Error='{ex.Message}'."); + return null; + } + } + + public bool SaveConfig(DataLocationConfig config) + { + try + { + var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig); + File.WriteAllText(_configPath, json); + return true; + } + catch (Exception ex) + { + Logger.Warn($"Failed to save data location config to '{_configPath}'. Error='{ex.Message}'."); + return false; + } + } + + public string ResolveDataRoot() + { + var config = LoadConfig(); + if (config is null) + { + return _defaultSystemDataPath; + } + + if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)) + { + var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath) + ? config.PortableDataPath + : DefaultPortableDataPath; + return Path.GetFullPath(portablePath); + } + + return !string.IsNullOrWhiteSpace(config.SystemDataPath) + ? Path.GetFullPath(config.SystemDataPath) + : _defaultSystemDataPath; + } + + public DataLocationMode ResolveMode() + { + var config = LoadConfig(); + if (config is null) + { + return DataLocationMode.System; + } + + return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase) + ? DataLocationMode.Portable + : DataLocationMode.System; + } + + public bool HasExistingSystemData() + { + var systemPath = _defaultSystemDataPath; + if (!Directory.Exists(systemPath)) + { + return false; + } + + var markerFiles = new[] + { + Path.Combine(systemPath, "settings.json"), + Path.Combine(systemPath, "launcher-settings.json"), + Path.Combine(systemPath, "component-state.db"), + Path.Combine(systemPath, "app.db") + }; + + return markerFiles.Any(File.Exists); + } + + public bool ApplyLocationChoice(DataLocationMode mode, bool migrateExistingData) + { + var config = new DataLocationConfig + { + DataLocationMode = mode.ToString(), + SystemDataPath = _defaultSystemDataPath, + PortableDataPath = DefaultPortableDataPath + }; + + if (!SaveConfig(config)) + { + return false; + } + + var targetDataRoot = mode == DataLocationMode.Portable + ? DefaultPortableDataPath + : _defaultSystemDataPath; + + try + { + Directory.CreateDirectory(targetDataRoot); + } + catch (Exception ex) + { + Logger.Warn($"Failed to create data directory '{targetDataRoot}'. Error='{ex.Message}'."); + return false; + } + + if (migrateExistingData && mode == DataLocationMode.Portable) + { + MigrateSystemDataToPortable(); + } + + return true; + } + + private void MigrateSystemDataToPortable() + { + if (!HasExistingSystemData()) + { + return; + } + + var sourcePath = _defaultSystemDataPath; + var targetPath = DefaultPortableDataPath; + + try + { + Directory.CreateDirectory(targetPath); + + var filesToMigrate = Directory.GetFiles(sourcePath, "*", SearchOption.TopDirectoryOnly); + foreach (var file in filesToMigrate) + { + var fileName = Path.GetFileName(file); + var destFile = Path.Combine(targetPath, fileName); + try + { + File.Copy(file, destFile, overwrite: true); + } + catch (Exception ex) + { + Logger.Warn($"Failed to migrate file '{fileName}'. Error='{ex.Message}'."); + } + } + + var dirsToMigrate = Directory.GetDirectories(sourcePath, "*", SearchOption.TopDirectoryOnly); + foreach (var dir in dirsToMigrate) + { + var dirName = Path.GetFileName(dir); + if (string.Equals(dirName, ".launcher", StringComparison.OrdinalIgnoreCase) && + string.Equals(Path.GetFileName(sourcePath), "LanMountainDesktop", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var destDir = Path.Combine(targetPath, dirName); + try + { + CopyDirectory(dir, destDir); + } + catch (Exception ex) + { + Logger.Warn($"Failed to migrate directory '{dirName}'. Error='{ex.Message}'."); + } + } + + Logger.Info($"Data migration completed. Source='{sourcePath}'; Target='{targetPath}'."); + } + catch (Exception ex) + { + Logger.Warn($"Data migration failed. Source='{sourcePath}'; Target='{targetPath}'. Error='{ex.Message}'."); + } + } + + private static void CopyDirectory(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)); + CopyDirectory(subDir, destSubDir); + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs b/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs index 2b7d7d7..ce01a88 100644 --- a/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs +++ b/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs @@ -12,10 +12,12 @@ internal sealed record HostLaunchPlan( internal static class HostLaunchPlanBuilder { + public const string DataRootOptionName = "data-root"; + private static readonly string[] LauncherOnlyOptions = [ "debug", "show-loading-details", "plugins-dir", "source", "result", - "app-root", + "app-root", DataRootOptionName, LauncherIpcConstants.LauncherPidEnvVar, LauncherIpcConstants.PackageRootEnvVar, LauncherIpcConstants.VersionEnvVar, @@ -25,7 +27,8 @@ internal static class HostLaunchPlanBuilder public static HostLaunchPlan Build( CommandContext context, DeploymentLocator deploymentLocator, - HostResolutionResult resolution) + HostResolutionResult resolution, + string? dataRoot = null) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(deploymentLocator); @@ -39,7 +42,7 @@ internal static class HostLaunchPlanBuilder var hostPath = Path.GetFullPath(resolution.ResolvedHostPath); var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource); var versionInfo = deploymentLocator.GetVersionInfo(); - var arguments = BuildForwardedArguments(context, packageRoot, versionInfo); + var arguments = BuildForwardedArguments(context, packageRoot, versionInfo, dataRoot); var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) { [LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(), @@ -48,6 +51,11 @@ internal static class HostLaunchPlanBuilder [LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename }; + if (!string.IsNullOrWhiteSpace(dataRoot)) + { + environment["LMD_DATA_ROOT"] = dataRoot; + } + return new HostLaunchPlan( hostPath, packageRoot, @@ -92,7 +100,8 @@ internal static class HostLaunchPlanBuilder private static IReadOnlyList BuildForwardedArguments( CommandContext context, string packageRoot, - AppVersionInfo versionInfo) + AppVersionInfo versionInfo, + string? dataRoot = null) { var arguments = new List(); @@ -144,6 +153,11 @@ internal static class HostLaunchPlanBuilder arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}"); arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}"); + if (!string.IsNullOrWhiteSpace(dataRoot)) + { + arguments.Add($"--{DataRootOptionName}={dataRoot}"); + } + return arguments; } diff --git a/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs b/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs index 5cb0bed..6ea9d68 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs @@ -100,6 +100,16 @@ internal static class LauncherDebugSettingsStore private static string ResolveConfigBaseDirectory() { + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + return Path.Combine(resolver.ResolveDataRoot(), ".launcher"); + } + catch + { + } + try { var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index a17558b..c598042 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -23,6 +23,7 @@ internal sealed class LauncherFlowCoordinator private readonly PluginInstallerService _pluginInstallerService; private readonly StartupAttemptRegistry _startupAttemptRegistry; private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer; + private readonly DataLocationResolver _dataLocationResolver; private readonly IReadOnlyList _oobeSteps; public LauncherFlowCoordinator( @@ -41,7 +42,12 @@ internal sealed class LauncherFlowCoordinator _pluginInstallerService = pluginInstallerService; _startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry(); _coordinatorIpcServer = coordinatorIpcServer; - _oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)]; + _dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot()); + _oobeSteps = + [ + new WelcomeOobeStep(_oobeStateService, _context), + new DataLocationOobeStep(_dataLocationResolver) + ]; } public static string ResolveSuccessPolicyKey(CommandContext context) @@ -1025,7 +1031,8 @@ internal sealed class LauncherFlowCoordinator bool forceDirectMode, string? retryTag) { - var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution); + var dataRoot = _dataLocationResolver.ResolveDataRoot(); + var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot); var hostPath = plan.HostPath; if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { diff --git a/LanMountainDesktop.Launcher/Services/Logger.cs b/LanMountainDesktop.Launcher/Services/Logger.cs index 5d60c39..0e474a9 100644 --- a/LanMountainDesktop.Launcher/Services/Logger.cs +++ b/LanMountainDesktop.Launcher/Services/Logger.cs @@ -53,6 +53,16 @@ internal static class Logger /// private static string? GetLogDirectory() { + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + return Path.Combine(resolver.ResolveDataRoot(), ".launcher", "logs"); + } + catch + { + } + try { var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); diff --git a/LanMountainDesktop.Launcher/Services/OobeStateService.cs b/LanMountainDesktop.Launcher/Services/OobeStateService.cs index 6903ba2..39c8f03 100644 --- a/LanMountainDesktop.Launcher/Services/OobeStateService.cs +++ b/LanMountainDesktop.Launcher/Services/OobeStateService.cs @@ -21,7 +21,7 @@ internal sealed class OobeStateService _executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture(); var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride) - ? GetDefaultStateRoot() + ? ResolveStateRoot(appRoot) : Path.GetFullPath(stateRootOverride); _stateDirectory = Path.Combine(stateRoot, ".launcher", "state"); _statePath = Path.Combine(_stateDirectory, "oobe-state.json"); @@ -208,14 +208,22 @@ internal sealed class OobeStateService }; } - private static string GetDefaultStateRoot() + private static string ResolveStateRoot(string appRoot) { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrWhiteSpace(appData)) + try { - throw new InvalidOperationException("LocalApplicationData is unavailable."); + var resolver = new DataLocationResolver(appRoot); + return resolver.ResolveDataRoot(); } + catch + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(appData)) + { + throw new InvalidOperationException("LocalApplicationData is unavailable."); + } - return Path.Combine(appData, "LanMountainDesktop"); + return Path.Combine(appData, "LanMountainDesktop"); + } } } diff --git a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs index 905fe97..60beff7 100644 --- a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs +++ b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs @@ -63,13 +63,28 @@ internal sealed class PluginInstallerService return null; } - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrWhiteSpace(localAppData)) + string? allowedRoot = null; + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot()); + } + catch { - return null; } - var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop")); + if (string.IsNullOrWhiteSpace(allowedRoot)) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + return null; + } + + allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop")); + } + var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory)); if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase)) { diff --git a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs index 8037cd4..a6d3476 100644 --- a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs +++ b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs @@ -20,15 +20,29 @@ internal sealed class StartupAttemptRegistry private string? _ownedAttemptId; public StartupAttemptRegistry() - : this(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LanMountainDesktop", - ".launcher", - "state", - "startup-attempt.json")) + : this(ResolveDefaultStatePath()) { } + private static string ResolveDefaultStatePath() + { + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + return Path.Combine(resolver.ResolveDataRoot(), ".launcher", "state", "startup-attempt.json"); + } + catch + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + ".launcher", + "state", + "startup-attempt.json"); + } + } + internal StartupAttemptRegistry(string statePath) { _statePath = statePath; diff --git a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml new file mode 100644 index 0000000..386cba2 --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +