From 8df0271032c9842d68944f4bfc29d6b652d0b9d8 Mon Sep 17 00:00:00 2001 From: lincube Date: Fri, 5 Jun 2026 23:38:32 +0800 Subject: [PATCH] =?UTF-8?q?feat.=E5=90=AF=E5=8A=A8=E5=99=A8=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=87=AA=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../startup-visuals-addendum.md | 8 + .../Resources/Strings.cs | 11 + .../Resources/Strings.en-US.resx | 11 + .../Resources/Strings.ja-JP.resx | 11 + .../Resources/Strings.ko-KR.resx | 11 + .../Resources/Strings.resx | 11 + .../Shell/LauncherBackgroundService.cs | 299 ++++++++++++++---- .../Views/ErrorDebugWindow.axaml | 167 ++++++---- .../Views/ErrorDebugWindow.axaml.cs | 104 ++++++ .../Views/SplashWindow.axaml | 139 ++++---- .../Views/SplashWindow.axaml.cs | 20 +- .../LauncherBackgroundServiceTests.cs | 182 +++++++++++ docs/LAUNCHER_STARTUP_VISUALS.md | 8 + 13 files changed, 806 insertions(+), 176 deletions(-) create mode 100644 LanMountainDesktop.Tests/LauncherBackgroundServiceTests.cs diff --git a/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md index 6c1ce8d..4b238f4 100644 --- a/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md +++ b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md @@ -19,6 +19,14 @@ - `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`. - `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`. +## Launcher custom splash image + +- The hidden Launcher debug menu owns the splash image picker. +- Saving an image copies it into `.Launcher` as `Launcher Picture.` and clears the in-memory image cache. +- Invalid, unsupported, or oversized images must not overwrite the existing managed image. +- Splash image rendering uses `Uniform` fitting so the full image remains visible. +- The self-drawn Splash shell uses fixed Fluent corner tokens: `8px` outer radius and `4px` control radius. + ## UX safeguards - If the host process is still alive at failure time, the failure dialog must prefer: diff --git a/LanMountainDesktop.Launcher/Resources/Strings.cs b/LanMountainDesktop.Launcher/Resources/Strings.cs index d52e78d..fe3a52d 100644 --- a/LanMountainDesktop.Launcher/Resources/Strings.cs +++ b/LanMountainDesktop.Launcher/Resources/Strings.cs @@ -105,6 +105,17 @@ public static class Strings public static string DebugDebug_ButtonCancel => ResourceManager.GetString(nameof(DebugDebug_ButtonCancel), Culture)!; public static string DebugDebug_ButtonOk => ResourceManager.GetString(nameof(DebugDebug_ButtonOk), Culture)!; public static string DebugDebug_SelectExeDialog => ResourceManager.GetString(nameof(DebugDebug_SelectExeDialog), Culture)!; + public static string DebugDebug_BackgroundImage => ResourceManager.GetString(nameof(DebugDebug_BackgroundImage), Culture)!; + public static string DebugDebug_BackgroundImageDesc => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageDesc), Culture)!; + public static string DebugDebug_BackgroundImageNotSet => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageNotSet), Culture)!; + public static string DebugDebug_BackgroundImageSaved => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageSaved), Culture)!; + public static string DebugDebug_BackgroundImageCleared => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageCleared), Culture)!; + public static string DebugDebug_BackgroundImageSaveFailedFormat => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageSaveFailedFormat), Culture)!; + public static string DebugDebug_BackgroundImageReadyFormat => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageReadyFormat), Culture)!; + public static string DebugDebug_BackgroundImageInvalidFormat => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageInvalidFormat), Culture)!; + public static string DebugDebug_Clear => ResourceManager.GetString(nameof(DebugDebug_Clear), Culture)!; + public static string DebugDebug_SelectImageDialog => ResourceManager.GetString(nameof(DebugDebug_SelectImageDialog), Culture)!; + public static string DebugDebug_ImageFiles => ResourceManager.GetString(nameof(DebugDebug_ImageFiles), Culture)!; public static string Oobe_Title => ResourceManager.GetString(nameof(Oobe_Title), Culture)!; public static string Oobe_WelcomeTitle => ResourceManager.GetString(nameof(Oobe_WelcomeTitle), Culture)!; public static string Oobe_WelcomeSubtitle => ResourceManager.GetString(nameof(Oobe_WelcomeSubtitle), Culture)!; diff --git a/LanMountainDesktop.Launcher/Resources/Strings.en-US.resx b/LanMountainDesktop.Launcher/Resources/Strings.en-US.resx index 53035d2..fc27a7d 100644 --- a/LanMountainDesktop.Launcher/Resources/Strings.en-US.resx +++ b/LanMountainDesktop.Launcher/Resources/Strings.en-US.resx @@ -119,6 +119,17 @@ Cancel OK Select LanMountainDesktop host executable + Splash image + Choose an image to show on the splash screen. It will be copied into the Launcher data directory. + No splash image selected + Splash image saved. The current splash screen will refresh immediately. + Splash image cleared. + Image setting failed: {0} + Current splash image is ready ({0} x {1}). + Current splash image is unavailable: {0} + Clear + Select splash image + Image files Welcome to LanMountain Desktop Welcome to LanMountain Desktop Your desktop, more than one side diff --git a/LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx b/LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx index 67860a5..1d3846c 100644 --- a/LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx +++ b/LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx @@ -119,6 +119,17 @@ キャンセル OK 蘭山デスクトップホスト実行可能ファイルを選択 + スプラッシュ画像 + 起動画面に表示する画像を選択します。画像は Launcher のデータディレクトリにコピーされます。 + スプラッシュ画像は未設定です + スプラッシュ画像を保存しました。現在の起動画面はすぐに更新されます。 + スプラッシュ画像をクリアしました。 + 画像設定に失敗しました: {0} + 現在のスプラッシュ画像は使用できます({0} x {1})。 + 現在のスプラッシュ画像は使用できません: {0} + クリア + スプラッシュ画像を選択 + 画像ファイル 蘭山デスクトップへようこそ 蘭山デスクトップへようこそ あなたのデスクトップ、一面だけじゃない diff --git a/LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx b/LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx index 5d9053f..2ea9f58 100644 --- a/LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx +++ b/LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx @@ -119,6 +119,17 @@ 취소 확인 란산 데스크톱 호스트 실행 파일 선택 + 스플래시 이미지 + 시작 화면에 표시할 이미지를 선택합니다. 이미지는 Launcher 데이터 디렉터리에 복사됩니다. + 스플래시 이미지가 설정되지 않았습니다 + 스플래시 이미지가 저장되었습니다. 현재 시작 화면이 즉시 새로 고쳐집니다. + 스플래시 이미지가 지워졌습니다. + 이미지 설정 실패: {0} + 현재 스플래시 이미지를 사용할 수 있습니다({0} x {1}). + 현재 스플래시 이미지를 사용할 수 없습니다: {0} + 지우기 + 스플래시 이미지 선택 + 이미지 파일 란산 데스크톱에 오신 것을 환영합니다 란산 데스크톱에 오신 것을 환영합니다 당신의 데스크톱, 한 면이 아닙니다 diff --git a/LanMountainDesktop.Launcher/Resources/Strings.resx b/LanMountainDesktop.Launcher/Resources/Strings.resx index 4107fea..59dd81c 100644 --- a/LanMountainDesktop.Launcher/Resources/Strings.resx +++ b/LanMountainDesktop.Launcher/Resources/Strings.resx @@ -119,6 +119,17 @@ 取消 确定 选择阑山桌面主程序可执行文件 + 启动图 + 选择一张图片显示在启动画面中。图片会复制保存到 Launcher 数据目录。 + 未设置启动图 + 启动图已保存,当前启动画面会立即刷新。 + 启动图已清除。 + 图片设置失败:{0} + 当前启动图可用({0} × {1})。 + 当前启动图不可用:{0} + 清除 + 选择启动图 + 图片文件 欢迎使用阑山桌面 欢迎使用阑山桌面 你的桌面,不止一面 diff --git a/LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs b/LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs index 0bb53b1..41acae7 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs @@ -2,22 +2,28 @@ using Avalonia.Media.Imaging; namespace LanMountainDesktop.Launcher.Shell; -/// -/// 启动器背景图片服务 -/// 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 const long MaxFileSize = 10 * 1024 * 1024; + + private static readonly string[] SupportedExtensions = + [ + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".webp" + ]; private static Bitmap? _cachedBitmap; private static string? _cachedPath; + private static long _cachedLength; + private static DateTime _cachedLastWriteTimeUtc; + + internal static string? LauncherDataDirectoryOverride { get; set; } - /// - /// 背景图片信息 - /// public record BackgroundImageInfo { public required bool Exists { get; init; } @@ -30,29 +36,29 @@ internal static class LauncherBackgroundService public string? ErrorMessage { get; init; } } - /// - /// 加载背景图片 - /// + public record BackgroundImageMutationResult + { + public required bool IsSuccess { get; init; } + public string? FilePath { get; init; } + public string? ErrorMessage { get; init; } + } + public static BackgroundImageInfo LoadBackgroundImage() { try { - var resolver = new DataLocationResolver(AppContext.BaseDirectory); - var launcherPath = resolver.ResolveLauncherDataPath(); - - // 查找图片文件 + var launcherPath = ResolveLauncherDataPath(); var imagePath = FindImageFile(launcherPath); - if (imagePath == null) + if (imagePath is null) { return new BackgroundImageInfo { Exists = false, IsValid = false, - ErrorMessage = "未找到背景图片文件" + ErrorMessage = "No launcher background image was found." }; } - // 检查文件大小 var fileInfo = new FileInfo(imagePath); if (fileInfo.Length > MaxFileSize) { @@ -61,12 +67,11 @@ internal static class LauncherBackgroundService Exists = true, IsValid = false, FilePath = imagePath, - ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)" + ErrorMessage = $"Image file is too large ({fileInfo.Length / 1024 / 1024}MB > 10MB)." }; } - // 使用缓存 - if (_cachedBitmap != null && _cachedPath == imagePath) + if (IsCacheCurrent(imagePath, fileInfo)) { return new BackgroundImageInfo { @@ -74,40 +79,40 @@ internal static class LauncherBackgroundService IsValid = true, FilePath = imagePath, Bitmap = _cachedBitmap, - Width = _cachedBitmap.PixelSize.Width, + 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; + DisposeCache(); - // 校验比例 - var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio; - if (ratioDiff > AspectRatioTolerance) + Bitmap bitmap; + try + { + bitmap = new Bitmap(imagePath); + } + catch (Exception ex) { - bitmap.Dispose(); return new BackgroundImageInfo { Exists = true, IsValid = false, FilePath = imagePath, - Width = width, - Height = height, - AspectRatio = aspectRatio, - ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})" + ErrorMessage = $"Image could not be decoded: {ex.Message}" }; } - // 缓存图片 + var width = bitmap.PixelSize.Width; + var height = bitmap.PixelSize.Height; + var aspectRatio = height == 0 ? 0d : (double)width / height; + _cachedBitmap = bitmap; _cachedPath = imagePath; + _cachedLength = fileInfo.Length; + _cachedLastWriteTimeUtc = fileInfo.LastWriteTimeUtc; - Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})"); + Logger.Info($"[LauncherBackground] Background image loaded: {imagePath} ({width}x{height})."); return new BackgroundImageInfo { @@ -122,38 +127,159 @@ internal static class LauncherBackgroundService } catch (Exception ex) { - Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}"); + Logger.Warn($"[LauncherBackground] Failed to load background image: {ex.Message}"); return new BackgroundImageInfo { Exists = false, IsValid = false, - ErrorMessage = $"加载失败: {ex.Message}" + ErrorMessage = $"Load failed: {ex.Message}" }; } } - /// - /// 查找图片文件 - /// + public static BackgroundImageMutationResult SaveBackgroundImage(string sourcePath) + { + try + { + if (string.IsNullOrWhiteSpace(sourcePath)) + { + return FailMutation("No image file was selected."); + } + + var fullSourcePath = Path.GetFullPath(sourcePath); + if (!File.Exists(fullSourcePath)) + { + return FailMutation("The selected image file does not exist."); + } + + var extension = NormalizeExtension(Path.GetExtension(fullSourcePath)); + if (!IsSupportedExtension(extension)) + { + return FailMutation("The selected image format is not supported."); + } + + var sourceInfo = new FileInfo(fullSourcePath); + if (sourceInfo.Length > MaxFileSize) + { + return FailMutation($"Image file is too large ({sourceInfo.Length / 1024 / 1024}MB > 10MB)."); + } + + try + { + using var bitmap = new Bitmap(fullSourcePath); + _ = bitmap.PixelSize; + } + catch (Exception ex) + { + return FailMutation($"The selected image could not be decoded: {ex.Message}"); + } + + var launcherPath = ResolveLauncherDataPath(); + Directory.CreateDirectory(launcherPath); + + var destinationPath = Path.Combine(launcherPath, PictureFileName + extension); + var tempPath = Path.Combine(launcherPath, $".{PictureFileName}.{Guid.NewGuid():N}.tmp"); + + try + { + File.Copy(fullSourcePath, tempPath, overwrite: true); + ClearCache(); + File.Move(tempPath, destinationPath, overwrite: true); + DeleteManagedImageFiles(launcherPath, destinationPath); + } + finally + { + TryDeleteFile(tempPath); + } + + ClearCache(); + + Logger.Info($"[LauncherBackground] Background image saved: {destinationPath}."); + return new BackgroundImageMutationResult + { + IsSuccess = true, + FilePath = destinationPath + }; + } + catch (Exception ex) + { + Logger.Warn($"[LauncherBackground] Failed to save background image: {ex.Message}"); + return FailMutation($"Save failed: {ex.Message}"); + } + } + + public static BackgroundImageMutationResult ClearBackgroundImage() + { + try + { + var launcherPath = ResolveLauncherDataPath(); + ClearCache(); + DeleteManagedImageFiles(launcherPath, exceptPath: null); + + Logger.Info("[LauncherBackground] Background image cleared."); + return new BackgroundImageMutationResult + { + IsSuccess = true + }; + } + catch (Exception ex) + { + Logger.Warn($"[LauncherBackground] Failed to clear background image: {ex.Message}"); + return FailMutation($"Clear failed: {ex.Message}"); + } + } + + public static void ClearCache() + { + DisposeCache(); + _cachedPath = null; + _cachedLength = 0; + _cachedLastWriteTimeUtc = DateTime.MinValue; + } + + internal static string? FindManagedImageFile() + { + return FindImageFile(ResolveLauncherDataPath()); + } + + internal static IReadOnlyList GetSupportedExtensions() => SupportedExtensions; + + private static BackgroundImageMutationResult FailMutation(string message) + { + return new BackgroundImageMutationResult + { + IsSuccess = false, + ErrorMessage = message + }; + } + + private static bool IsCacheCurrent(string imagePath, FileInfo fileInfo) + { + return _cachedBitmap is not null && + string.Equals(_cachedPath, imagePath, StringComparison.OrdinalIgnoreCase) && + _cachedLength == fileInfo.Length && + _cachedLastWriteTimeUtc == fileInfo.LastWriteTimeUtc; + } + private static string? FindImageFile(string directory) { - var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" }; - - foreach (var ext in extensions) + if (!Directory.Exists(directory)) { - var path = Path.Combine(directory, PictureFileName + ext); + return null; + } + + foreach (var extension in SupportedExtensions) + { + var path = Path.Combine(directory, PictureFileName + extension); if (File.Exists(path)) { return path; } } - // 也尝试不带扩展名的匹配(如果文件本身就有扩展名) - var files = Directory.GetFiles(directory, PictureFileName + ".*"); - foreach (var file in files) + foreach (var file in Directory.GetFiles(directory, PictureFileName + ".*")) { - var ext = Path.GetExtension(file).ToLowerInvariant(); - if (extensions.Contains(ext)) + if (IsSupportedExtension(Path.GetExtension(file))) { return file; } @@ -162,13 +288,72 @@ internal static class LauncherBackgroundService return null; } - /// - /// 清除缓存 - /// - public static void ClearCache() + private static void DeleteManagedImageFiles(string directory, string? exceptPath) + { + if (!Directory.Exists(directory)) + { + return; + } + + foreach (var file in Directory.GetFiles(directory, PictureFileName + ".*")) + { + if (!IsSupportedExtension(Path.GetExtension(file))) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(exceptPath) && + string.Equals(Path.GetFullPath(file), Path.GetFullPath(exceptPath), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + TryDeleteFile(file); + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + Logger.Warn($"[LauncherBackground] Failed to delete '{path}': {ex.Message}"); + } + } + + private static string NormalizeExtension(string? extension) + { + return string.IsNullOrWhiteSpace(extension) + ? string.Empty + : extension.Trim().ToLowerInvariant(); + } + + private static bool IsSupportedExtension(string? extension) + { + var normalized = NormalizeExtension(extension); + return SupportedExtensions.Contains(normalized, StringComparer.OrdinalIgnoreCase); + } + + private static string ResolveLauncherDataPath() + { + if (!string.IsNullOrWhiteSpace(LauncherDataDirectoryOverride)) + { + return Path.GetFullPath(LauncherDataDirectoryOverride); + } + + var resolver = new DataLocationResolver(AppContext.BaseDirectory); + return resolver.ResolveLauncherDataPath(); + } + + private static void DisposeCache() { _cachedBitmap?.Dispose(); _cachedBitmap = null; - _cachedPath = null; } } diff --git a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml index 80569e9..875d3a3 100644 --- a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml +++ b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml @@ -5,25 +5,37 @@ xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views" xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources" mc:Ignorable="d" - d:DesignWidth="420" - d:DesignHeight="320" + d:DesignWidth="460" + d:DesignHeight="500" x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow" x:DataType="views:ErrorDebugWindow" x:CompileBindings="False" Title="{x:Static res:Strings.DebugDebug_Title}" - Width="420" - Height="320" + Width="460" + Height="500" CanResize="False" WindowStartupLocation="CenterOwner" Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" TransparencyLevelHint="None" Icon="/Assets/logo.ico"> + + 2 + 4 + 4 + 8 + 8 + 12 + 16 + 8 + 8 + 4 + + - - - - - - - - + + + + + + + + + + + + + + - - - +