Refactor data location paths and add background service

Refactor DataLocationResolver to centralize data path resolution (ResolveLauncherDataPath, ResolveDesktopDataPath, ResolveConfigPath, ResolveLauncherLogsPath, ResolveLauncherStatePath) and replace usages of the previous ".launcher" layout with a "Launcher" folder. Update API: LoadConfig/SaveConfig reorganized and ApplyLocationChoice now accepts an optional custom path and migration flag; migration logic updated accordingly. Update dependent services and views (Logger, DeploymentLocator, UpdateEngineService, OobeStateService, StartupAttemptRegistry, LauncherDebugSettingsStore, OobeWindow) to use the new resolver APIs and paths. Add LauncherBackgroundService to load/validate/cache a custom splash background image and wire it into SplashWindow (AXAML/Axaml.cs) with UI placeholders and overlay. Misc: minor cleanup of Oobe/Splash XAML and related code adjustments and logging improvements.
This commit is contained in:
lincube
2026-04-25 18:14:29 +08:00
parent 5b4b9f32b5
commit 05ffadd1a0
13 changed files with 381 additions and 143 deletions

View File

@@ -43,11 +43,11 @@ internal sealed class DataLocationOobeStep : IOobeStep
if (result is null)
{
Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location.");
_resolver.ApplyLocationChoice(DataLocationMode.System, false);
_resolver.ApplyLocationChoice(DataLocationMode.System, null, false);
}
else
{
var success = _resolver.ApplyLocationChoice(result.SelectedMode, result.MigrateExistingData);
var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData);
Logger.Info(
$"DataLocation OOBE step: user selected '{result.SelectedMode}'. " +
$"Migrate={result.MigrateExistingData}; Success={success}.");

View File

