Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
8df0271032 feat.启动器图片自定义 2026-06-05 23:38:32 +08:00
lincube
eae3e67238 fix.安装器AOT优化 2026-06-05 21:43:43 +08:00
21 changed files with 1110 additions and 184 deletions

View File

@@ -57,15 +57,21 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
$publishDir = Join-Path $env:GITHUB_WORKSPACE '${{ env.INSTALLER_ARTIFACT_DIR }}' $publishDir = Join-Path $env:GITHUB_WORKSPACE '${{ env.INSTALLER_ARTIFACT_DIR }}'
$tempDir = Join-Path $env:GITHUB_WORKSPACE 'artifacts/installer-online/tmp'
if (Test-Path $publishDir) { if (Test-Path $publishDir) {
Remove-Item -LiteralPath $publishDir -Recurse -Force Remove-Item -LiteralPath $publishDir -Recurse -Force
} }
New-Item -ItemType Directory -Path $publishDir -Force | Out-Null New-Item -ItemType Directory -Path $publishDir -Force | Out-Null
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
$env:TEMP = $tempDir
$env:TMP = $tempDir
dotnet restore '${{ env.INSTALLER_PROJECT }}' -r '${{ env.INSTALLER_RUNTIME }}' dotnet restore '${{ env.INSTALLER_PROJECT }}' `
-r '${{ env.INSTALLER_RUNTIME }}' `
-p:PublishAot=true
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Online installer runtime restore failed with exit code $LASTEXITCODE." throw "Online installer NativeAOT restore failed with exit code $LASTEXITCODE."
} }
dotnet publish '${{ env.INSTALLER_PROJECT }}' ` dotnet publish '${{ env.INSTALLER_PROJECT }}' `
@@ -74,6 +80,9 @@ jobs:
-r '${{ env.INSTALLER_RUNTIME }}' ` -r '${{ env.INSTALLER_RUNTIME }}' `
-p:PublishAot=true ` -p:PublishAot=true `
-p:UseAppHost=true ` -p:UseAppHost=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:StripSymbols=true `
-o $publishDir ` -o $publishDir `
-v minimal -v minimal
@@ -90,7 +99,32 @@ jobs:
Write-Warning "dotnet publish exited with $LASTEXITCODE after producing the installer artifact." Write-Warning "dotnet publish exited with $LASTEXITCODE after producing the installer artifact."
} }
Get-ChildItem -Path $publishDir -File -Filter 'LanDesktopPLONDS.installer*' | Get-ChildItem -Path $publishDir -Recurse -Filter '*.pdb' |
Remove-Item -Force
$jitFiles = @(
'coreclr.dll',
'clrjit.dll',
'hostfxr.dll',
'hostpolicy.dll',
'LanDesktopPLONDS.installer.deps.json',
'LanDesktopPLONDS.installer.runtimeconfig.json'
)
foreach ($file in $jitFiles) {
if (Test-Path (Join-Path $publishDir $file)) {
throw "JIT runtime artifact found in NativeAOT output: $file"
}
}
$unexpectedFiles = Get-ChildItem -Path $publishDir -File |
Where-Object { $_.Name -ne 'LanDesktopPLONDS.installer.exe' }
if ($unexpectedFiles) {
$names = ($unexpectedFiles | Select-Object -ExpandProperty Name) -join ', '
throw "Unexpected files in single-exe NativeAOT installer artifact: $names"
}
Get-ChildItem -Path $publishDir -File |
Sort-Object Name |
Select-Object Name, Length Select-Object Name, Length
- name: Upload online installer artifact - name: Upload online installer artifact

View File

