mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
feat.启动器图片自定义
This commit is contained in:
@@ -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.<ext>` 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:
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>Cancel</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>OK</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>Select LanMountainDesktop host executable</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>Splash image</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>Choose an image to show on the splash screen. It will be copied into the Launcher data directory.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>No splash image selected</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>Splash image saved. The current splash screen will refresh immediately.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>Splash image cleared.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>Image setting failed: {0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>Current splash image is ready ({0} x {1}).</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>Current splash image is unavailable: {0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>Clear</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>Select splash image</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>Image files</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>Welcome to LanMountain Desktop</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>Welcome to LanMountain Desktop</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>Your desktop, more than one side</value></data>
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>キャンセル</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>OK</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>蘭山デスクトップホスト実行可能ファイルを選択</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>スプラッシュ画像</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>起動画面に表示する画像を選択します。画像は Launcher のデータディレクトリにコピーされます。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>スプラッシュ画像は未設定です</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>スプラッシュ画像を保存しました。現在の起動画面はすぐに更新されます。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>スプラッシュ画像をクリアしました。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>画像設定に失敗しました: {0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>現在のスプラッシュ画像は使用できます({0} x {1})。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>現在のスプラッシュ画像は使用できません: {0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>クリア</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>スプラッシュ画像を選択</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>画像ファイル</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>蘭山デスクトップへようこそ</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>蘭山デスクトップへようこそ</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>あなたのデスクトップ、一面だけじゃない</value></data>
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>취소</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>확인</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>란산 데스크톱 호스트 실행 파일 선택</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>스플래시 이미지</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>시작 화면에 표시할 이미지를 선택합니다. 이미지는 Launcher 데이터 디렉터리에 복사됩니다.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>스플래시 이미지가 설정되지 않았습니다</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>스플래시 이미지가 저장되었습니다. 현재 시작 화면이 즉시 새로 고쳐집니다.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>스플래시 이미지가 지워졌습니다.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>이미지 설정 실패: {0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>현재 스플래시 이미지를 사용할 수 있습니다({0} x {1}).</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>현재 스플래시 이미지를 사용할 수 없습니다: {0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>지우기</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>스플래시 이미지 선택</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>이미지 파일</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>란산 데스크톱에 오신 것을 환영합니다</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>란산 데스크톱에 오신 것을 환영합니다</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>당신의 데스크톱, 한 면이 아닙니다</value></data>
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>取消</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>确定</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>选择阑山桌面主程序可执行文件</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>启动图</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>选择一张图片显示在启动画面中。图片会复制保存到 Launcher 数据目录。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>未设置启动图</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>启动图已保存,当前启动画面会立即刷新。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>启动图已清除。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>图片设置失败:{0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>当前启动图可用({0} × {1})。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>当前启动图不可用:{0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>清除</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>选择启动图</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>图片文件</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>你的桌面,不止一面</value></data>
|
||||
|
||||
@@ -2,22 +2,28 @@ using Avalonia.Media.Imaging;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <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 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; }
|
||||
|
||||
/// <summary>
|
||||
/// 背景图片信息
|
||||
/// </summary>
|
||||
public record BackgroundImageInfo
|
||||
{
|
||||
public required bool Exists { get; init; }
|
||||
@@ -30,29 +36,29 @@ internal static class LauncherBackgroundService
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载背景图片
|
||||
/// </summary>
|
||||
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}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找图片文件
|
||||
/// </summary>
|
||||
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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<Window.Resources>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusLg">8</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusIsland">16</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusComponent">8</CornerRadius>
|
||||
<CornerRadius x:Key="OverlayCornerRadius">8</CornerRadius>
|
||||
<CornerRadius x:Key="ControlCornerRadius">4</CornerRadius>
|
||||
</Window.Resources>
|
||||
|
||||
<Design.DataContext>
|
||||
<views:ErrorDebugWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
|
||||
<!-- 标题 -->
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="{x:Static res:Strings.DebugDebug_SettingsTitle}"
|
||||
FontSize="20"
|
||||
@@ -31,65 +43,108 @@
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
Margin="0,0,0,16" />
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<StackPanel Grid.Row="1" Spacing="16">
|
||||
<!-- 开发模式开关 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_DevMode}"
|
||||
<ScrollViewer Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="16">
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_DevMode}"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_DevModeDesc}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="DevModeToggle"
|
||||
Grid.Column="1"
|
||||
OnContent="{x:Static res:Strings.DebugDebug_On}"
|
||||
OffContent="{x:Static res:Strings.DebugDebug_Off}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16,12">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="{x:Static res:Strings.DebugDebug_AppPath}"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_DevModeDesc}"
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
Grid.Row="1" Grid.Column="0"
|
||||
Text="{x:Static res:Strings.DebugDebug_NotSelected}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="DevModeToggle"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="0,4,12,0" />
|
||||
<Button x:Name="BrowseButton"
|
||||
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
|
||||
Content="{x:Static res:Strings.DebugDebug_Browse}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16,12">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Spacing="2">
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_BackgroundImage}"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_BackgroundImageDesc}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock x:Name="BackgroundImagePathTextBlock"
|
||||
Grid.Row="1" Grid.Column="0"
|
||||
Text="{x:Static res:Strings.DebugDebug_BackgroundImageNotSet}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="0,10,12,0" />
|
||||
|
||||
<StackPanel Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
OnContent="{x:Static res:Strings.DebugDebug_On}"
|
||||
OffContent="{x:Static res:Strings.DebugDebug_Off}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Margin="0,6,0,0">
|
||||
<Button x:Name="BrowseImageButton"
|
||||
Content="{x:Static res:Strings.DebugDebug_Browse}"
|
||||
VerticalAlignment="Center" />
|
||||
<Button x:Name="ClearImageButton"
|
||||
Content="{x:Static res:Strings.DebugDebug_Clear}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 应用路径选择 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="16,12">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="{x:Static res:Strings.DebugDebug_AppPath}"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
Grid.Row="1" Grid.Column="0"
|
||||
Text="{x:Static res:Strings.DebugDebug_NotSelected}"
|
||||
<TextBlock x:Name="BackgroundImageStatusTextBlock"
|
||||
Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
Text="{x:Static res:Strings.DebugDebug_BackgroundImageNotSet}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="True">
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_Warning}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="0,4,12,0" />
|
||||
<Button x:Name="BrowseButton"
|
||||
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
|
||||
Content="{x:Static res:Strings.DebugDebug_Browse}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="12,10"
|
||||
IsVisible="True">
|
||||
<TextBlock Text="{x:Static res:Strings.DebugDebug_Warning}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
@@ -46,6 +47,7 @@ public partial class ErrorDebugWindow : Window
|
||||
}
|
||||
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
RefreshBackgroundImageDisplay();
|
||||
}
|
||||
|
||||
private void InitializeComponents()
|
||||
@@ -63,6 +65,16 @@ public partial class ErrorDebugWindow : Window
|
||||
browseButton.Click += OnBrowseClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("BrowseImageButton") is { } browseImageButton)
|
||||
{
|
||||
browseImageButton.Click += OnBrowseImageClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("ClearImageButton") is { } clearImageButton)
|
||||
{
|
||||
clearImageButton.Click += OnClearImageClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("OkButton") is { } okButton)
|
||||
{
|
||||
okButton.Click += (_, _) =>
|
||||
@@ -111,6 +123,56 @@ public partial class ErrorDebugWindow : Window
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
|
||||
private async void OnBrowseImageClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var storageProvider = StorageProvider;
|
||||
if (storageProvider is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var patterns = LauncherBackgroundService
|
||||
.GetSupportedExtensions()
|
||||
.Select(extension => "*" + extension)
|
||||
.ToArray();
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = Strings.DebugDebug_SelectImageDialog,
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType(Strings.DebugDebug_ImageFiles)
|
||||
{
|
||||
Patterns = patterns
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||
if (result.Count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var saveResult = LauncherBackgroundService.SaveBackgroundImage(result[0].Path.LocalPath);
|
||||
var status = saveResult.IsSuccess
|
||||
? Strings.DebugDebug_BackgroundImageSaved
|
||||
: string.Format(Strings.DebugDebug_BackgroundImageSaveFailedFormat, saveResult.ErrorMessage ?? string.Empty);
|
||||
|
||||
RefreshBackgroundImageDisplay(status);
|
||||
}
|
||||
|
||||
private void OnClearImageClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var clearResult = LauncherBackgroundService.ClearBackgroundImage();
|
||||
var status = clearResult.IsSuccess
|
||||
? Strings.DebugDebug_BackgroundImageCleared
|
||||
: string.Format(Strings.DebugDebug_BackgroundImageSaveFailedFormat, clearResult.ErrorMessage ?? string.Empty);
|
||||
|
||||
RefreshBackgroundImageDisplay(status);
|
||||
}
|
||||
|
||||
private void UpdatePathDisplay(string? path)
|
||||
{
|
||||
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
|
||||
@@ -118,4 +180,46 @@ public partial class ErrorDebugWindow : Window
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? Strings.DebugDebug_NotSelected : path;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshBackgroundImageDisplay(string? statusOverride = null)
|
||||
{
|
||||
var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
|
||||
|
||||
if (this.FindControl<TextBlock>("BackgroundImagePathTextBlock") is { } pathTextBlock)
|
||||
{
|
||||
pathTextBlock.Text = imageInfo.Exists && !string.IsNullOrWhiteSpace(imageInfo.FilePath)
|
||||
? imageInfo.FilePath
|
||||
: Strings.DebugDebug_BackgroundImageNotSet;
|
||||
}
|
||||
|
||||
if (this.FindControl<TextBlock>("BackgroundImageStatusTextBlock") is { } statusTextBlock)
|
||||
{
|
||||
statusTextBlock.Text = statusOverride ?? ResolveBackgroundImageStatus(imageInfo);
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("ClearImageButton") is { } clearButton)
|
||||
{
|
||||
clearButton.IsEnabled = imageInfo.Exists;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveBackgroundImageStatus(LauncherBackgroundService.BackgroundImageInfo imageInfo)
|
||||
{
|
||||
if (imageInfo.IsValid)
|
||||
{
|
||||
return string.Format(
|
||||
Strings.DebugDebug_BackgroundImageReadyFormat,
|
||||
imageInfo.Width,
|
||||
imageInfo.Height);
|
||||
}
|
||||
|
||||
if (imageInfo.Exists)
|
||||
{
|
||||
return string.Format(
|
||||
Strings.DebugDebug_BackgroundImageInvalidFormat,
|
||||
imageInfo.ErrorMessage ?? string.Empty);
|
||||
}
|
||||
|
||||
return Strings.DebugDebug_BackgroundImageNotSet;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,72 +15,91 @@
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
WindowDecorations="None"
|
||||
Background="#0B0B0B"
|
||||
TransparencyLevelHint="None"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Transparent"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Window.Resources>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusLg">8</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusIsland">16</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusComponent">8</CornerRadius>
|
||||
<CornerRadius x:Key="OverlayCornerRadius">8</CornerRadius>
|
||||
<CornerRadius x:Key="ControlCornerRadius">4</CornerRadius>
|
||||
</Window.Resources>
|
||||
|
||||
<Design.DataContext>
|
||||
<views:SplashWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 背景图片 -->
|
||||
<Image x:Name="BackgroundImage"
|
||||
Grid.RowSpan="2"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False"
|
||||
Opacity="0"/>
|
||||
<Border x:Name="RootShell"
|
||||
Background="#0B0B0B"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<Image x:Name="BackgroundImage"
|
||||
Grid.RowSpan="2"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="False"
|
||||
Opacity="0"/>
|
||||
|
||||
<!-- 半透明遮罩层 -->
|
||||
<Border x:Name="BackgroundOverlay"
|
||||
Grid.RowSpan="2"
|
||||
Background="#0B0B0B"
|
||||
Opacity="0.85"/>
|
||||
<Border x:Name="BackgroundOverlay"
|
||||
Grid.RowSpan="2"
|
||||
Background="#0B0B0B"
|
||||
Opacity="0.42"/>
|
||||
|
||||
<Grid Grid.Row="0"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Padding="24,18,24,24"
|
||||
Background="Transparent">
|
||||
<Grid RowDefinitions="Auto,Auto"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Border x:Name="VersionTextBorder"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
HorizontalAlignment="Left">
|
||||
<TextBlock x:Name="VersionText"
|
||||
FontSize="11"
|
||||
Foreground="#B9C0CC"
|
||||
Text="0.0.0-dev (Administrate)" />
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="#B9C0CC"
|
||||
HorizontalAlignment="Right"
|
||||
Text="{x:Static res:Strings.Splash_StatusInitializing}" />
|
||||
</Grid>
|
||||
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="#F6F7FB"
|
||||
Background="#2C313D" />
|
||||
<Grid Grid.Row="0"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="{x:Static res:Strings.Splash_AppName}"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Padding="24,18,24,24"
|
||||
Background="Transparent">
|
||||
<Grid RowDefinitions="Auto,Auto"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Border x:Name="VersionTextBorder"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
HorizontalAlignment="Left">
|
||||
<TextBlock x:Name="VersionText"
|
||||
FontSize="11"
|
||||
Foreground="#D8DEE9"
|
||||
Text="0.0.0-dev (Administrate)" />
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="#D8DEE9"
|
||||
HorizontalAlignment="Right"
|
||||
Text="{x:Static res:Strings.Splash_StatusInitializing}" />
|
||||
</Grid>
|
||||
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="#F6F7FB"
|
||||
Background="#592C313D" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
|
||||
@@ -42,6 +42,8 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
try
|
||||
{
|
||||
ResetBackgroundImage();
|
||||
|
||||
var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
|
||||
if (imageInfo is { IsValid: true, Bitmap: not null })
|
||||
{
|
||||
@@ -51,16 +53,27 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
backgroundImage.IsVisible = true;
|
||||
backgroundImage.Opacity = 1;
|
||||
}
|
||||
Logger.Info("[SplashWindow] 背景图片加载成功");
|
||||
|
||||
Logger.Info("[SplashWindow] Background image loaded.");
|
||||
}
|
||||
else if (imageInfo is { Exists: true, IsValid: false })
|
||||
{
|
||||
Logger.Warn($"[SplashWindow] 背景图片校验失败: {imageInfo.ErrorMessage}");
|
||||
Logger.Warn($"[SplashWindow] Background image validation failed: {imageInfo.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[SplashWindow] 加载背景图片失败: {ex.Message}");
|
||||
Logger.Warn($"[SplashWindow] Failed to load background image: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetBackgroundImage()
|
||||
{
|
||||
if (this.FindControl<Image>("BackgroundImage") is { } backgroundImage)
|
||||
{
|
||||
backgroundImage.Source = null;
|
||||
backgroundImage.IsVisible = false;
|
||||
backgroundImage.Opacity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +237,7 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
debugWindow.SelectedHostPath));
|
||||
}
|
||||
|
||||
InitializeBackgroundImage();
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
};
|
||||
|
||||
182
LanMountainDesktop.Tests/LauncherBackgroundServiceTests.cs
Normal file
182
LanMountainDesktop.Tests/LauncherBackgroundServiceTests.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class LauncherBackgroundServiceTests : IDisposable
|
||||
{
|
||||
private const string RedPng1x1 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAANSURBVBhXY/jPwPAfAAUAAf+mXJtdAAAAAElFTkSuQmCC";
|
||||
|
||||
private const string BluePng2x2 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAASSURBVBhXY2Bg+P8fgsHE//8AP9IH+WMJIRIAAAAASUVORK5CYII=";
|
||||
|
||||
private const string GreenJpeg1x1 =
|
||||
"/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDiqKKK+aPjz//Z";
|
||||
|
||||
private readonly string _tempDirectory;
|
||||
private readonly string _launcherDataDirectory;
|
||||
private static readonly object AvaloniaGate = new();
|
||||
private static bool _avaloniaInitialized;
|
||||
|
||||
public LauncherBackgroundServiceTests()
|
||||
{
|
||||
EnsureAvaloniaInitialized();
|
||||
|
||||
_tempDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.BackgroundImageTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
_launcherDataDirectory = Path.Combine(_tempDirectory, ".Launcher");
|
||||
Directory.CreateDirectory(_launcherDataDirectory);
|
||||
LauncherBackgroundService.LauncherDataDirectoryOverride = _launcherDataDirectory;
|
||||
LauncherBackgroundService.ClearCache();
|
||||
}
|
||||
|
||||
private static void EnsureAvaloniaInitialized()
|
||||
{
|
||||
lock (AvaloniaGate)
|
||||
{
|
||||
if (_avaloniaInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Application.Current is null)
|
||||
{
|
||||
AppBuilder
|
||||
.Configure<Application>()
|
||||
.UsePlatformDetect()
|
||||
.SetupWithoutStarting();
|
||||
}
|
||||
|
||||
_avaloniaInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveBackgroundImage_CopiesSelectedImageToLauncherDataDirectory()
|
||||
{
|
||||
var sourcePath = WriteImage("selected.png", RedPng1x1);
|
||||
|
||||
var result = LauncherBackgroundService.SaveBackgroundImage(sourcePath);
|
||||
|
||||
Assert.True(result.IsSuccess, result.ErrorMessage);
|
||||
Assert.Equal(Path.Combine(_launcherDataDirectory, "Launcher Picture.png"), result.FilePath);
|
||||
Assert.True(File.Exists(result.FilePath));
|
||||
Assert.Equal(File.ReadAllBytes(sourcePath), File.ReadAllBytes(result.FilePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveBackgroundImage_ReplacesPreviousManagedExtension()
|
||||
{
|
||||
var pngSourcePath = WriteImage("first.png", RedPng1x1);
|
||||
var jpegSourcePath = WriteImage("second.jpg", GreenJpeg1x1);
|
||||
|
||||
var firstResult = LauncherBackgroundService.SaveBackgroundImage(pngSourcePath);
|
||||
var secondResult = LauncherBackgroundService.SaveBackgroundImage(jpegSourcePath);
|
||||
|
||||
Assert.True(firstResult.IsSuccess, firstResult.ErrorMessage);
|
||||
Assert.True(secondResult.IsSuccess, secondResult.ErrorMessage);
|
||||
Assert.False(File.Exists(Path.Combine(_launcherDataDirectory, "Launcher Picture.png")));
|
||||
Assert.True(File.Exists(Path.Combine(_launcherDataDirectory, "Launcher Picture.jpg")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadBackgroundImage_AcceptsNonSevenByFiveImage()
|
||||
{
|
||||
var sourcePath = WriteImage("square.png", RedPng1x1);
|
||||
|
||||
var saveResult = LauncherBackgroundService.SaveBackgroundImage(sourcePath);
|
||||
var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
|
||||
|
||||
Assert.True(saveResult.IsSuccess, saveResult.ErrorMessage);
|
||||
Assert.True(imageInfo.IsValid, imageInfo.ErrorMessage);
|
||||
Assert.Equal(1, imageInfo.Width);
|
||||
Assert.Equal(1, imageInfo.Height);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("oversized.png", InvalidImageKind.Oversized)]
|
||||
[InlineData("unknown.txt", InvalidImageKind.UnknownExtension)]
|
||||
[InlineData("broken.png", InvalidImageKind.BrokenImage)]
|
||||
public void SaveBackgroundImage_WhenInvalid_DoesNotOverwriteExistingImage(
|
||||
string invalidFileName,
|
||||
InvalidImageKind invalidImageKind)
|
||||
{
|
||||
var existingPath = WriteImage("existing.png", RedPng1x1);
|
||||
var existingResult = LauncherBackgroundService.SaveBackgroundImage(existingPath);
|
||||
var managedPath = existingResult.FilePath!;
|
||||
var originalBytes = File.ReadAllBytes(managedPath);
|
||||
var invalidPath = WriteInvalidFile(invalidFileName, invalidImageKind);
|
||||
|
||||
var invalidResult = LauncherBackgroundService.SaveBackgroundImage(invalidPath);
|
||||
|
||||
Assert.False(invalidResult.IsSuccess);
|
||||
Assert.True(File.Exists(managedPath));
|
||||
Assert.Equal(originalBytes, File.ReadAllBytes(managedPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadBackgroundImage_WhenFileChangesAtSamePath_RefreshesCachedBitmap()
|
||||
{
|
||||
var sourcePath = WriteImage("source.png", RedPng1x1);
|
||||
var saveResult = LauncherBackgroundService.SaveBackgroundImage(sourcePath);
|
||||
Assert.True(saveResult.IsSuccess, saveResult.ErrorMessage);
|
||||
|
||||
var firstLoad = LauncherBackgroundService.LoadBackgroundImage();
|
||||
Assert.True(firstLoad.IsValid, firstLoad.ErrorMessage);
|
||||
Assert.Equal(1, firstLoad.Width);
|
||||
|
||||
var managedPath = saveResult.FilePath!;
|
||||
File.WriteAllBytes(managedPath, Convert.FromBase64String(BluePng2x2));
|
||||
File.SetLastWriteTimeUtc(managedPath, DateTime.UtcNow.AddSeconds(2));
|
||||
|
||||
var secondLoad = LauncherBackgroundService.LoadBackgroundImage();
|
||||
|
||||
Assert.True(secondLoad.IsValid, secondLoad.ErrorMessage);
|
||||
Assert.Equal(2, secondLoad.Width);
|
||||
Assert.Equal(2, secondLoad.Height);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LauncherBackgroundService.ClearCache();
|
||||
LauncherBackgroundService.LauncherDataDirectoryOverride = null;
|
||||
|
||||
if (Directory.Exists(_tempDirectory))
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private string WriteImage(string fileName, string base64)
|
||||
{
|
||||
var path = Path.Combine(_tempDirectory, fileName);
|
||||
File.WriteAllBytes(path, Convert.FromBase64String(base64));
|
||||
return path;
|
||||
}
|
||||
|
||||
private string WriteInvalidFile(string fileName, InvalidImageKind kind)
|
||||
{
|
||||
var path = Path.Combine(_tempDirectory, fileName);
|
||||
var bytes = kind switch
|
||||
{
|
||||
InvalidImageKind.Oversized => new byte[(10 * 1024 * 1024) + 1],
|
||||
InvalidImageKind.UnknownExtension => Convert.FromBase64String(RedPng1x1),
|
||||
InvalidImageKind.BrokenImage => "not an image"u8.ToArray(),
|
||||
_ => []
|
||||
};
|
||||
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
public enum InvalidImageKind
|
||||
{
|
||||
Oversized,
|
||||
UnknownExtension,
|
||||
BrokenImage
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,14 @@ This supplement records the startup rules that are shared by the launcher and th
|
||||
- Slide splash enters from the right edge of the target screen and exits back to the right edge.
|
||||
- Static splash uses the same fullscreen black surface without motion.
|
||||
|
||||
## Launcher splash image rules
|
||||
|
||||
- The hidden launcher debug menu can save a custom splash image.
|
||||
- The selected image is copied into the Launcher data directory as `Launcher Picture.<ext>`.
|
||||
- Supported formats are `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, and `.webp`; files larger than `10MB` are rejected.
|
||||
- Splash displays the image with `Uniform` fitting, preserving the full image and allowing black letterboxing.
|
||||
- The splash window uses a transparent self-drawn shell with a fixed Fluent `8px` outer corner radius.
|
||||
|
||||
## Recovery rules
|
||||
|
||||
- Closing Launcher during startup does not cancel the startup attempt.
|
||||
|
||||
Reference in New Issue
Block a user