@@ -6,16 +6,15 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class DataLocationResolver
{
private const string ConfigFileName = "data-location.config.json";
private const string PortableDataFolderName = "AppData";
private const string LauncherFolderName = "Launcher";
private const string DesktopFolderName = "Desktop";
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");
@@ -23,12 +22,19 @@ internal sealed class DataLocationResolver
public string AppRoot => _appRoot;
public string ConfigPath => _configPath;
/// <summary>
/// 默认系统数据路径(用户目录)
/// </summary>
public string DefaultSystemDataPath => _defaultSystemDataPath;
public string DefaultPortableDataPath => Path.Combine(_appRoot, PortableDataFolderName);
/// <summary>
/// 默认便携模式数据路径(应用目录下的 AppData
/// </summary>
public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData");
/// <summary>
/// 检查是否允许便携模式(应用目录是否可写)
/// </summary>
public bool IsPortableModeAllowed()
{
try
@@ -44,40 +50,9 @@ internal sealed class DataLocationResolver
}
}
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;
}
}
/// <summary>
/// 解析数据根目录(用户选择的位置)
/// </summary>
public string ResolveDataRoot()
{
var config = LoadConfig();
@@ -90,7 +65,7 @@ internal sealed class DataLocationResolver
{
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
? config.PortableDataPath
: DefaultPortableDataPath;
: _defaultSystemDataPath;
return Path.GetFullPath(portablePath);
}
@@ -99,6 +74,46 @@ internal sealed class DataLocationResolver
: _defaultSystemDataPath;
}
/// <summary>
/// 启动器数据目录(日志、配置、状态等)
/// </summary>
public string ResolveLauncherDataPath()
{
return Path.Combine(ResolveDataRoot(), LauncherFolderName);
}
/// <summary>
/// 桌面应用数据目录(组件、设置、插件等)
/// </summary>
public string ResolveDesktopDataPath()
{
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
}
/// <summary>
/// 数据位置配置文件路径(保存在 Launcher 目录下)
/// </summary>
public string ResolveConfigPath()
{
return Path.Combine(ResolveLauncherDataPath(), ConfigFileName);
}
/// <summary>
/// 启动器日志目录
/// </summary>
public string ResolveLauncherLogsPath()
{
return Path.Combine(ResolveLauncherDataPath(), "logs");
}
/// <summary>
/// 启动器状态目录
/// </summary>
public string ResolveLauncherStatePath()
{
return Path.Combine(ResolveLauncherDataPath(), "state");
}
public DataLocationMode ResolveMode()
{
var config = LoadConfig();
@@ -112,116 +127,127 @@ internal sealed class DataLocationResolver
: DataLocationMode.System;
}
public DataLocationConfig? LoadConfig()
{
try
{
var configPath = ResolveConfigPath();
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. Error='{ex.Message}'.");
return null;
}
}
public bool SaveConfig(DataLocationConfig config)
{
try
{
var launcherPath = ResolveLauncherDataPath();
Directory.CreateDirectory(launcherPath);
var configPath = ResolveConfigPath();
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. Error='{ex.Message}'.");
return false;
}
}
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
? Path.GetFullPath(customPath)
: _defaultSystemDataPath;
var config = new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
// 先创建目录结构
try
{
Directory.CreateDirectory(ResolveLauncherDataPath());
Directory.CreateDirectory(ResolveDesktopDataPath());
}
catch (Exception ex)
{
Logger.Warn($"Failed to create data directories. Error='{ex.Message}'.");
return false;
}
// 保存配置
if (!SaveConfig(config))
{
return false;
}
if (migrateExistingData && mode == DataLocationMode.Portable)
{
MigrateSystemDataToPortable(targetDataRoot);
}
return true;
}
public bool HasExistingSystemData()
{
var systemPath = _defaultSystemDataPath;
if (!Directory.Exists(systemPath))
var desktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
if (!Directory.Exists(desktopPath))
{
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")
Path.Combine(desktopPath, "settings.json"),
Path.Combine(desktopPath, "component-state.db"),
Path.Combine(desktopPath, "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()
private void MigrateSystemDataToPortable(string targetDataRoot)
{
if (!HasExistingSystemData())
{
return;
}
var sourcePath = _defaultSystemDataPath;
var targetPath = DefaultPortableDataPath;
var sourceDesktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
var targetDesktopPath = Path.Combine(targetDataRoot, DesktopFolderName);
try
{
Directory.CreateDirectory(targetPath);
Directory.CreateDirectory(targetDesktopPath);
var filesToMigrate = Directory.GetFiles(sourcePath, "*", SearchOption.TopDirectoryOnly);
foreach (var file in filesToMigrate)
// 迁移桌面数据
if (Directory.Exists(sourceDesktopPath))
{
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}'.");
}
CopyDirectory(sourceDesktopPath, targetDesktopPath);
}
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}'.");
Logger.Info($"Data migration completed. Target='{targetDataRoot}'.");
}
catch (Exception ex)
{
Logger.Warn($"Data migration failed. Source='{sourcePath}'; Target='{targetPath}'. Error='{ex.Message}'.");
Logger.Warn($"Data migration failed. Target='{targetDataRoot}'. Error='{ex.Message}'.");
}
}

View File

