mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-23 09:54:25 +08:00
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.
This commit is contained in:
67
LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs
Normal file
67
LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
244
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
244
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>(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<string> BuildForwardedArguments(
|
||||
CommandContext context,
|
||||
string packageRoot,
|
||||
AppVersionInfo versionInfo)
|
||||
AppVersionInfo versionInfo,
|
||||
string? dataRoot = null)
|
||||
{
|
||||
var arguments = new List<string>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<IOobeStep> _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())
|
||||
{
|
||||
|
||||
@@ -53,6 +53,16 @@ internal static class Logger
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user