@@ -19,6 +19,14 @@
- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`. - `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`.
- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`. - `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 ## UX safeguards
- If the host process is still alive at failure time, the failure dialog must prefer: - If the host process is still alive at failure time, the failure dialog must prefer:

View File

@@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="12.0.3" /> <PackageVersion Include="Avalonia" Version="12.0.3" />
<PackageVersion Include="Avalonia.Angle.Windows.Natives" Version="2.1.25547.20250602" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" /> <PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" /> <PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
@@ -16,6 +17,7 @@
<PackageVersion Include="Downloader" Version="5.4.0" /> <PackageVersion Include="Downloader" Version="5.4.0" />
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview4" /> <PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview4" />
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" /> <PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Win32" Version="8.3.1.3" />
<PackageVersion Include="Lib.Harmony.Thin" Version="2.4.2" /> <PackageVersion Include="Lib.Harmony.Thin" Version="2.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.17.0" /> <PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" /> <PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
@@ -32,6 +34,7 @@
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" /> <PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
<PackageVersion Include="PostHog" Version="2.7.1" /> <PackageVersion Include="PostHog" Version="2.7.1" />
<PackageVersion Include="Sentry" Version="6.5.0" /> <PackageVersion Include="Sentry" Version="6.5.0" />
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="3.119.4-preview.1.1" />
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" /> <PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" /> <PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" /> <PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />

View File

@@ -1,6 +1,6 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling" xmlns:theme="using:Avalonia.Themes.Fluent"
xmlns:fi="using:FluentIcons.Avalonia" xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanDesktopPLONDS.Installer.App" x:Class="LanDesktopPLONDS.Installer.App"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
@@ -69,7 +69,7 @@
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
<sty:FluentAvaloniaTheme /> <theme:FluentTheme />
<Style Selector="Window"> <Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" /> <Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style> </Style>

View File

@@ -0,0 +1,45 @@
param(
[Parameter(Mandatory = $true)]
[string] $SourcePath,
[Parameter(Mandatory = $true)]
[string] $DestinationPath
)
$ErrorActionPreference = 'Stop'
$source = Get-Item -LiteralPath $SourcePath
$destinationDirectory = Split-Path -Parent $DestinationPath
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
$existing = Get-Item -LiteralPath $DestinationPath -ErrorAction SilentlyContinue
if ($existing -and $existing.LastWriteTimeUtc -ge $source.LastWriteTimeUtc -and $existing.Length -gt 0) {
return
}
$temporaryPath = "$DestinationPath.$PID.tmp"
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force
}
$inputStream = [System.IO.File]::OpenRead($source.FullName)
try {
$outputStream = [System.IO.File]::Create($temporaryPath)
try {
$gzipStream = New-Object System.IO.Compression.GZipStream($outputStream, [System.IO.Compression.CompressionMode]::Compress)
try {
$inputStream.CopyTo($gzipStream)
}
finally {
$gzipStream.Dispose()
}
}
finally {
$outputStream.Dispose()
}
}
finally {
$inputStream.Dispose()
}
Move-Item -LiteralPath $temporaryPath -Destination $DestinationPath -Force

View File

@@ -19,12 +19,52 @@
<ItemGroup Condition="'$(PublishAot)' == 'true'"> <ItemGroup Condition="'$(PublishAot)' == 'true'">
<TrimmerRootAssembly Include="Avalonia" /> <TrimmerRootAssembly Include="Avalonia" />
<TrimmerRootAssembly Include="Avalonia.Desktop" /> <TrimmerRootAssembly Include="Avalonia.Desktop" />
<TrimmerRootAssembly Include="FluentAvalonia" /> <TrimmerRootAssembly Include="Avalonia.Themes.Fluent" />
<TrimmerRootAssembly Include="FluentIcons.Avalonia" /> <TrimmerRootAssembly Include="FluentIcons.Avalonia" />
<TrimmerRootAssembly Include="LanDesktopPLONDS.installer" /> <TrimmerRootAssembly Include="LanDesktopPLONDS.installer" />
<TrimmerRootAssembly Include="System.Text.Json" /> <TrimmerRootAssembly Include="System.Text.Json" />
</ItemGroup> </ItemGroup>
<Target
Name="PrepareInstallerEmbeddedNativeLibraries"
BeforeTargets="AssignTargetPaths"
Condition="'$(PublishAot)' == 'true' and '$(RuntimeIdentifier)' == 'win-x64'">
<ItemGroup>
<InstallerNativeLibrary
Include="$(PkgAvalonia_Angle_Windows_Natives)\runtimes\win-x64\native\av_libglesv2.dll"
CompressedName="av_libglesv2.dll.gz"
Condition="Exists('$(PkgAvalonia_Angle_Windows_Natives)\runtimes\win-x64\native\av_libglesv2.dll')" />
<InstallerNativeLibrary
Include="$(PkgHarfBuzzSharp_NativeAssets_Win32)\runtimes\win-x64\native\libHarfBuzzSharp.dll"
CompressedName="libHarfBuzzSharp.dll.gz"
Condition="Exists('$(PkgHarfBuzzSharp_NativeAssets_Win32)\runtimes\win-x64\native\libHarfBuzzSharp.dll')" />
<InstallerNativeLibrary
Include="$(PkgSkiaSharp_NativeAssets_Win32)\runtimes\win-x64\native\libSkiaSharp.dll"
CompressedName="libSkiaSharp.dll.gz"
Condition="Exists('$(PkgSkiaSharp_NativeAssets_Win32)\runtimes\win-x64\native\libSkiaSharp.dll')" />
</ItemGroup>
<Error
Condition="'@(InstallerNativeLibrary)' == ''"
Text="NativeAOT installer native libraries were not found. Restore the installer with -p:PublishAot=true -r win-x64 before publishing." />
<MakeDir Directories="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\" />
<Exec
Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildThisFileDirectory)Compress-NativeLibrary.ps1&quot; -SourcePath &quot;%(InstallerNativeLibrary.FullPath)&quot; -DestinationPath &quot;$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\%(InstallerNativeLibrary.CompressedName)&quot;" />
<ItemGroup>
<EmbeddedResource
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\av_libglesv2.dll.gz"
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.av_libglesv2.dll.gz" />
<EmbeddedResource
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\libHarfBuzzSharp.dll.gz"
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.libHarfBuzzSharp.dll.gz" />
<EmbeddedResource
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\libSkiaSharp.dll.gz"
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.libSkiaSharp.dll.gz" />
</ItemGroup>
</Target>
<PropertyGroup Condition="'$(PublishAot)' == 'true'"> <PropertyGroup Condition="'$(PublishAot)' == 'true'">
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings> <SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn> <TrimmerSingleWarn>false</TrimmerSingleWarn>

View File

@@ -20,10 +20,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Angle.Windows.Natives" GeneratePathProperty="true" PrivateAssets="all" />
<PackageReference Include="Avalonia.Desktop" /> <PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" /> <PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="FluentAvaloniaUI" /> <PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="FluentIcons.Avalonia" /> <PackageReference Include="FluentIcons.Avalonia" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,170 @@
using System.ComponentModel;
using System.Diagnostics;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.InteropServices;
namespace LanDesktopPLONDS.Installer;
internal static class NativeDependencyBootstrapper
{
private const string CacheRootEnvironmentVariable = "LANDESKTOPPLONDS_INSTALLER_NATIVE_CACHE";
private const string ResourcePrefix = "LanDesktopPLONDS.Installer.NativeLibraries.";
private static readonly string[] NativeLibraryNames =
[
"av_libglesv2.dll",
"libHarfBuzzSharp.dll",
"libSkiaSharp.dll"
];
public static void Prepare()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var nativeDirectory = GetNativeDirectory();
Directory.CreateDirectory(nativeDirectory);
var extractedLibraries = new List<string>(NativeLibraryNames.Length);
foreach (var libraryName in NativeLibraryNames)
{
extractedLibraries.Add(ExtractLibrary(nativeDirectory, libraryName));
}
AddToProcessDllSearchPath(nativeDirectory);
foreach (var libraryPath in extractedLibraries)
{
NativeLibrary.Load(libraryPath);
}
}
private static string GetNativeDirectory()
{
var configuredCacheRoot = Environment.GetEnvironmentVariable(CacheRootEnvironmentVariable);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var cacheRoot = !string.IsNullOrWhiteSpace(configuredCacheRoot)
? configuredCacheRoot
: string.IsNullOrWhiteSpace(localAppData)
? Path.GetTempPath()
: localAppData;
string? versionStamp = null;
if (!string.IsNullOrWhiteSpace(Environment.ProcessPath))
{
versionStamp = FileVersionInfo.GetVersionInfo(Environment.ProcessPath).ProductVersion;
}
if (string.IsNullOrWhiteSpace(versionStamp))
{
versionStamp = "dev";
}
return Path.Combine(
cacheRoot,
"LanDesktopPLONDS",
"Installer",
"native",
RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(),
SanitizePathSegment(versionStamp));
}
private static string ExtractLibrary(string nativeDirectory, string libraryName)
{
var resourceName = ResourcePrefix + libraryName + ".gz";
var assembly = Assembly.GetExecutingAssembly();
using var resource = assembly.GetManifestResourceStream(resourceName);
if (resource is null)
{
var availableResources = string.Join(", ", assembly.GetManifestResourceNames());
throw new FileNotFoundException(
$"Missing embedded native installer library resource '{resourceName}'. Available resources: {availableResources}");
}
var destinationPath = Path.Combine(nativeDirectory, libraryName);
var temporaryPath = destinationPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
using (var gzip = new GZipStream(resource, CompressionMode.Decompress))
using (var output = File.Create(temporaryPath))
{
gzip.CopyTo(output);
}
if (File.Exists(destinationPath) && FilesEqual(destinationPath, temporaryPath))
{
File.Delete(temporaryPath);
return destinationPath;
}
File.Move(temporaryPath, destinationPath, overwrite: true);
return destinationPath;
}
private static void AddToProcessDllSearchPath(string nativeDirectory)
{
var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
if (!currentPath.Contains(nativeDirectory, StringComparison.OrdinalIgnoreCase))
{
Environment.SetEnvironmentVariable("PATH", nativeDirectory + Path.PathSeparator + currentPath);
}
if (!SetDllDirectory(nativeDirectory))
{
throw new Win32Exception(Marshal.GetLastPInvokeError(), "Failed to update the process native DLL search path.");
}
}
private static string SanitizePathSegment(string value)
{
foreach (var invalidChar in Path.GetInvalidFileNameChars())
{
value = value.Replace(invalidChar, '_');
}
return value;
}
private static bool FilesEqual(string leftPath, string rightPath)
{
var left = new FileInfo(leftPath);
var right = new FileInfo(rightPath);
if (left.Length != right.Length)
{
return false;
}
using var leftStream = File.OpenRead(leftPath);
using var rightStream = File.OpenRead(rightPath);
var leftBuffer = new byte[81920];
var rightBuffer = new byte[81920];
while (true)
{
var leftRead = leftStream.Read(leftBuffer, 0, leftBuffer.Length);
var rightRead = rightStream.Read(rightBuffer, 0, rightBuffer.Length);
if (leftRead != rightRead)
{
return false;
}
if (leftRead == 0)
{
return true;
}
for (var i = 0; i < leftRead; i++)
{
if (leftBuffer[i] != rightBuffer[i])
{
return false;
}
}
}
}
[DllImport("kernel32", EntryPoint = "SetDllDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetDllDirectory(string pathName);
}

View File

@@ -7,6 +7,7 @@ public static class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
NativeDependencyBootstrapper.Prepare();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} }

View File

@@ -105,6 +105,17 @@ public static class Strings
public static string DebugDebug_ButtonCancel => ResourceManager.GetString(nameof(DebugDebug_ButtonCancel), Culture)!; 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_ButtonOk => ResourceManager.GetString(nameof(DebugDebug_ButtonOk), Culture)!;
public static string DebugDebug_SelectExeDialog => ResourceManager.GetString(nameof(DebugDebug_SelectExeDialog), 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_Title => ResourceManager.GetString(nameof(Oobe_Title), Culture)!;
public static string Oobe_WelcomeTitle => ResourceManager.GetString(nameof(Oobe_WelcomeTitle), Culture)!; public static string Oobe_WelcomeTitle => ResourceManager.GetString(nameof(Oobe_WelcomeTitle), Culture)!;
public static string Oobe_WelcomeSubtitle => ResourceManager.GetString(nameof(Oobe_WelcomeSubtitle), Culture)!; public static string Oobe_WelcomeSubtitle => ResourceManager.GetString(nameof(Oobe_WelcomeSubtitle), Culture)!;

View File

@@ -119,6 +119,17 @@
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>Cancel</value></data> <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_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_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_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_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> <data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>Your desktop, more than one side</value></data>

View File

@@ -119,6 +119,17 @@
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>キャンセル</value></data> <data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>キャンセル</value></data>
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>OK</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_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_Title" xml:space="preserve"><value>蘭山デスクトップへようこそ</value></data>
<data name="Oobe_WelcomeTitle" 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> <data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>あなたのデスクトップ、一面だけじゃない</value></data>

View File

@@ -119,6 +119,17 @@
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>취소</value></data> <data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>취소</value></data>
<data name="DebugDebug_ButtonOk" 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_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_Title" xml:space="preserve"><value>란산 데스크톱에 오신 것을 환영합니다</value></data>
<data name="Oobe_WelcomeTitle" 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> <data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>당신의 데스크톱, 한 면이 아닙니다</value></data>

View File

@@ -119,6 +119,17 @@
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>取消</value></data> <data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>取消</value></data>
<data name="DebugDebug_ButtonOk" 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_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_Title" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
<data name="Oobe_WelcomeTitle" 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> <data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>你的桌面,不止一面</value></data>

View File

@@ -2,22 +2,28 @@ using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Launcher.Shell; namespace LanMountainDesktop.Launcher.Shell;
/// <summary>
/// 启动器背景图片服务
/// </summary>
internal static class LauncherBackgroundService internal static class LauncherBackgroundService
{ {
private const string PictureFileName = "Launcher Picture"; private const string PictureFileName = "Launcher Picture";
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB private const long MaxFileSize = 10 * 1024 * 1024;
private const double WindowAspectRatio = 7.0 / 5.0; // 700:500
private const double AspectRatioTolerance = 0.15; // 15% 误差 private static readonly string[] SupportedExtensions =
[
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".webp"
];
private static Bitmap? _cachedBitmap; private static Bitmap? _cachedBitmap;
private static string? _cachedPath; private static string? _cachedPath;
private static long _cachedLength;
private static DateTime _cachedLastWriteTimeUtc;
internal static string? LauncherDataDirectoryOverride { get; set; }
/// <summary>
/// 背景图片信息
/// </summary>
public record BackgroundImageInfo public record BackgroundImageInfo
{ {
public required bool Exists { get; init; } public required bool Exists { get; init; }
@@ -30,29 +36,29 @@ internal static class LauncherBackgroundService
public string? ErrorMessage { get; init; } public string? ErrorMessage { get; init; }
} }
/// <summary> public record BackgroundImageMutationResult
/// 加载背景图片 {
/// </summary> public required bool IsSuccess { get; init; }
public string? FilePath { get; init; }
public string? ErrorMessage { get; init; }
}
public static BackgroundImageInfo LoadBackgroundImage() public static BackgroundImageInfo LoadBackgroundImage()
{ {
try try
{ {
var resolver = new DataLocationResolver(AppContext.BaseDirectory); var launcherPath = ResolveLauncherDataPath();
var launcherPath = resolver.ResolveLauncherDataPath();
// 查找图片文件
var imagePath = FindImageFile(launcherPath); var imagePath = FindImageFile(launcherPath);
if (imagePath == null) if (imagePath is null)
{ {
return new BackgroundImageInfo return new BackgroundImageInfo
{ {
Exists = false, Exists = false,
IsValid = false, IsValid = false,
ErrorMessage = "未找到背景图片文件" ErrorMessage = "No launcher background image was found."
}; };
} }
// 检查文件大小
var fileInfo = new FileInfo(imagePath); var fileInfo = new FileInfo(imagePath);
if (fileInfo.Length > MaxFileSize) if (fileInfo.Length > MaxFileSize)
{ {
@@ -61,12 +67,11 @@ internal static class LauncherBackgroundService
Exists = true, Exists = true,
IsValid = false, IsValid = false,
FilePath = imagePath, FilePath = imagePath,
ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)" ErrorMessage = $"Image file is too large ({fileInfo.Length / 1024 / 1024}MB > 10MB)."
}; };
} }
// 使用缓存 if (IsCacheCurrent(imagePath, fileInfo))
if (_cachedBitmap != null && _cachedPath == imagePath)
{ {
return new BackgroundImageInfo return new BackgroundImageInfo
{ {
@@ -74,40 +79,40 @@ internal static class LauncherBackgroundService
IsValid = true, IsValid = true,
FilePath = imagePath, FilePath = imagePath,
Bitmap = _cachedBitmap, Bitmap = _cachedBitmap,
Width = _cachedBitmap.PixelSize.Width, Width = _cachedBitmap!.PixelSize.Width,
Height = _cachedBitmap.PixelSize.Height, Height = _cachedBitmap.PixelSize.Height,
AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height
}; };
} }
// 加载图片 DisposeCache();
var bitmap = new Bitmap(imagePath);
var width = bitmap.PixelSize.Width;
var height = bitmap.PixelSize.Height;
var aspectRatio = (double)width / height;
// 校验比例 Bitmap bitmap;
var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio; try
if (ratioDiff > AspectRatioTolerance) {
bitmap = new Bitmap(imagePath);
}
catch (Exception ex)
{ {
bitmap.Dispose();
return new BackgroundImageInfo return new BackgroundImageInfo
{ {
Exists = true, Exists = true,
IsValid = false, IsValid = false,
FilePath = imagePath, FilePath = imagePath,
Width = width, ErrorMessage = $"Image could not be decoded: {ex.Message}"
Height = height,
AspectRatio = aspectRatio,
ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})"
}; };
} }
// 缓存图片 var width = bitmap.PixelSize.Width;
var height = bitmap.PixelSize.Height;
var aspectRatio = height == 0 ? 0d : (double)width / height;
_cachedBitmap = bitmap; _cachedBitmap = bitmap;
_cachedPath = imagePath; _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 return new BackgroundImageInfo
{ {
@@ -122,38 +127,159 @@ internal static class LauncherBackgroundService
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}"); Logger.Warn($"[LauncherBackground] Failed to load background image: {ex.Message}");
return new BackgroundImageInfo return new BackgroundImageInfo
{ {
Exists = false, Exists = false,
IsValid = false, IsValid = false,
ErrorMessage = $"加载失败: {ex.Message}" ErrorMessage = $"Load failed: {ex.Message}"
}; };
} }
} }
/// <summary> public static BackgroundImageMutationResult SaveBackgroundImage(string sourcePath)
/// 查找图片文件 {
/// </summary> 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) private static string? FindImageFile(string directory)
{ {
var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" }; if (!Directory.Exists(directory))
foreach (var ext in extensions)
{ {
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)) if (File.Exists(path))
{ {
return path; return path;
} }
} }
// 也尝试不带扩展名的匹配(如果文件本身就有扩展名) foreach (var file in Directory.GetFiles(directory, PictureFileName + ".*"))
var files = Directory.GetFiles(directory, PictureFileName + ".*");
foreach (var file in files)
{ {
var ext = Path.GetExtension(file).ToLowerInvariant(); if (IsSupportedExtension(Path.GetExtension(file)))
if (extensions.Contains(ext))
{ {
return file; return file;
} }
@@ -162,13 +288,72 @@ internal static class LauncherBackgroundService
return null; return null;
} }
/// <summary> private static void DeleteManagedImageFiles(string directory, string? exceptPath)
/// 清除缓存 {
/// </summary> if (!Directory.Exists(directory))
public static void ClearCache() {
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?.Dispose();
_cachedBitmap = null; _cachedBitmap = null;
_cachedPath = null;
} }
} }

View File

@@ -5,25 +5,37 @@
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views" xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources" xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignWidth="420" d:DesignWidth="460"
d:DesignHeight="320" d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow" x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
x:DataType="views:ErrorDebugWindow" x:DataType="views:ErrorDebugWindow"
x:CompileBindings="False" x:CompileBindings="False"
Title="{x:Static res:Strings.DebugDebug_Title}" Title="{x:Static res:Strings.DebugDebug_Title}"
Width="420" Width="460"
Height="320" Height="500"
CanResize="False" CanResize="False"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None" TransparencyLevelHint="None"
Icon="/Assets/logo.ico"> 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> <Design.DataContext>
<views:ErrorDebugWindow /> <views:ErrorDebugWindow />
</Design.DataContext> </Design.DataContext>
<Grid Margin="24" RowDefinitions="Auto,*,Auto"> <Grid Margin="24" RowDefinitions="Auto,*,Auto">
<!-- 标题 -->
<TextBlock Grid.Row="0" <TextBlock Grid.Row="0"
Text="{x:Static res:Strings.DebugDebug_SettingsTitle}" Text="{x:Static res:Strings.DebugDebug_SettingsTitle}"
FontSize="20" FontSize="20"
@@ -31,65 +43,108 @@
Foreground="{DynamicResource TextFillColorPrimaryBrush}" Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Margin="0,0,0,16" /> Margin="0,0,0,16" />
<!-- 设置内容 --> <ScrollViewer Grid.Row="1"
<StackPanel Grid.Row="1" Spacing="16"> VerticalScrollBarVisibility="Auto">
<!-- 开发模式开关 --> <StackPanel Spacing="16">
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" <Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}" CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16,12"> Padding="16,12">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center"> <StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{x:Static res:Strings.DebugDebug_DevMode}" <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" FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> 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" FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,2,0,0" /> TextTrimming="CharacterEllipsis"
</StackPanel> Margin="0,4,12,0" />
<ToggleSwitch x:Name="DevModeToggle" <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" Grid.Column="1"
OnContent="{x:Static res:Strings.DebugDebug_On}" Orientation="Horizontal"
OffContent="{x:Static res:Strings.DebugDebug_Off}" /> Spacing="8"
</Grid> Margin="0,6,0,0">
</Border> <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>
<!-- 应用路径选择 --> <TextBlock x:Name="BackgroundImageStatusTextBlock"
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
CornerRadius="{DynamicResource ControlCornerRadius}" Text="{x:Static res:Strings.DebugDebug_BackgroundImageNotSet}"
Padding="16,12"> FontSize="12"
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto"> Foreground="{DynamicResource TextFillColorSecondaryBrush}"
<TextBlock Grid.Row="0" Grid.Column="0" TextWrapping="Wrap"
Text="{x:Static res:Strings.DebugDebug_AppPath}" Margin="0,8,0,0" />
FontSize="14" </Grid>
Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> </Border>
<TextBlock x:Name="PathTextBlock"
Grid.Row="1" Grid.Column="0" <Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
Text="{x:Static res:Strings.DebugDebug_NotSelected}" CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="12,10"
IsVisible="True">
<TextBlock Text="{x:Static res:Strings.DebugDebug_Warning}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="{DynamicResource SystemFillColorCautionBrush}"
TextTrimming="CharacterEllipsis" TextWrapping="Wrap" />
Margin="0,4,12,0" /> </Border>
<Button x:Name="BrowseButton" </StackPanel>
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" </ScrollViewer>
Content="{x:Static res:Strings.DebugDebug_Browse}"
VerticalAlignment="Center" />
</Grid>
</Border>
<!-- 提示信息 -->
<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" <StackPanel Grid.Row="2"
Orientation="Horizontal" Orientation="Horizontal"
HorizontalAlignment="Right" HorizontalAlignment="Right"

View File

@@ -3,6 +3,7 @@ using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LanMountainDesktop.Launcher.Resources; using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Shell;
namespace LanMountainDesktop.Launcher.Views; namespace LanMountainDesktop.Launcher.Views;
@@ -46,6 +47,7 @@ public partial class ErrorDebugWindow : Window
} }
UpdatePathDisplay(_selectedHostPath); UpdatePathDisplay(_selectedHostPath);
RefreshBackgroundImageDisplay();
} }
private void InitializeComponents() private void InitializeComponents()
@@ -63,6 +65,16 @@ public partial class ErrorDebugWindow : Window
browseButton.Click += OnBrowseClick; 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) if (this.FindControl<Button>("OkButton") is { } okButton)
{ {
okButton.Click += (_, _) => okButton.Click += (_, _) =>
@@ -111,6 +123,56 @@ public partial class ErrorDebugWindow : Window
UpdatePathDisplay(_selectedHostPath); 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) private void UpdatePathDisplay(string? path)
{ {
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock) 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; 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;
}
} }

View File

@@ -15,72 +15,91 @@
ShowInTaskbar="False" ShowInTaskbar="False"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
WindowDecorations="None" WindowDecorations="None"
Background="#0B0B0B" Background="Transparent"
TransparencyLevelHint="None" TransparencyLevelHint="Transparent"
Icon="/Assets/logo.ico"> 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> <Design.DataContext>
<views:SplashWindow /> <views:SplashWindow />
</Design.DataContext> </Design.DataContext>
<Grid RowDefinitions="*,Auto"> <Border x:Name="RootShell"
<!-- 背景图片 --> Background="#0B0B0B"
<Image x:Name="BackgroundImage" CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Grid.RowSpan="2" ClipToBounds="True">
Stretch="UniformToFill" <Grid RowDefinitions="*,Auto">
IsVisible="False" <Image x:Name="BackgroundImage"
Opacity="0"/> Grid.RowSpan="2"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsVisible="False"
Opacity="0"/>
<!-- 半透明遮罩层 --> <Border x:Name="BackgroundOverlay"
<Border x:Name="BackgroundOverlay" Grid.RowSpan="2"
Grid.RowSpan="2" Background="#0B0B0B"
Background="#0B0B0B" Opacity="0.42"/>
Opacity="0.85"/>
<Grid Grid.Row="0" <Grid Grid.Row="0"
Margin="24"> Margin="24">
<TextBlock x:Name="AppNameText" <TextBlock x:Name="AppNameText"
Text="LanMountain Desktop" Text="{x:Static res:Strings.Splash_AppName}"
FontSize="24" FontSize="24"
FontWeight="SemiBold" FontWeight="SemiBold"
VerticalAlignment="Top" VerticalAlignment="Top"
HorizontalAlignment="Left" HorizontalAlignment="Left"
Foreground="#F6F7FB" /> 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>
</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> </Window>

View File

@@ -42,6 +42,8 @@ public partial class SplashWindow : Window, ISplashStageReporter
{ {
try try
{ {
ResetBackgroundImage();
var imageInfo = LauncherBackgroundService.LoadBackgroundImage(); var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
if (imageInfo is { IsValid: true, Bitmap: not null }) if (imageInfo is { IsValid: true, Bitmap: not null })
{ {
@@ -51,16 +53,27 @@ public partial class SplashWindow : Window, ISplashStageReporter
backgroundImage.IsVisible = true; backgroundImage.IsVisible = true;
backgroundImage.Opacity = 1; backgroundImage.Opacity = 1;
} }
Logger.Info("[SplashWindow] 背景图片加载成功");
Logger.Info("[SplashWindow] Background image loaded.");
} }
else if (imageInfo is { Exists: true, IsValid: false }) 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) 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)); debugWindow.SelectedHostPath));
} }
InitializeBackgroundImage();
_isDebugModeOpened = false; _isDebugModeOpened = false;
_versionTextClickCount = 0; _versionTextClickCount = 0;
}; };

View 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
}
}

View File

@@ -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. - 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. - 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 ## Recovery rules
- Closing Launcher during startup does not cancel the startup attempt. - Closing Launcher during startup does not cancel the startup attempt.