@@ -497,7 +497,8 @@ internal sealed class DeploymentLocator
}
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
var resolver = new DataLocationResolver(_appRoot);
var snapshotDir = Path.Combine(resolver.ResolveLauncherDataPath(), "snapshots");
if (Directory.Exists(snapshotDir))
{
try

View File

@@ -0,0 +1,174 @@
using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 启动器背景图片服务
/// </summary>
internal static class LauncherBackgroundService
{
private const string PictureFileName = "Launcher Picture";
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private const double WindowAspectRatio = 7.0 / 5.0; // 700:500
private const double AspectRatioTolerance = 0.15; // 15% 误差
private static Bitmap? _cachedBitmap;
private static string? _cachedPath;
/// <summary>
/// 背景图片信息
/// </summary>
public record BackgroundImageInfo
{
public required bool Exists { get; init; }
public required bool IsValid { get; init; }
public string? FilePath { get; init; }
public Bitmap? Bitmap { get; init; }
public int Width { get; init; }
public int Height { get; init; }
public double AspectRatio { get; init; }
public string? ErrorMessage { get; init; }
}
/// <summary>
/// 加载背景图片
/// </summary>
public static BackgroundImageInfo LoadBackgroundImage()
{
try
{
var resolver = new DataLocationResolver(AppContext.BaseDirectory);
var launcherPath = resolver.ResolveLauncherDataPath();
// 查找图片文件
var imagePath = FindImageFile(launcherPath);
if (imagePath == null)
{
return new BackgroundImageInfo
{
Exists = false,
IsValid = false,
ErrorMessage = "未找到背景图片文件"
};
}
// 检查文件大小
var fileInfo = new FileInfo(imagePath);
if (fileInfo.Length > MaxFileSize)
{
return new BackgroundImageInfo
{
Exists = true,
IsValid = false,
FilePath = imagePath,
ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)"
};
}
// 使用缓存
if (_cachedBitmap != null && _cachedPath == imagePath)
{
return new BackgroundImageInfo
{
Exists = true,
IsValid = true,
FilePath = imagePath,
Bitmap = _cachedBitmap,
Width = _cachedBitmap.PixelSize.Width,
Height = _cachedBitmap.PixelSize.Height,
AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height
};
}
// 加载图片
var bitmap = new Bitmap(imagePath);
var width = bitmap.PixelSize.Width;
var height = bitmap.PixelSize.Height;
var aspectRatio = (double)width / height;
// 校验比例
var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio;
if (ratioDiff > AspectRatioTolerance)
{
bitmap.Dispose();
return new BackgroundImageInfo
{
Exists = true,
IsValid = false,
FilePath = imagePath,
Width = width,
Height = height,
AspectRatio = aspectRatio,
ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})"
};
}
// 缓存图片
_cachedBitmap = bitmap;
_cachedPath = imagePath;
Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})");
return new BackgroundImageInfo
{
Exists = true,
IsValid = true,
FilePath = imagePath,
Bitmap = bitmap,
Width = width,
Height = height,
AspectRatio = aspectRatio
};
}
catch (Exception ex)
{
Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}");
return new BackgroundImageInfo
{
Exists = false,
IsValid = false,
ErrorMessage = $"加载失败: {ex.Message}"
};
}
}
/// <summary>
/// 查找图片文件
/// </summary>
private static string? FindImageFile(string directory)
{
var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
foreach (var ext in extensions)
{
var path = Path.Combine(directory, PictureFileName + ext);
if (File.Exists(path))
{
return path;
}
}
// 也尝试不带扩展名的匹配(如果文件本身就有扩展名)
var files = Directory.GetFiles(directory, PictureFileName + ".*");
foreach (var file in files)
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (extensions.Contains(ext))
{
return file;
}
}
return null;
}
/// <summary>
/// 清除缓存
/// </summary>
public static void ClearCache()
{
_cachedBitmap?.Dispose();
_cachedBitmap = null;
_cachedPath = null;
}
}

View File

@@ -104,7 +104,7 @@ internal static class LauncherDebugSettingsStore
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
return Path.Combine(resolver.ResolveDataRoot(), ".launcher");
return resolver.ResolveLauncherDataPath();
}
catch
{
@@ -115,7 +115,7 @@ internal static class LauncherDebugSettingsStore
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
return Path.Combine(appData, "LanMountainDesktop", "Launcher");
}
}
catch
@@ -124,11 +124,11 @@ internal static class LauncherDebugSettingsStore
try
{
return Path.Combine(AppContext.BaseDirectory, ".launcher");
return Path.Combine(AppContext.BaseDirectory, "Launcher");
}
catch
{
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
return Path.Combine(Directory.GetCurrentDirectory(), "Launcher");
}
}
}

View File

@@ -57,7 +57,7 @@ internal static class Logger
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
return Path.Combine(resolver.ResolveDataRoot(), ".launcher", "logs");
return resolver.ResolveLauncherLogsPath();
}
catch
{
@@ -68,7 +68,7 @@ internal static class Logger
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
return Path.Combine(appData, "LanMountainDesktop", "Launcher", "logs");
}
}
catch
@@ -78,7 +78,7 @@ internal static class Logger
try
{
var launcherDir = AppContext.BaseDirectory;
return Path.Combine(launcherDir, ".launcher", "logs");
return Path.Combine(launcherDir, "Launcher", "logs");
}
catch
{

View File

@@ -23,7 +23,7 @@ internal sealed class OobeStateService
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
? ResolveStateRoot(appRoot)
: Path.GetFullPath(stateRootOverride);
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
}

View File

@@ -26,14 +26,14 @@ internal sealed class StartupAttemptRegistry
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
return Path.Combine(resolver.ResolveDataRoot(), ".launcher", "state", "startup-attempt.json");
return Path.Combine(resolver.ResolveLauncherStatePath(), "startup-attempt.json");
}
catch
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
".launcher",
"Launcher",
"state",
"startup-attempt.json");
}

View File

@@ -7,7 +7,6 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class UpdateEngineService
{
private const string LauncherDirectoryName = ".launcher";
private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming";
private const string SnapshotsDirectoryName = "snapshots";
@@ -22,7 +21,6 @@ internal sealed class UpdateEngineService
private readonly DeploymentLocator _deploymentLocator;
private readonly string _appRoot;
private readonly string _dataRoot;
private readonly string _launcherRoot;
private readonly string _incomingRoot;
private readonly string _snapshotsRoot;
@@ -31,8 +29,8 @@ internal sealed class UpdateEngineService
{
_deploymentLocator = deploymentLocator;
_appRoot = deploymentLocator.GetAppRoot();
_dataRoot = new DataLocationResolver(_appRoot).ResolveDataRoot();
_launcherRoot = Path.Combine(_dataRoot, LauncherDirectoryName);
var resolver = new DataLocationResolver(_appRoot);
_launcherRoot = resolver.ResolveLauncherDataPath();
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
}

View File

@@ -23,7 +23,6 @@
</Design.DataContext>
<Grid x:Name="ContentGrid">
<!-- 步骤 1: 打字机动画开场 -->
<Grid x:Name="TypingStep" Margin="60,80,60,60">
<!-- 主标题区域(左上角) -->

View File

@@ -431,7 +431,7 @@ public partial class OobeWindow : Window
// 应用数据位置选择
if (!_isDebugMode)
{
_resolver.ApplyLocationChoice(_selectedDataLocationMode, _migrateExistingData);
_resolver.ApplyLocationChoice(_selectedDataLocationMode, null, _migrateExistingData);
}
await NavigateToStep(4);

View File

@@ -20,8 +20,20 @@
<views:SplashWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto"
Background="#0B0B0B">
<Grid RowDefinitions="*,Auto">
<!-- 背景图片 -->
<Image x:Name="BackgroundImage"
Grid.RowSpan="2"
Stretch="UniformToFill"
IsVisible="False"
Opacity="0"/>
<!-- 半透明遮罩层 -->
<Border x:Name="BackgroundOverlay"
Grid.RowSpan="2"
Background="#0B0B0B"
Opacity="0.85"/>
<Grid Grid.Row="0"
Margin="24">
<TextBlock x:Name="AppNameText"

View File

@@ -29,12 +29,40 @@ public partial class SplashWindow : Window, ISplashStageReporter
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
InitializeBackgroundImage();
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
{
versionBorder.PointerPressed += OnVersionTextClick;
}
}
private void InitializeBackgroundImage()
{
try
{
var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
if (imageInfo is { IsValid: true, Bitmap: not null })
{
if (this.FindControl<Image>("BackgroundImage") is { } backgroundImage)
{
backgroundImage.Source = imageInfo.Bitmap;
backgroundImage.IsVisible = true;
backgroundImage.Opacity = 1;
}
Logger.Info("[SplashWindow] 背景图片加载成功");
}
else if (imageInfo is { Exists: true, IsValid: false })
{
Logger.Warn($"[SplashWindow] 背景图片校验失败: {imageInfo.ErrorMessage}");
}
}
catch (Exception ex)
{
Logger.Warn($"[SplashWindow] 加载背景图片失败: {ex.Message}");
}
}
private async void OnWindowOpened(object? sender, EventArgs e)
{
if (_isOpened)