From ad3648a0b875cf6b323b311a93438591f956ebf1 Mon Sep 17 00:00:00 2001 From: lincube Date: Fri, 24 Apr 2026 12:30:05 +0800 Subject: [PATCH] Add configurable data location (portable/system) Introduce support for choosing and resolving the application's data root (system user dir vs. portable app folder). Adds DataLocationConfig model, DataLocationResolver (load/save/resolve/migrate), a UI prompt (DataLocationPromptWindow) and an OOBE step (DataLocationOobeStep) to let users pick and optionally migrate existing data. Wire the chosen data root into the launcher flow and host launch plan (forwarded via --data-root and LMD_DATA_ROOT), and add AppDataPathProvider to let runtime services read the effective data root (initialized in Program.Main). Update various services (logging, settings, DB, plugin/market, startup registry, etc.) to use the new provider/resolver and register the config type in the JSON context. This enables portable installs, safe migration, and runtime overrides via CLI or environment variable. --- LanMountainDesktop.Launcher/AppJsonContext.cs | 1 + .../Models/DataLocationModels.cs | 23 ++ .../Services/DataLocationOobeStep.cs | 67 ++++ .../Services/DataLocationResolver.cs | 244 ++++++++++++++ .../Services/HostLaunchPlan.cs | 22 +- .../Services/LauncherDebugSettingsStore.cs | 10 + .../Services/LauncherFlowCoordinator.cs | 11 +- .../Services/Logger.cs | 10 + .../Services/OobeStateService.cs | 20 +- .../Services/PluginInstallerService.cs | 23 +- .../Services/StartupAttemptRegistry.cs | 26 +- .../Views/DataLocationPromptWindow.axaml | 153 +++++++++ .../Views/DataLocationPromptWindow.axaml.cs | 307 ++++++++++++++++++ LanMountainDesktop/Program.cs | 1 + .../Services/AppDataPathProvider.cs | 66 ++++ .../Services/AppDatabaseService.cs | 3 +- .../Services/AppSettingsService.cs | 3 +- .../Services/LauncherSettingsService.cs | 3 +- .../Settings/ComponentDomainStorage.cs | 4 +- .../Settings/SettingsDomainServices.cs | 15 +- .../Services/Settings/SettingsService.cs | 4 +- 21 files changed, 970 insertions(+), 46 deletions(-) create mode 100644 LanMountainDesktop.Launcher/Models/DataLocationModels.cs create mode 100644 LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs create mode 100644 LanMountainDesktop.Launcher/Services/DataLocationResolver.cs create mode 100644 LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml create mode 100644 LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs create mode 100644 LanMountainDesktop/Services/AppDataPathProvider.cs 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +