Compare commits

...

3 Commits

Author SHA1 Message Date
lincube
e7a03404ce 0.5.14
二次启动拦截,统一了生命进程API
2026-03-11 09:40:36 +08:00
lincube
2781d7e0d9 0.5.13
插件市场安装优化
2026-03-11 06:38:11 +08:00
lincube
5003ff1be2 0.5.12 2026-03-10 21:25:47 +08:00
55 changed files with 3725 additions and 1167 deletions

View File

@@ -67,6 +67,11 @@
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.", "capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
"capability.widget_context.title": "PluginDesktopComponentContext", "capability.widget_context.title": "PluginDesktopComponentContext",
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.", "capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
"widget.close_desktop.display_name": "Close Desktop",
"widget.close_desktop.text": "Close Desktop",
"widget.close_desktop.hint": "Exit LanMountainDesktop on click",
"widget.close_desktop.unavailable": "Host lifecycle API is unavailable",
"widget.close_desktop.failed": "Host rejected the exit request",
"widget.subtitle.preview": "Preview surface | placed: {0}", "widget.subtitle.preview": "Preview surface | placed: {0}",
"widget.subtitle.placement": "Placement {0} | placed: {1}", "widget.subtitle.placement": "Placement {0} | placed: {1}",
"common.dev": "dev", "common.dev": "dev",

View File

@@ -16,6 +16,7 @@ public sealed class SamplePlugin : PluginBase, IDisposable
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost"); var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion"); var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion"); var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
var hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
var messageBus = context.GetService<IPluginMessageBus>() var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available."); ?? throw new InvalidOperationException("Plugin message bus is not available.");
@@ -74,6 +75,19 @@ public sealed class SamplePlugin : PluginBase, IDisposable
allowStatusBarPlacement: false, allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional, resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34))); cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.CloseDesktop",
localizer.GetString("widget.close_desktop.display_name", "关闭桌面"),
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
iconKey: "DismissCircle",
category: localizer.GetString("widget.category", "鎻掍欢"),
minWidthCells: 2,
minHeightCells: 1,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Free,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
} }
public void Dispose() public void Dispose()

View File

@@ -0,0 +1,166 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginCloseDesktopWidget : Border
{
private readonly PluginLocalizer _localizer;
private readonly IHostApplicationLifecycle? _hostApplicationLifecycle;
private readonly TextBlock _titleTextBlock;
private readonly TextBlock _statusTextBlock;
public SamplePluginCloseDesktopWidget(PluginDesktopComponentContext context)
{
_localizer = PluginLocalizer.Create(context);
_hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
_titleTextBlock = new TextBlock
{
Text = T("widget.close_desktop.text", "关闭桌面"),
Foreground = Brushes.White,
FontWeight = FontWeight.SemiBold,
VerticalAlignment = VerticalAlignment.Center
};
_statusTextBlock = new TextBlock
{
Text = _hostApplicationLifecycle is null
? T("widget.close_desktop.unavailable", "宿主未提供退出接口")
: T("widget.close_desktop.hint", "点击后退出阑山桌面"),
Foreground = new SolidColorBrush(Color.Parse("#FFD4E7F6")),
VerticalAlignment = VerticalAlignment.Center
};
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 14,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
CreateIconShell(),
new StackPanel
{
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
_titleTextBlock,
_statusTextBlock
}
}
}
};
Grid.SetColumn(contentGrid.Children[1], 1);
var actionButton = new Button
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Stretch,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
IsEnabled = _hostApplicationLifecycle is not null,
Content = contentGrid
};
actionButton.Click += OnButtonClick;
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#FF0B1220"), 0),
new GradientStop(Color.Parse("#FF172554"), 0.55),
new GradientStop(Color.Parse("#FF7F1D1D"), 1)
]
};
BorderBrush = new SolidColorBrush(Color.Parse("#66FB7185"));
BorderThickness = new Thickness(1);
CornerRadius = new CornerRadius(18);
Padding = new Thickness(14, 10);
Child = actionButton;
SizeChanged += OnSizeChanged;
ApplyScale();
}
private Border CreateIconShell()
{
return new Border
{
Width = 36,
Height = 36,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(Color.Parse("#33F87171")),
BorderBrush = new SolidColorBrush(Color.Parse("#88FCA5A5")),
BorderThickness = new Thickness(1),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "⏻",
FontSize = 18,
Foreground = Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center
}
};
}
private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_hostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
Source: "SamplePlugin.CloseDesktopWidget",
Reason: "User invoked the sample plugin close-desktop widget.")) == true)
{
return;
}
_statusTextBlock.Text = T("widget.close_desktop.failed", "宿主未接受退出请求");
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyScale();
}
private void ApplyScale()
{
var basis = Bounds.Height > 1 ? Bounds.Height : 72;
Padding = new Thickness(Math.Clamp(basis * 0.18, 12, 18), Math.Clamp(basis * 0.14, 8, 14));
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.32, 16, 24));
if (Child is not Button actionButton || actionButton.Content is not Grid contentGrid)
{
return;
}
if (contentGrid.Children[0] is Border iconShell)
{
var iconSize = Math.Clamp(basis * 0.58, 28, 40);
iconShell.Width = iconSize;
iconShell.Height = iconSize;
if (iconShell.Child is TextBlock iconText)
{
iconText.FontSize = Math.Clamp(iconSize * 0.5, 14, 20);
}
}
_titleTextBlock.FontSize = Math.Clamp(basis * 0.28, 14, 20);
_statusTextBlock.FontSize = Math.Clamp(basis * 0.18, 10, 13);
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
}

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record HostApplicationLifecycleRequest(
string? Source = null,
string? Reason = null);
public interface IHostApplicationLifecycle
{
bool TryExit(HostApplicationLifecycleRequest? request = null);
bool TryRestart(HostApplicationLifecycleRequest? request = null);
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,177 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
internal static class Program
{
private static async Task<int> Main(string[] args)
{
var result = new HelperResult();
string? resultPath = null;
try
{
var parsedArgs = ParseArgs(args);
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
!parsedArgs.TryGetValue("result", out resultPath) ||
string.IsNullOrWhiteSpace(sourcePath) ||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
string.IsNullOrWhiteSpace(resultPath))
{
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
}
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
resultPath = Path.GetFullPath(resultPath);
if (!File.Exists(fullSourcePath))
{
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
File.Copy(fullSourcePath, destinationPath, overwrite: true);
result = new HelperResult
{
Success = true,
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
catch (Exception ex)
{
result = new HelperResult
{
Success = false,
ErrorMessage = ex.Message
};
}
if (!string.IsNullOrWhiteSpace(resultPath))
{
var resultDirectory = Path.GetDirectoryName(resultPath);
if (!string.IsNullOrWhiteSpace(resultDirectory))
{
Directory.CreateDirectory(resultDirectory);
}
await File.WriteAllTextAsync(
resultPath,
JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
}),
Encoding.UTF8);
}
return result.Success ? 0 : 1;
}
private static Dictionary<string, string> ParseArgs(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
{
continue;
}
values[key] = args[++i];
}
return values;
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
File.Delete(existingPackagePath);
}
catch
{
// Ignore unrelated or malformed packages while replacing an install target.
}
}
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PluginSdkInfo.PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
private sealed class HelperResult
{
public bool Success { get; init; }
public string? InstalledPackagePath { get; init; }
public string? ManifestId { get; init; }
public string? ManifestName { get; init; }
public string? ErrorMessage { get; init; }
}
}

View File

@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPa
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginsInstallHelper", "LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj", "{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -33,5 +35,9 @@ Global
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU {30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU {30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU {30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -4,7 +4,6 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core; using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins; using Avalonia.Data.Core.Plugins;
using System; using System;
using System.Diagnostics;
using System.Linq; using System.Linq;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Platform; using Avalonia.Platform;
@@ -13,6 +12,7 @@ using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels; using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views; using LanMountainDesktop.Views;
using AvaloniaWebView; using AvaloniaWebView;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop; namespace LanMountainDesktop;
@@ -20,15 +20,23 @@ public partial class App : Application
{ {
private readonly AppSettingsService _appSettingsService = new(); private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new(); private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private bool _exitCleanupCompleted;
private SettingsWindow? _traySettingsWindow; private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons; private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService; private PluginRuntimeService? _pluginRuntimeService;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService; public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
public override void Initialize() public override void Initialize()
{ {
AppLogger.Info("App", "Initializing application resources.");
ConfigureWebViewUserDataFolder(); ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default); AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
@@ -36,6 +44,7 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
AppLogger.Info("App", "Framework initialization completed.");
LinuxDesktopEntryInstaller.EnsureInstalled(); LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime(); InitializePluginRuntime();
AppSettingsService.SettingsSaved += OnAppSettingsSaved; AppSettingsService.SettingsSaved += OnAppSettingsSaved;
@@ -49,13 +58,15 @@ public partial class App : Application
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown; desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) => desktop.Exit += (_, _) =>
{ {
AppSettingsService.SettingsSaved -= OnAppSettingsSaved; AppLogger.Info("App", "Desktop lifetime exit triggered.");
DisposeTrayIcon(); PerformExitCleanup();
}; };
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow
{ {
DataContext = new MainWindowViewModel(), DataContext = new MainWindowViewModel(),
}; };
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
@@ -63,12 +74,9 @@ public partial class App : Application
private void OnTrayExitClick(object? sender, EventArgs e) private void OnTrayExitClick(object? sender, EventArgs e)
{ {
DisposeTrayIcon(); _ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) Reason: "User selected Exit App from the tray menu."));
{
desktop.Shutdown();
}
} }
private void OnTraySettingsClick(object? sender, EventArgs e) private void OnTraySettingsClick(object? sender, EventArgs e)
@@ -104,25 +112,16 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}"); AppLogger.Warn("TraySettings", "Failed to open settings window.", ex);
} }
}, DispatcherPriority.Normal); }, DispatcherPriority.Normal);
} }
private void OnTrayRestartClick(object? sender, EventArgs e) private void OnTrayRestartClick(object? sender, EventArgs e)
{ {
AppRestartService.TryRestartApplication(); _ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
} Source: "TrayMenu",
Reason: "User selected Restart App from the tray menu."));
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
} }
private void DisableAvaloniaDataAnnotationValidation() private void DisableAvaloniaDataAnnotationValidation()
@@ -159,9 +158,10 @@ public partial class App : Application
userDataFolder, userDataFolder,
EnvironmentVariableTarget.Process); EnvironmentVariableTarget.Process);
} }
catch catch (Exception ex)
{ {
// Keep startup resilient if user profile folders are unavailable. // Keep startup resilient if user profile folders are unavailable.
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
} }
} }
@@ -175,7 +175,7 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}"); AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
} }
} }
@@ -199,7 +199,7 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.WriteLine($"[TrayIcon] Failed to initialize tray icon: {ex}"); AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
} }
} }
@@ -207,19 +207,19 @@ public partial class App : Application
{ {
var menu = new NativeMenu(); var menu = new NativeMenu();
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "ÉèÖÃ")); var settingsItem = new NativeMenuItem(L("tray.menu.settings", "设置"));
settingsItem.Click += OnTraySettingsClick; settingsItem.Click += OnTraySettingsClick;
menu.Items.Add(settingsItem); menu.Items.Add(settingsItem);
menu.Items.Add(new NativeMenuItemSeparator()); menu.Items.Add(new NativeMenuItemSeparator());
var restartItem = new NativeMenuItem(L("tray.menu.restart", "ÖØÆôÓ¦ÓÃ")); var restartItem = new NativeMenuItem(L("tray.menu.restart", "重启应用"));
restartItem.Click += OnTrayRestartClick; restartItem.Click += OnTrayRestartClick;
menu.Items.Add(restartItem); menu.Items.Add(restartItem);
menu.Items.Add(new NativeMenuItemSeparator()); menu.Items.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Í˳öÓ¦ÓÃ")); var exitItem = new NativeMenuItem(L("tray.menu.exit", "退出应用"));
exitItem.Click += OnTrayExitClick; exitItem.Click += OnTrayExitClick;
menu.Items.Add(exitItem); menu.Items.Add(exitItem);
@@ -242,6 +242,95 @@ public partial class App : Application
_trayIcons = null; _trayIcons = null;
} }
private void ActivateMainWindow()
{
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
if (desktop.MainWindow is not Window mainWindow)
{
return;
}
try
{
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Failed to activate the existing main window.", ex);
}
}, DispatcherPriority.Send);
}
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
}
private void PerformExitCleanup()
{
if (_exitCleanupCompleted)
{
return;
}
_exitCleanupCompleted = true;
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
try
{
_traySettingsWindow?.Close();
}
catch (Exception ex)
{
AppLogger.Warn("App", "Failed to close tray-opened settings window during shutdown.", ex);
}
finally
{
_traySettingsWindow = null;
}
try
{
_pluginRuntimeService?.Dispose();
}
catch (Exception ex)
{
AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex);
}
finally
{
_pluginRuntimeService = null;
}
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
}
private string L(string key, string fallback) private string L(string key, string fallback)
{ {
var snapshot = _appSettingsService.Load(); var snapshot = _appSettingsService.Load();

View File

@@ -26,6 +26,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" /> <ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -40,6 +42,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" /> <PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<PackageReference Include="Downloader" Version="4.1.1" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" /> <PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" /> <PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" /> <PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
@@ -55,4 +58,22 @@
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" /> <PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" /> <PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup> </ItemGroup>
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
<ItemGroup>
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<ItemGroup>
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project> </Project>

View File

@@ -251,6 +251,7 @@
"settings.update.status_launching_installer": "Download complete. Launching installer...", "settings.update.status_launching_installer": "Download complete. Launching installer...",
"settings.update.status_installer_missing": "Installer file was not found after download.", "settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.", "settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
"settings.update.status_launch_failed_format": "Failed to start installer: {0}", "settings.update.status_launch_failed_format": "Failed to start installer: {0}",
"settings.about.title": "About", "settings.about.title": "About",
"settings.about.version_format": "Version: {0}", "settings.about.version_format": "Version: {0}",
@@ -306,15 +307,17 @@
"settings.launcher.hidden_empty": "No hidden items.", "settings.launcher.hidden_empty": "No hidden items.",
"settings.launcher.hidden_type_folder": "Folder", "settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "Shortcut", "settings.launcher.hidden_type_shortcut": "Shortcut",
"settings.launcher.restore_button": "Show Again", "settings.launcher.restore_button": "Unhide",
"settings.plugins.title": "Plugins", "settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime", "settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.", "settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.", "settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.", "settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
"settings.plugins.installed_header": "Installed Plugins", "settings.plugins.installed_header": "Installed Plugins",
"settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.", "settings.plugins.installed_desc": "Review installed plugins and remove them here.",
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.", "settings.plugins.import_header": "Install From Package",
"settings.plugins.import_desc": "Open a .laapp package and stage it into the local plugin directory.",
"settings.plugins.restart_hint": "Plugin installation and deletion changes take effect after restarting the app.",
"settings.plugins.empty": "No plugins found.", "settings.plugins.empty": "No plugins found.",
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.", "settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.", "settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
@@ -329,6 +332,7 @@
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.", "settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
"settings.plugins.toggle_state_enabled": "enabled", "settings.plugins.toggle_state_enabled": "enabled",
"settings.plugins.toggle_state_disabled": "disabled", "settings.plugins.toggle_state_disabled": "disabled",
"settings.plugins.toggle_failed_detail_format": "Failed to update plugin '{0}': {1}",
"settings.plugins.install_button": "Open .laapp package", "settings.plugins.install_button": "Open .laapp package",
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.", "settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}", "settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
@@ -338,6 +342,12 @@
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.", "settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.", "settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}", "settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
"settings.plugins.delete_button": "Delete plugin",
"settings.plugins.delete_success_format": "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
"settings.plugins.delete_failed_format": "Failed to delete plugin: {0}",
"settings.plugins.delete_failed_detail_format": "Failed to delete plugin '{0}': {1}",
"settings.plugins.publisher_format": "Publisher: {0}",
"settings.plugins.publisher_unknown": "Unknown publisher",
"settings.plugins.source_package": ".laapp package", "settings.plugins.source_package": ".laapp package",
"settings.plugins.source_manifest": "Loose manifest", "settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.subtitle_format": "{0} | {1} | {2}",

View File

@@ -251,6 +251,7 @@
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...", "settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_installer_missing": "下载后未找到安装包文件。", "settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。", "settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}", "settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
"settings.about.title": "关于", "settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}", "settings.about.version_format": "版本号: {0}",
@@ -306,15 +307,17 @@
"settings.launcher.hidden_empty": "暂无隐藏项目。", "settings.launcher.hidden_empty": "暂无隐藏项目。",
"settings.launcher.hidden_type_folder": "文件夹", "settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "快捷方式", "settings.launcher.hidden_type_shortcut": "快捷方式",
"settings.launcher.restore_button": "重新显示", "settings.launcher.restore_button": "取消隐藏",
"settings.plugins.title": "插件", "settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时", "settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。", "settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。", "settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。", "settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
"settings.plugins.installed_header": "已安装插件", "settings.plugins.installed_header": "已安装插件",
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。", "settings.plugins.installed_desc": "在这里查看和删除已安装的插件。",
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。", "settings.plugins.import_header": "从安装包导入",
"settings.plugins.import_desc": "打开一个 .laapp 插件包,并将其暂存到本地插件目录。",
"settings.plugins.restart_hint": "插件安装和删除变更会在重启应用后生效。",
"settings.plugins.empty": "未找到插件。", "settings.plugins.empty": "未找到插件。",
"settings.plugins.runtime_unavailable": "插件运行时不可用。", "settings.plugins.runtime_unavailable": "插件运行时不可用。",
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。", "settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
@@ -329,6 +332,7 @@
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。", "settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
"settings.plugins.toggle_state_enabled": "启用", "settings.plugins.toggle_state_enabled": "启用",
"settings.plugins.toggle_state_disabled": "禁用", "settings.plugins.toggle_state_disabled": "禁用",
"settings.plugins.toggle_failed_detail_format": "更新插件“{0}”状态失败:{1}",
"settings.plugins.install_button": "打开 .laapp 插件包", "settings.plugins.install_button": "打开 .laapp 插件包",
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。", "settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}", "settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
@@ -338,6 +342,12 @@
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。", "settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。", "settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
"settings.plugins.install_failed_format": "安装插件包失败:{0}", "settings.plugins.install_failed_format": "安装插件包失败:{0}",
"settings.plugins.delete_button": "删除插件",
"settings.plugins.delete_success_format": "插件“{0}”已暂存删除。重启应用后会完成移除。",
"settings.plugins.delete_failed_format": "删除插件失败:{0}",
"settings.plugins.delete_failed_detail_format": "删除插件“{0}”失败:{1}",
"settings.plugins.publisher_format": "发布者:{0}",
"settings.plugins.publisher_unknown": "未知发布者",
"settings.plugins.source_package": ".laapp 包", "settings.plugins.source_package": ".laapp 包",
"settings.plugins.source_manifest": "散装清单", "settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.subtitle_format": "{0} | {1} | {2}",

View File

@@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.WebView.Desktop; using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using System; using System;
using System.Threading.Tasks;
namespace LanMountainDesktop; namespace LanMountainDesktop;
@@ -11,8 +12,41 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break. // yet and stuff might break.
[STAThread] [STAThread]
public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode()) public static void Main(string[] args)
.StartWithClassicDesktopLifetime(args); {
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
using var singleInstance = SingleInstanceService.CreateDefault();
if (!singleInstance.IsPrimaryInstance)
{
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
var notified = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
ShowAlreadyRunningNotice(notified);
return;
}
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
try
{
var renderMode = LoadConfiguredRenderMode();
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
catch (Exception ex)
{
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
throw;
}
finally
{
App.CurrentSingleInstanceService = null;
}
}
// Avalonia configuration, don't remove; also used by visual designer. // Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default) public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
@@ -44,9 +78,36 @@ sealed class Program
{ {
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode); return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("Startup", "Failed to load configured render mode. Falling back to default.", ex);
return AppRenderingModeHelper.Default; return AppRenderingModeHelper.Default;
} }
} }
private static void ShowAlreadyRunningNotice(bool notifiedPrimaryInstance)
{
const string caption = "LanMountainDesktop";
var message = notifiedPrimaryInstance
? "应用已打开,不需要多开了。\r\n\r\n已为你切换到正在运行的阑山桌面。"
: "应用已打开,不需要多开了。\r\n\r\n请切换到正在运行的阑山桌面。";
WindowsNativeDialogService.ShowInformation(caption, message);
}
private static void RegisterGlobalExceptionLogging()
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
AppLogger.Critical(
"UnhandledException",
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
eventArgs.ExceptionObject as Exception);
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
};
}
} }

View File

@@ -0,0 +1,169 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace LanMountainDesktop.Services;
public static class AppLogger
{
private static readonly object SyncRoot = new();
private static bool _initialized;
private static string _logDirectory = string.Empty;
private static string _logFilePath = string.Empty;
public static string LogDirectory
{
get
{
EnsureInitialized();
return _logDirectory;
}
}
public static string LogFilePath
{
get
{
EnsureInitialized();
return _logFilePath;
}
}
public static void Initialize()
{
lock (SyncRoot)
{
if (_initialized)
{
return;
}
var preferredDirectory = Path.Combine(AppContext.BaseDirectory, "log");
var fallbackDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"log");
var preferredReady = TryPrepareDirectory(preferredDirectory, out var preferredError);
var fallbackReady = false;
string? fallbackError = null;
if (preferredReady)
{
_logDirectory = preferredDirectory;
}
else
{
fallbackReady = TryPrepareDirectory(fallbackDirectory, out fallbackError);
_logDirectory = fallbackReady ? fallbackDirectory : preferredDirectory;
}
_logFilePath = Path.Combine(_logDirectory, $"app-{DateTime.Now:yyyyMMdd}.log");
_initialized = true;
WriteCore(
"INFO",
"Logger",
$"Initialized. Directory={_logDirectory}; File={_logFilePath}; PreferredDirectory={preferredDirectory}");
if (!preferredReady && !string.IsNullOrWhiteSpace(preferredError))
{
WriteCore(
"WARN",
"Logger",
$"Failed to use program log directory '{preferredDirectory}'. Falling back to '{_logDirectory}'. Reason: {preferredError}");
}
if (!preferredReady && !fallbackReady && !string.IsNullOrWhiteSpace(fallbackError))
{
Trace.WriteLine(
$"[LanMountainDesktop][Logger][ERROR] Failed to initialize fallback log directory '{fallbackDirectory}': {fallbackError}");
}
}
}
public static void Info(string category, string message)
{
Write("INFO", category, message, null);
}
public static void Warn(string category, string message, Exception? exception = null)
{
Write("WARN", category, message, exception);
}
public static void Error(string category, string message, Exception? exception = null)
{
Write("ERROR", category, message, exception);
}
public static void Critical(string category, string message, Exception? exception = null)
{
Write("CRITICAL", category, message, exception);
}
private static void Write(string level, string category, string message, Exception? exception)
{
EnsureInitialized();
var payload = exception is null
? message
: $"{message}{Environment.NewLine}{exception}";
WriteCore(level, category, payload);
}
private static void EnsureInitialized()
{
if (_initialized)
{
return;
}
Initialize();
}
private static void WriteCore(string level, string category, string message)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
var line = $"[{timestamp}] [{level}] [{category}] {message}";
lock (SyncRoot)
{
try
{
var directory = Path.GetDirectoryName(_logFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.AppendAllText(_logFilePath, line + Environment.NewLine, Encoding.UTF8);
}
catch (Exception ex)
{
Trace.WriteLine($"[LanMountainDesktop][Logger][ERROR] {ex}");
}
}
Trace.WriteLine(line);
}
private static bool TryPrepareDirectory(string directory, out string? error)
{
try
{
Directory.CreateDirectory(directory);
var probePath = Path.Combine(directory, $".probe-{Guid.NewGuid():N}.tmp");
File.WriteAllText(probePath, "probe");
File.Delete(probePath);
error = null;
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}

View File

@@ -3,8 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using Avalonia; using LanMountainDesktop.PluginSdk;
using Avalonia.Controls.ApplicationLifetimes;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
@@ -12,17 +11,9 @@ public static class AppRestartService
{ {
public static bool TryRestartApplication() public static bool TryRestartApplication()
{ {
if (!TryRestartCurrentProcess()) return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
{ Source: nameof(AppRestartService),
return false; Reason: "Legacy restart entry point invoked.")) == true;
}
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
return true;
} }
public static bool TryRestartCurrentProcess() public static bool TryRestartCurrentProcess()

View File

@@ -63,8 +63,9 @@ public sealed class AppSettingsService
return loadedSnapshot.Clone(); return loadedSnapshot.Clone();
} }
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("AppSettings", $"Failed to load settings from '{_settingsPath}'.", ex);
return new AppSettingsSnapshot(); return new AppSettingsSnapshot();
} }
} }
@@ -95,9 +96,9 @@ public sealed class AppSettingsService
SettingsSaved?.Invoke(InstanceId); SettingsSaved?.Invoke(InstanceId);
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn("AppSettings", $"Failed to save settings to '{_settingsPath}'.", ex);
} }
} }
@@ -136,8 +137,9 @@ public sealed class AppSettingsService
var json = File.ReadAllText(_settingsPath); var json = File.ReadAllText(_settingsPath);
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot(); return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("AppSettings", $"Failed to deserialize settings from '{_settingsPath}'.", ex);
return new AppSettingsSnapshot(); return new AppSettingsSnapshot();
} }
} }

View File

@@ -51,8 +51,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return document.DefaultSettings.Clone(); return document.DefaultSettings.Clone();
} }
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsSnapshot(); return new ComponentSettingsSnapshot();
} }
} }
@@ -76,9 +77,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
PersistDocumentLocked(document); PersistDocumentLocked(document);
} }
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex);
} }
} }
@@ -99,8 +100,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return document.DefaultSettings.Clone(); return document.DefaultSettings.Clone();
} }
} }
catch catch (Exception ex)
{ {
AppLogger.Warn(
"ComponentSettings",
$"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new ComponentSettingsSnapshot(); return new ComponentSettingsSnapshot();
} }
} }
@@ -124,9 +129,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
PersistDocumentLocked(document); PersistDocumentLocked(document);
} }
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn(
"ComponentSettings",
$"Failed to save component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
} }
} }
@@ -151,9 +159,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
} }
} }
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn(
"ComponentSettings",
$"Failed to delete component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
} }
} }
@@ -174,8 +185,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T(); return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
} }
} }
catch catch (Exception ex)
{ {
AppLogger.Warn(
"ComponentSettings",
$"Failed to load plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new T(); return new T();
} }
} }
@@ -197,9 +212,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
PersistDocumentLocked(document); PersistDocumentLocked(document);
} }
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn(
"ComponentSettings",
$"Failed to save plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
} }
} }
@@ -222,9 +240,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
} }
} }
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn(
"ComponentSettings",
$"Failed to delete plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
} }
} }
@@ -373,8 +394,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
DefaultSettings = NormalizeSnapshot(legacySnapshot) DefaultSettings = NormalizeSnapshot(legacySnapshot)
}; };
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsDocumentSnapshot(); return new ComponentSettingsDocumentSnapshot();
} }
} }
@@ -428,8 +450,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return true; return true;
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
return false; return false;
} }
} }

View File

@@ -80,8 +80,9 @@ public sealed class DesktopLayoutSettingsService
return normalizedSnapshot.Clone(); return normalizedSnapshot.Clone();
} }
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot(); return new DesktopLayoutSettingsSnapshot();
} }
} }
@@ -99,9 +100,9 @@ public sealed class DesktopLayoutSettingsService
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow); UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
} }
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn("DesktopLayout", $"Failed to save desktop layout settings to '{_settingsPath}'.", ex);
} }
} }
@@ -141,8 +142,9 @@ public sealed class DesktopLayoutSettingsService
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions); var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot); return NormalizeSnapshot(snapshot);
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot(); return new DesktopLayoutSettingsSnapshot();
} }
} }
@@ -174,8 +176,9 @@ public sealed class DesktopLayoutSettingsService
return true; return true;
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex);
return false; return false;
} }
} }

View File

@@ -0,0 +1,98 @@
using System;
using System.IO;
using System.Threading;
namespace LanMountainDesktop.Services;
internal static class FileOperationRetryHelper
{
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
public static void CopyWithRetry(string sourceFilePath, string destinationFilePath, bool overwrite, string category)
{
Retry(
() => File.Copy(sourceFilePath, destinationFilePath, overwrite),
category,
$"Copy '{sourceFilePath}' -> '{destinationFilePath}'");
}
public static void MoveWithOverwriteRetry(string sourceFilePath, string destinationFilePath, string category)
{
Retry(
() => File.Move(sourceFilePath, destinationFilePath, overwrite: true),
category,
$"Move '{sourceFilePath}' -> '{destinationFilePath}'");
}
public static void DeleteFileWithRetry(string filePath, string category)
{
Retry(
() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
},
category,
$"Delete file '{filePath}'");
}
public static void DeleteDirectoryWithRetry(string directoryPath, bool recursive, string category)
{
Retry(
() =>
{
if (Directory.Exists(directoryPath))
{
Directory.Delete(directoryPath, recursive);
}
},
category,
$"Delete directory '{directoryPath}'");
}
private static void Retry(Action action, string category, string operationDescription)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (IsRetriable(ex))
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
var delay = RetryDelays[attempt];
AppLogger.Warn(
category,
$"{operationDescription} failed on attempt {attempt + 1}. Retrying after {delay.TotalMilliseconds:0} ms.",
ex);
Thread.Sleep(delay);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static bool IsRetriable(Exception exception)
{
return exception is IOException or UnauthorizedAccessException;
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{
public bool TryExit(HostApplicationLifecycleRequest? request = null)
{
try
{
AppLogger.Info(
"HostLifecycle",
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
return false;
}
if (Dispatcher.UIThread.CheckAccess())
{
desktop.Shutdown();
}
else
{
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
}
return true;
}
catch (Exception ex)
{
AppLogger.Warn("HostLifecycle", "Failed to exit the application.", ex);
return false;
}
}
public bool TryRestart(HostApplicationLifecycleRequest? request = null)
{
try
{
var startInfo = AppRestartService.CreateRestartStartInfo();
if (startInfo is null)
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
return false;
}
Process.Start(startInfo);
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
Reason = string.IsNullOrWhiteSpace(request.Reason)
? "Restart accepted."
: request.Reason
};
return TryExit(exitRequest);
}
catch (Exception ex)
{
AppLogger.Warn("HostLifecycle", "Failed to restart the application.", ex);
return false;
}
}
}

View File

@@ -80,6 +80,19 @@ public static class AudioRecorderServiceFactory
{ {
return CreateRecorder(); return CreateRecorder();
} }
public static void DisposeSharedServices()
{
if (SharedRecorderService.IsValueCreated)
{
SharedRecorderService.Value.Dispose();
}
if (SharedStudyMonitoringService.IsValueCreated)
{
SharedStudyMonitoringService.Value.Dispose();
}
}
} }
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService

View File

@@ -85,8 +85,9 @@ public sealed class LauncherSettingsService
return normalizedSnapshot.Clone(); return normalizedSnapshot.Clone();
} }
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("LauncherSettings", $"Failed to load launcher settings from '{_settingsPath}'.", ex);
return new LauncherSettingsSnapshot(); return new LauncherSettingsSnapshot();
} }
} }
@@ -106,9 +107,9 @@ public sealed class LauncherSettingsService
SettingsSaved?.Invoke(InstanceId); SettingsSaved?.Invoke(InstanceId);
} }
catch catch (Exception ex)
{ {
// Swallow persistence errors to keep UI interactions uninterrupted. AppLogger.Warn("LauncherSettings", $"Failed to save launcher settings to '{_settingsPath}'.", ex);
} }
} }
@@ -148,8 +149,9 @@ public sealed class LauncherSettingsService
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions); var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot); return NormalizeSnapshot(snapshot);
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("LauncherSettings", $"Failed to deserialize launcher settings from '{_settingsPath}'.", ex);
return new LauncherSettingsSnapshot(); return new LauncherSettingsSnapshot();
} }
} }
@@ -180,8 +182,9 @@ public sealed class LauncherSettingsService
return true; return true;
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("LauncherSettings", $"Failed to migrate legacy launcher settings from '{_legacyAppSettingsPath}'.", ex);
return false; return false;
} }
} }

View File

@@ -0,0 +1,186 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
internal sealed class PluginsInstallHelperClient
{
private const int UserCanceledUacErrorCode = 1223;
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
string packagePath,
string pluginsDirectory,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
if (!OperatingSystem.IsWindows())
{
return new PluginsInstallHelperResult(
false,
null,
"Elevated helper install is only supported on Windows.");
}
var helperPath = ResolveHelperPath();
if (!File.Exists(helperPath))
{
return new PluginsInstallHelperResult(
false,
null,
$"Plugins install helper was not found at '{helperPath}'.");
}
var resultPath = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop",
"PluginInstallResults",
$"{Guid.NewGuid():N}.json");
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
try
{
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
if (process is null)
{
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
}
await process.WaitForExitAsync(cancellationToken);
var result = await ReadResultAsync(resultPath, cancellationToken);
if (result is not null)
{
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
}
if (process.ExitCode == 0)
{
return new PluginsInstallHelperResult(
false,
null,
"Plugins install helper exited without producing a result file.");
}
return new PluginsInstallHelperResult(
false,
null,
string.Format(
CultureInfo.InvariantCulture,
"Plugins install helper exited with code {0}.",
process.ExitCode));
}
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
{
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
}
finally
{
TryDeleteFile(resultPath);
}
}
private static Process? StartHelperProcess(
string helperPath,
string packagePath,
string pluginsDirectory,
string resultPath)
{
var startInfo = new ProcessStartInfo
{
FileName = helperPath,
Verb = "runas",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
Arguments = string.Create(
CultureInfo.InvariantCulture,
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
};
return Process.Start(startInfo);
}
private static async Task<HelperResultFile?> ReadResultAsync(string resultPath, CancellationToken cancellationToken)
{
if (!File.Exists(resultPath))
{
return null;
}
await using var stream = File.OpenRead(resultPath);
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
}
private static string ResolveHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Ignore temp file cleanup failures.
}
}
private sealed class HelperResultFile
{
public bool Success { get; init; }
public string? InstalledPackagePath { get; init; }
public string? ErrorMessage { get; init; }
}
}
internal sealed record PluginsInstallHelperResult(
bool Success,
string? InstalledPackagePath,
string? ErrorMessage);

View File

@@ -1,14 +1,9 @@
using System; using System;
using System.Buffers; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Downloader;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
@@ -34,16 +29,11 @@ public sealed record DownloadResult(
public sealed class ResumableDownloadService public sealed class ResumableDownloadService
{ {
private static readonly JsonSerializerOptions MetadataSerializerOptions = new() private static readonly ConcurrentDictionary<string, SemaphoreSlim> DestinationGates =
{ new(StringComparer.OrdinalIgnoreCase);
WriteIndented = false
};
private readonly HttpClient _httpClient; public ResumableDownloadService(System.Net.Http.HttpClient httpClient)
public ResumableDownloadService(HttpClient httpClient)
{ {
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
} }
public async Task<DownloadResult> DownloadAsync( public async Task<DownloadResult> DownloadAsync(
@@ -57,13 +47,19 @@ public sealed class ResumableDownloadService
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
var normalizedOptions = NormalizeOptions(options); var normalizedOptions = NormalizeOptions(options);
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
var destinationGate = DestinationGates.GetOrAdd(
fullDestinationPath,
static _ => new SemaphoreSlim(1, 1));
await destinationGate.WaitAsync(cancellationToken);
try try
{ {
if (File.Exists(source)) if (File.Exists(source))
{ {
return await CopyLocalFileAsync( return await CopyLocalFileAsync(
source, source,
destinationFilePath, fullDestinationPath,
normalizedOptions, normalizedOptions,
progress, progress,
cancellationToken); cancellationToken);
@@ -77,7 +73,7 @@ public sealed class ResumableDownloadService
return await DownloadRemoteFileAsync( return await DownloadRemoteFileAsync(
sourceUri, sourceUri,
destinationFilePath, fullDestinationPath,
normalizedOptions, normalizedOptions,
progress, progress,
cancellationToken); cancellationToken);
@@ -88,8 +84,16 @@ public sealed class ResumableDownloadService
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn(
"Downloader",
$"Download failed. Source='{source}'; Destination='{fullDestinationPath}'.",
ex);
return new DownloadResult(false, null, ex.Message, false, false); return new DownloadResult(false, null, ex.Message, false, false);
} }
finally
{
destinationGate.Release();
}
} }
private async Task<DownloadResult> CopyLocalFileAsync( private async Task<DownloadResult> CopyLocalFileAsync(
@@ -104,13 +108,12 @@ public sealed class ResumableDownloadService
var totalBytes = new FileInfo(fullSourcePath).Length; var totalBytes = new FileInfo(fullSourcePath).Length;
var tempFilePath = BuildTempFilePath(fullDestinationPath); var tempFilePath = BuildTempFilePath(fullDestinationPath);
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
PrepareDestination(fullDestinationPath); PrepareDestination(fullDestinationPath);
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes)) if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
{ {
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false)); progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
CleanupPartialArtifacts(tempFilePath, metadataFilePath); CleanupLocalPartialArtifacts(tempFilePath);
return new DownloadResult(true, fullDestinationPath, null, false, false); return new DownloadResult(true, fullDestinationPath, null, false, false);
} }
@@ -120,7 +123,7 @@ public sealed class ResumableDownloadService
existingBytes = new FileInfo(tempFilePath).Length; existingBytes = new FileInfo(tempFilePath).Length;
if (existingBytes > totalBytes) if (existingBytes > totalBytes)
{ {
ResetPartialArtifacts(tempFilePath, metadataFilePath); CleanupLocalPartialArtifacts(tempFilePath);
existingBytes = 0; existingBytes = 0;
} }
} }
@@ -138,7 +141,7 @@ public sealed class ResumableDownloadService
if (existingBytes >= totalBytes) if (existingBytes >= totalBytes)
{ {
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath); CompleteLocalCopy(tempFilePath, fullDestinationPath);
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false)); progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false); return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
} }
@@ -169,13 +172,10 @@ public sealed class ResumableDownloadService
destinationStream, destinationStream,
existingBytes, existingBytes,
totalBytes, totalBytes,
isResuming: existingBytes > 0,
isParallel: false,
options.BufferSize,
progress, progress,
cancellationToken); cancellationToken);
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath); CompleteLocalCopy(tempFilePath, fullDestinationPath);
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false); return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
} }
@@ -186,501 +186,144 @@ public sealed class ResumableDownloadService
IProgress<DownloadProgressInfo>? progress, IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var fullDestinationPath = Path.GetFullPath(destinationFilePath); PrepareDestination(destinationFilePath);
var tempFilePath = BuildTempFilePath(fullDestinationPath);
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
PrepareDestination(fullDestinationPath);
var probe = await ProbeRemoteFileAsync(sourceUri, cancellationToken); if (CanReuseCompletedDestination(destinationFilePath, options.ExpectedSizeBytes))
var totalBytes = probe.TotalBytes ?? options.ExpectedSizeBytes;
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
{ {
progress?.Report(new DownloadProgressInfo( var existingLength = new FileInfo(destinationFilePath).Length;
totalBytes ?? new FileInfo(fullDestinationPath).Length, progress?.Report(new DownloadProgressInfo(existingLength, options.ExpectedSizeBytes, 1d, false, false));
totalBytes, CleanupDownloaderArtifacts(destinationFilePath);
1d, return new DownloadResult(true, destinationFilePath, null, false, false);
false,
false));
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
return new DownloadResult(true, fullDestinationPath, null, false, false);
} }
var canUseParallel = probe.SupportsRanges && var usedResume = HasDownloaderResumeArtifacts(destinationFilePath);
totalBytes is > 0 && var usedParallelDownload = ShouldUseParallelDownload(options);
totalBytes.Value >= options.ParallelThresholdBytes && var configuration = CreateConfiguration(options, usedParallelDownload);
options.MaxParallelSegments > 1; using var downloader = new DownloadService(configuration);
try downloader.DownloadProgressChanged += (_, args) =>
{ {
var result = canUseParallel progress?.Report(MapProgress(args, options.ExpectedSizeBytes, usedResume, usedParallelDownload));
? await DownloadRemoteInParallelAsync( };
sourceUri,
fullDestinationPath,
tempFilePath,
metadataFilePath,
totalBytes!.Value,
options,
progress,
cancellationToken)
: await DownloadRemoteSequentiallyAsync(
sourceUri,
fullDestinationPath,
tempFilePath,
metadataFilePath,
totalBytes,
probe.SupportsRanges,
options,
progress,
cancellationToken);
return result; using var cancellationRegistration = cancellationToken.Register(() =>
}
catch (RangeRequestNotSupportedException)
{ {
ResetPartialArtifacts(tempFilePath, metadataFilePath); try
return await DownloadRemoteSequentiallyAsync(
sourceUri,
fullDestinationPath,
tempFilePath,
metadataFilePath,
totalBytes,
allowResume: false,
options,
progress,
cancellationToken);
}
}
private async Task<DownloadResult> DownloadRemoteSequentiallyAsync(
Uri sourceUri,
string destinationFilePath,
string tempFilePath,
string metadataFilePath,
long? totalBytes,
bool allowResume,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
long existingBytes = 0;
if (File.Exists(tempFilePath))
{
existingBytes = new FileInfo(tempFilePath).Length;
if (totalBytes is > 0 && existingBytes > totalBytes.Value)
{ {
ResetPartialArtifacts(tempFilePath, metadataFilePath); downloader.CancelAsync();
existingBytes = 0;
} }
} catch (Exception ex)
if (!allowResume && existingBytes > 0)
{
ResetPartialArtifacts(tempFilePath, metadataFilePath);
existingBytes = 0;
}
if (totalBytes is > 0 && existingBytes >= totalBytes.Value)
{
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, existingBytes > 0, false));
return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false);
}
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri);
if (allowResume && existingBytes > 0)
{
request.Headers.Range = new RangeHeaderValue(existingBytes, null);
}
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (allowResume && existingBytes > 0)
{
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && totalBytes is > 0 && existingBytes == totalBytes)
{ {
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); AppLogger.Warn(
progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, true, false)); "Downloader",
return new DownloadResult(true, destinationFilePath, null, true, false); $"Failed to cancel Downloader request for '{destinationFilePath}'.",
ex);
} }
});
if (response.StatusCode != HttpStatusCode.PartialContent) AppLogger.Info(
{ "Downloader",
throw new RangeRequestNotSupportedException("The server did not honor the resume range request."); $"Starting remote download. Source='{sourceUri}'; Destination='{destinationFilePath}'; Parallel={usedParallelDownload}; ChunkCount={configuration.ChunkCount}; Resume={usedResume}.");
}
}
response.EnsureSuccessStatusCode(); await downloader.DownloadFileTaskAsync(sourceUri.AbsoluteUri, destinationFilePath);
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); if (!File.Exists(destinationFilePath))
await using var destinationStream = new FileStream(
tempFilePath,
existingBytes > 0 ? FileMode.Open : FileMode.Create,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
if (existingBytes > 0)
{ {
destinationStream.Seek(existingBytes, SeekOrigin.Begin); throw new FileNotFoundException(
$"Downloader completed without producing '{destinationFilePath}'.",
destinationFilePath);
} }
var effectiveTotalBytes = totalBytes; var finalLength = new FileInfo(destinationFilePath).Length;
if (effectiveTotalBytes is null && response.Content.Headers.ContentLength is > 0) progress?.Report(new DownloadProgressInfo(
{ finalLength,
effectiveTotalBytes = existingBytes + response.Content.Headers.ContentLength.Value; options.ExpectedSizeBytes ?? finalLength,
} 1d,
usedResume,
usedParallelDownload));
await CopyStreamAsync( AppLogger.Info(
sourceStream, "Downloader",
destinationStream, $"Remote download completed. Source='{sourceUri}'; Destination='{destinationFilePath}'; Size={finalLength}; Parallel={usedParallelDownload}; Resume={usedResume}.");
existingBytes,
effectiveTotalBytes,
isResuming: existingBytes > 0,
isParallel: false,
options.BufferSize,
progress,
cancellationToken);
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); return new DownloadResult(true, destinationFilePath, null, usedResume, usedParallelDownload);
return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false);
}
private async Task<DownloadResult> DownloadRemoteInParallelAsync(
Uri sourceUri,
string destinationFilePath,
string tempFilePath,
string metadataFilePath,
long totalBytes,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var requestedSegments = Math.Min(options.MaxParallelSegments, CalculateRecommendedSegments(totalBytes));
var metadata = await LoadOrCreateMetadataAsync(
sourceUri,
tempFilePath,
metadataFilePath,
totalBytes,
requestedSegments,
cancellationToken);
await using (var tempStream = new FileStream(
tempFilePath,
FileMode.OpenOrCreate,
FileAccess.Write,
FileShare.ReadWrite,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.RandomAccess))
{
if (tempStream.Length != totalBytes)
{
tempStream.SetLength(totalBytes);
}
}
var initialDownloadedBytes = metadata.Segments.Sum(segment => segment.CompletedBytes);
ReportProgress(progress, initialDownloadedBytes, totalBytes, initialDownloadedBytes > 0, true);
if (initialDownloadedBytes >= totalBytes)
{
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true);
}
long downloadedBytes = initialDownloadedBytes;
var metadataWriter = new MetadataWriter(metadataFilePath, metadata);
try
{
var tasks = metadata.Segments
.Where(segment => segment.CompletedBytes < segment.Length)
.Select(segment => DownloadSegmentAsync(
sourceUri,
tempFilePath,
segment,
options.BufferSize,
delta =>
{
var currentDownloaded = Interlocked.Add(ref downloadedBytes, delta);
ReportProgress(progress, currentDownloaded, totalBytes, initialDownloadedBytes > 0, true);
},
metadataWriter,
cancellationToken))
.ToArray();
await Task.WhenAll(tasks);
await metadataWriter.FlushAsync(cancellationToken);
}
catch
{
await metadataWriter.FlushAsync(cancellationToken);
throw;
}
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
ReportProgress(progress, totalBytes, totalBytes, initialDownloadedBytes > 0, true);
return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true);
}
private async Task DownloadSegmentAsync(
Uri sourceUri,
string tempFilePath,
DownloadSegmentState segment,
int bufferSize,
Action<int> reportDownloadedBytes,
MetadataWriter metadataWriter,
CancellationToken cancellationToken)
{
var rangeStart = segment.Start + segment.CompletedBytes;
if (rangeStart > segment.EndInclusive)
{
return;
}
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri);
request.Headers.Range = new RangeHeaderValue(rangeStart, segment.EndInclusive);
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (response.StatusCode != HttpStatusCode.PartialContent)
{
throw new RangeRequestNotSupportedException(
$"The server returned HTTP {(int)response.StatusCode} for range {rangeStart}-{segment.EndInclusive}.");
}
response.EnsureSuccessStatusCode();
var contentRange = response.Content.Headers.ContentRange;
if (contentRange?.From != rangeStart || contentRange.To != segment.EndInclusive)
{
throw new RangeRequestNotSupportedException("The server returned an unexpected content range.");
}
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = new FileStream(
tempFilePath,
FileMode.Open,
FileAccess.Write,
FileShare.ReadWrite,
bufferSize,
FileOptions.Asynchronous | FileOptions.RandomAccess);
destinationStream.Seek(rangeStart, SeekOrigin.Begin);
var buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
try
{
while (segment.CompletedBytes < segment.Length)
{
var remainingBytes = segment.Length - segment.CompletedBytes;
var readSize = (int)Math.Min(buffer.Length, remainingBytes);
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, readSize), cancellationToken);
if (read <= 0)
{
throw new EndOfStreamException(
$"Unexpected end of stream while downloading range {segment.Start}-{segment.EndInclusive}.");
}
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
segment.CompletedBytes += read;
reportDownloadedBytes(read);
metadataWriter.MarkDirty();
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private async Task<RemoteProbeResult> ProbeRemoteFileAsync(Uri sourceUri, CancellationToken cancellationToken)
{
long? totalBytes = null;
var supportsRanges = false;
try
{
using var headRequest = new HttpRequestMessage(HttpMethod.Head, sourceUri);
using var headResponse = await _httpClient.SendAsync(
headRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (headResponse.IsSuccessStatusCode)
{
totalBytes = headResponse.Content.Headers.ContentLength;
supportsRanges = headResponse.Headers.AcceptRanges.Any(
value => string.Equals(value, "bytes", StringComparison.OrdinalIgnoreCase));
}
}
catch
{
// Fall back to a small range probe when HEAD is unsupported or blocked.
}
if (supportsRanges && totalBytes is > 0)
{
return new RemoteProbeResult(totalBytes, true);
}
using var rangeRequest = new HttpRequestMessage(HttpMethod.Get, sourceUri);
rangeRequest.Headers.Range = new RangeHeaderValue(0, 0);
using var rangeResponse = await _httpClient.SendAsync(
rangeRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (rangeResponse.StatusCode == HttpStatusCode.PartialContent)
{
totalBytes = rangeResponse.Content.Headers.ContentRange?.Length ?? totalBytes;
return new RemoteProbeResult(totalBytes, true);
}
rangeResponse.EnsureSuccessStatusCode();
totalBytes ??= rangeResponse.Content.Headers.ContentLength;
return new RemoteProbeResult(totalBytes, false);
} }
private static async Task CopyStreamAsync( private static async Task CopyStreamAsync(
Stream sourceStream, Stream sourceStream,
Stream destinationStream, Stream destinationStream,
long initialDownloadedBytes, long initialDownloadedBytes,
long? totalBytes,
bool isResuming,
bool isParallel,
int bufferSize,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
var downloadedBytes = initialDownloadedBytes;
try
{
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
while (true)
{
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (read <= 0)
{
break;
}
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
downloadedBytes += read;
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
}
await destinationStream.FlushAsync(cancellationToken);
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static void ReportProgress(
IProgress<DownloadProgressInfo>? progress,
long downloadedBytes,
long? totalBytes,
bool isResuming,
bool isParallel)
{
if (progress is null)
{
return;
}
double normalizedProgress;
if (totalBytes is > 0)
{
normalizedProgress = Math.Clamp(downloadedBytes / (double)totalBytes.Value, 0d, 1d);
}
else
{
normalizedProgress = 0d;
}
progress.Report(new DownloadProgressInfo(
downloadedBytes,
totalBytes,
normalizedProgress,
isResuming,
isParallel));
}
private static async Task<DownloadMetadata> LoadOrCreateMetadataAsync(
Uri sourceUri,
string tempFilePath,
string metadataFilePath,
long totalBytes, long totalBytes,
int segmentCount, IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (File.Exists(metadataFilePath)) var buffer = new byte[128 * 1024];
var downloadedBytes = initialDownloadedBytes;
progress?.Report(new DownloadProgressInfo(downloadedBytes, totalBytes, downloadedBytes / (double)totalBytes, initialDownloadedBytes > 0, false));
while (true)
{ {
try var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (read <= 0)
{ {
var json = await File.ReadAllTextAsync(metadataFilePath, cancellationToken); break;
var metadata = JsonSerializer.Deserialize<SerializableDownloadMetadata>(json);
if (metadata is not null)
{
var normalizedMetadata = metadata.ToRuntime();
if (string.Equals(normalizedMetadata.Source, sourceUri.ToString(), StringComparison.OrdinalIgnoreCase) &&
normalizedMetadata.TotalBytes == totalBytes &&
normalizedMetadata.Segments.Count > 0)
{
return normalizedMetadata.Normalize();
}
}
}
catch
{
// Reset invalid metadata below.
} }
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
downloadedBytes += read;
progress?.Report(new DownloadProgressInfo(
downloadedBytes,
totalBytes,
Math.Clamp(downloadedBytes / (double)totalBytes, 0d, 1d),
initialDownloadedBytes > 0,
false));
} }
ResetPartialArtifacts(tempFilePath, metadataFilePath); await destinationStream.FlushAsync(cancellationToken);
var createdMetadata = DownloadMetadata.Create(sourceUri.ToString(), totalBytes, segmentCount);
var serialized = JsonSerializer.Serialize(createdMetadata.ToSerializable(), MetadataSerializerOptions);
await File.WriteAllTextAsync(metadataFilePath, serialized, cancellationToken);
return createdMetadata;
} }
private static DownloadOptions NormalizeOptions(DownloadOptions? options) private static DownloadConfiguration CreateConfiguration(DownloadOptions options, bool useParallelDownload)
{ {
var normalized = options ?? new DownloadOptions(); return new DownloadConfiguration
var maxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8);
var parallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes);
var bufferSize = Math.Max(16 * 1024, normalized.BufferSize);
return normalized with
{ {
MaxParallelSegments = maxParallelSegments, BufferBlockSize = options.BufferSize,
ParallelThresholdBytes = parallelThresholdBytes, ChunkCount = useParallelDownload ? options.MaxParallelSegments : 1,
BufferSize = bufferSize ParallelCount = useParallelDownload ? options.MaxParallelSegments : 1,
ParallelDownload = useParallelDownload,
MinimumSizeOfChunking = options.ParallelThresholdBytes,
MaxTryAgainOnFailure = 3,
ResumeDownloadIfCan = true,
ClearPackageOnCompletionWithFailure = false,
FileExistPolicy = FileExistPolicy.Delete,
DownloadFileExtension = ".part"
}; };
} }
private static int CalculateRecommendedSegments(long totalBytes) private static DownloadProgressInfo MapProgress(
DownloadProgressChangedEventArgs args,
long? expectedSizeBytes,
bool isResuming,
bool isParallel)
{ {
if (totalBytes < 16 * 1024 * 1024) var totalBytes = args.TotalBytesToReceive > 0
? args.TotalBytesToReceive
: expectedSizeBytes;
var downloadedBytes = Math.Max(0L, args.ReceivedBytesSize);
var normalizedProgress = args.ProgressPercentage > 1d
? args.ProgressPercentage / 100d
: args.ProgressPercentage;
if (totalBytes is > 0 && normalizedProgress <= 0d)
{ {
return 2; normalizedProgress = downloadedBytes / (double)totalBytes.Value;
} }
if (totalBytes < 64 * 1024 * 1024) return new DownloadProgressInfo(
{ downloadedBytes,
return 4; totalBytes,
} Math.Clamp(normalizedProgress, 0d, 1d),
isResuming,
return 6; isParallel);
} }
private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes) private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes)
@@ -707,234 +350,63 @@ public sealed class ResumableDownloadService
} }
} }
private static void CompleteDownload(string tempFilePath, string destinationFilePath, string metadataFilePath) private static void CleanupLocalPartialArtifacts(string tempFilePath)
{
if (File.Exists(tempFilePath))
{
FileOperationRetryHelper.DeleteFileWithRetry(tempFilePath, "Downloader");
}
}
private static void CompleteLocalCopy(string tempFilePath, string destinationFilePath)
{ {
if (!File.Exists(tempFilePath)) if (!File.Exists(tempFilePath))
{ {
return; return;
} }
File.Move(tempFilePath, destinationFilePath, overwrite: true); FileOperationRetryHelper.MoveWithOverwriteRetry(tempFilePath, destinationFilePath, "Downloader");
if (File.Exists(metadataFilePath))
{
File.Delete(metadataFilePath);
}
} }
private static void CleanupPartialArtifacts(string tempFilePath, string metadataFilePath) private static void CleanupDownloaderArtifacts(string destinationFilePath)
{ {
if (File.Exists(tempFilePath)) var transientFilePath = BuildTempFilePath(destinationFilePath);
var metadataFilePath = BuildPackageFilePath(destinationFilePath);
if (File.Exists(transientFilePath))
{ {
File.Delete(tempFilePath); FileOperationRetryHelper.DeleteFileWithRetry(transientFilePath, "Downloader");
} }
if (File.Exists(metadataFilePath)) if (File.Exists(metadataFilePath))
{ {
File.Delete(metadataFilePath); FileOperationRetryHelper.DeleteFileWithRetry(metadataFilePath, "Downloader");
} }
} }
private static void ResetPartialArtifacts(string tempFilePath, string metadataFilePath) private static bool HasDownloaderResumeArtifacts(string destinationFilePath)
{ {
CleanupPartialArtifacts(tempFilePath, metadataFilePath); return File.Exists(BuildTempFilePath(destinationFilePath)) ||
File.Exists(BuildPackageFilePath(destinationFilePath));
}
private static bool ShouldUseParallelDownload(DownloadOptions options)
{
return options.MaxParallelSegments > 1;
}
private static DownloadOptions NormalizeOptions(DownloadOptions? options)
{
var normalized = options ?? new DownloadOptions();
return normalized with
{
MaxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8),
ParallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes),
BufferSize = Math.Max(16 * 1024, normalized.BufferSize)
};
} }
private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part"; private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part";
private static string BuildMetadataFilePath(string destinationFilePath) => destinationFilePath + ".part.json"; private static string BuildPackageFilePath(string destinationFilePath) => destinationFilePath + ".download";
private sealed record RemoteProbeResult(long? TotalBytes, bool SupportsRanges);
private sealed class RangeRequestNotSupportedException : InvalidOperationException
{
public RangeRequestNotSupportedException(string message)
: base(message)
{
}
}
private sealed class MetadataWriter
{
private readonly string _metadataFilePath;
private readonly DownloadMetadata _metadata;
private readonly SemaphoreSlim _writeGate = new(1, 1);
private long _lastPersistedTickCount;
private int _dirty;
public MetadataWriter(string metadataFilePath, DownloadMetadata metadata)
{
_metadataFilePath = metadataFilePath;
_metadata = metadata;
_lastPersistedTickCount = Environment.TickCount64;
}
public void MarkDirty()
{
Interlocked.Exchange(ref _dirty, 1);
var now = Environment.TickCount64;
if (now - Interlocked.Read(ref _lastPersistedTickCount) < 750)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await FlushAsync(CancellationToken.None);
}
catch
{
// The final flush still runs on completion/cancellation.
}
});
}
public async Task FlushAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _dirty, 0) == 0 && File.Exists(_metadataFilePath))
{
return;
}
await _writeGate.WaitAsync(cancellationToken);
try
{
var json = JsonSerializer.Serialize(_metadata.ToSerializable(), MetadataSerializerOptions);
await File.WriteAllTextAsync(_metadataFilePath, json, cancellationToken);
Interlocked.Exchange(ref _lastPersistedTickCount, Environment.TickCount64);
}
finally
{
_writeGate.Release();
}
}
}
private sealed class DownloadMetadata
{
public string Source { get; init; } = string.Empty;
public long TotalBytes { get; init; }
public List<DownloadSegmentState> Segments { get; init; } = [];
public static DownloadMetadata Create(string source, long totalBytes, int segmentCount)
{
var segments = SplitIntoSegments(totalBytes, segmentCount)
.Select(range => new DownloadSegmentState(range.Start, range.EndInclusive, 0))
.ToList();
return new DownloadMetadata
{
Source = source,
TotalBytes = totalBytes,
Segments = segments
};
}
public DownloadMetadata Normalize()
{
foreach (var segment in Segments)
{
segment.CompletedBytes = Math.Clamp(segment.CompletedBytes, 0, segment.Length);
}
return this;
}
public SerializableDownloadMetadata ToSerializable()
{
return new SerializableDownloadMetadata
{
Source = Source,
TotalBytes = TotalBytes,
Segments = Segments
.Select(segment => new SerializableDownloadSegment
{
Start = segment.Start,
EndInclusive = segment.EndInclusive,
CompletedBytes = segment.CompletedBytes
})
.ToList()
};
}
}
private sealed class DownloadSegmentState
{
public DownloadSegmentState(long start, long endInclusive, long completedBytes)
{
Start = start;
EndInclusive = endInclusive;
CompletedBytes = completedBytes;
}
public long Start { get; }
public long EndInclusive { get; }
public long Length => EndInclusive - Start + 1;
public long CompletedBytes { get; set; }
}
private sealed class SerializableDownloadMetadata
{
public string Source { get; init; } = string.Empty;
public long TotalBytes { get; init; }
public List<SerializableDownloadSegment> Segments { get; init; } = [];
public DownloadMetadata ToRuntime()
{
return new DownloadMetadata
{
Source = Source,
TotalBytes = TotalBytes,
Segments = Segments
.Select(segment => new DownloadSegmentState(
segment.Start,
segment.EndInclusive,
segment.CompletedBytes))
.ToList()
};
}
}
private sealed class SerializableDownloadSegment
{
public long Start { get; init; }
public long EndInclusive { get; init; }
public long CompletedBytes { get; init; }
}
private static IEnumerable<(long Start, long EndInclusive)> SplitIntoSegments(long totalBytes, int segmentCount)
{
if (totalBytes <= 0)
{
yield break;
}
var normalizedSegmentCount = Math.Max(1, segmentCount);
var segmentSize = totalBytes / normalizedSegmentCount;
var remainder = totalBytes % normalizedSegmentCount;
long start = 0;
for (var index = 0; index < normalizedSegmentCount; index++)
{
var currentSegmentSize = segmentSize + (index < remainder ? 1 : 0);
if (currentSegmentSize <= 0)
{
continue;
}
var endInclusive = start + currentSegmentSize - 1;
yield return (start, endInclusive);
start = endInclusive + 1;
}
}
} }

View File

@@ -0,0 +1,151 @@
using System;
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public sealed class SingleInstanceService : IDisposable
{
private readonly Mutex _mutex;
private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new();
private bool _ownsMutex;
private bool _disposed;
private Task? _listenTask;
private SingleInstanceService(string mutexName, string pipeName)
{
_mutex = new Mutex(initiallyOwned: false, mutexName);
_pipeName = pipeName;
try
{
_ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
}
catch (AbandonedMutexException)
{
_ownsMutex = true;
}
}
public bool IsPrimaryInstance => _ownsMutex;
public static SingleInstanceService CreateDefault()
{
const string appId = "LanMountainDesktop";
var userName = Environment.UserName;
var scopeSeed = $"{appId}:{userName}";
var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed)));
var suffix = scopeHash[..16];
var mutexName = OperatingSystem.IsWindows()
? $"Local\\{appId}.SingleInstance.{suffix}"
: $"{appId}.SingleInstance.{suffix}";
return new SingleInstanceService(
mutexName,
$"{appId}.Activate.{suffix}");
}
public void StartActivationListener(Action onActivationRequested)
{
ArgumentNullException.ThrowIfNull(onActivationRequested);
if (!_ownsMutex || _disposed || _listenTask is not null)
{
return;
}
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
{
if (_ownsMutex || _disposed)
{
return false;
}
try
{
using var client = new NamedPipeClientStream(
serverName: ".",
pipeName: _pipeName,
direction: PipeDirection.Out,
options: PipeOptions.Asynchronous);
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
client.WriteByte(1);
client.Flush();
return true;
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
return false;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_listenCts.Cancel();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(1));
}
catch
{
// Ignore listener shutdown races during process exit.
}
_listenCts.Dispose();
if (_ownsMutex)
{
try
{
_mutex.ReleaseMutex();
}
catch (ApplicationException)
{
// Ownership may already be lost during shutdown.
}
}
_mutex.Dispose();
}
private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
using var server = new NamedPipeServerStream(
_pipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
onActivationRequested();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Activation listener failed.", ex);
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
namespace LanMountainDesktop.Services;
public sealed class StartupDiagnosticsResult
{
public string ExecutablePath { get; init; } = string.Empty;
public string BaseDirectory { get; init; } = string.Empty;
public string ExecutableName { get; init; } = string.Empty;
public bool IsLegacyExecutableLaunch { get; init; }
public IReadOnlyList<string> FoundLegacyArtifacts { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> DeletedLegacyArtifacts { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> FailedLegacyArtifacts { get; init; } = Array.Empty<string>();
}
public static class StartupDiagnosticsService
{
private const string CurrentExecutableName = "LanMountainDesktop.exe";
private static readonly string[] LegacyArtifactNames =
[
"LanMontainDesktop.exe",
"LanMontainDesktop.dll",
"LanMontainDesktop.deps.json",
"LanMontainDesktop.runtimeconfig.json",
"LanMontainDesktop.pdb",
"LanMontainDesktop.exe.WebView2"
];
public static StartupDiagnosticsResult Run(string[] args)
{
var executablePath = ResolveExecutablePath();
var baseDirectory = AppContext.BaseDirectory;
var executableName = Path.GetFileName(executablePath);
var assemblyVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
var fileVersion = string.Empty;
try
{
if (!string.IsNullOrWhiteSpace(executablePath) && File.Exists(executablePath))
{
fileVersion = FileVersionInfo.GetVersionInfo(executablePath).FileVersion ?? string.Empty;
}
}
catch
{
// Keep diagnostics best-effort.
}
AppLogger.Info(
"Startup",
$"Application starting. ExecutablePath={executablePath}; BaseDirectory={baseDirectory}; ExecutableName={executableName}; AssemblyVersion={assemblyVersion}; FileVersion={fileVersion}; Args=[{string.Join(", ", args)}]");
var foundLegacyArtifacts = LegacyArtifactNames
.Select(name => Path.Combine(baseDirectory, name))
.Where(path => File.Exists(path) || Directory.Exists(path))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
var deletedLegacyArtifacts = new List<string>();
var failedLegacyArtifacts = new List<string>();
foreach (var legacyArtifact in foundLegacyArtifacts)
{
if (string.Equals(legacyArtifact, executablePath, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (TryDeleteLegacyArtifact(legacyArtifact, out var error))
{
deletedLegacyArtifacts.Add(legacyArtifact);
}
else if (!string.IsNullOrWhiteSpace(error))
{
failedLegacyArtifacts.Add($"{legacyArtifact} ({error})");
}
}
if (foundLegacyArtifacts.Count > 0)
{
AppLogger.Warn(
"StartupDiagnostics",
$"Found legacy artifacts: {string.Join("; ", foundLegacyArtifacts)}");
}
if (deletedLegacyArtifacts.Count > 0)
{
AppLogger.Info(
"StartupDiagnostics",
$"Deleted legacy artifacts: {string.Join("; ", deletedLegacyArtifacts)}");
}
if (failedLegacyArtifacts.Count > 0)
{
AppLogger.Warn(
"StartupDiagnostics",
$"Failed to delete legacy artifacts: {string.Join("; ", failedLegacyArtifacts)}");
}
var isLegacyExecutableLaunch = string.Equals(
executableName,
"LanMontainDesktop.exe",
StringComparison.OrdinalIgnoreCase);
if (isLegacyExecutableLaunch)
{
AppLogger.Warn(
"StartupDiagnostics",
$"Legacy executable launch detected. Current executable should be '{CurrentExecutableName}', but actual executable is '{executableName}'.");
}
return new StartupDiagnosticsResult
{
ExecutablePath = executablePath,
BaseDirectory = baseDirectory,
ExecutableName = executableName,
IsLegacyExecutableLaunch = isLegacyExecutableLaunch,
FoundLegacyArtifacts = foundLegacyArtifacts,
DeletedLegacyArtifacts = deletedLegacyArtifacts,
FailedLegacyArtifacts = failedLegacyArtifacts
};
}
public static void ShowLegacyExecutableWarningIfNeeded(StartupDiagnosticsResult diagnostics)
{
if (!diagnostics.IsLegacyExecutableLaunch)
{
return;
}
var message =
"检测到当前是从旧残留可执行文件启动的。\r\n\r\n" +
$"当前文件: {diagnostics.ExecutableName}\r\n" +
$"当前路径: {diagnostics.ExecutablePath}\r\n\r\n" +
$"请改用 {CurrentExecutableName} 启动,以免继续读取旧残留文件。\r\n" +
$"日志目录: {AppLogger.LogDirectory}";
WindowsNativeDialogService.ShowWarning("LanMountainDesktop 启动诊断", message);
}
private static string ResolveExecutablePath()
{
try
{
return Environment.ProcessPath ??
Process.GetCurrentProcess().MainModule?.FileName ??
string.Empty;
}
catch
{
return string.Empty;
}
}
private static bool TryDeleteLegacyArtifact(string path, out string? error)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
error = null;
return true;
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
error = null;
return true;
}
error = null;
return false;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}

View File

@@ -16,6 +16,14 @@ public static class StudyAnalyticsServiceFactory
{ {
return SharedService.Value; return SharedService.Value;
} }
public static void DisposeSharedService()
{
if (SharedService.IsValueCreated)
{
SharedService.Value.Dispose();
}
}
} }
public sealed class StudyAnalyticsService : IStudyAnalyticsService public sealed class StudyAnalyticsService : IStudyAnalyticsService
@@ -446,6 +454,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_disposed = true; _disposed = true;
StopTimerLocked(); StopTimerLocked();
_samplingTimer.Dispose(); _samplingTimer.Dispose();
_audioRecorderService.Dispose();
} }
} }
@@ -759,4 +768,3 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_lastSessionReport = null; _lastSessionReport = null;
} }
} }

View File

@@ -0,0 +1,41 @@
using System;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
internal static class WindowsNativeDialogService
{
private const uint Ok = 0x00000000;
private const uint IconInformation = 0x00000040;
private const uint IconWarning = 0x00000030;
public static void ShowInformation(string caption, string message)
{
Show(caption, message, Ok | IconInformation, "NativeDialog");
}
public static void ShowWarning(string caption, string message)
{
Show(caption, message, Ok | IconWarning, "StartupDiagnostics");
}
private static void Show(string caption, string message, uint type, string logCategory)
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
_ = MessageBoxW(IntPtr.Zero, message, caption, type);
}
catch (Exception ex)
{
AppLogger.Warn(logCategory, "Failed to show native dialog.", ex);
}
}
[DllImport("user32.dll", EntryPoint = "MessageBoxW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}

View File

@@ -30,8 +30,9 @@ public sealed class WindowsStartupService
return runKey?.GetValue(ValueName) is string value && return runKey?.GetValue(ValueName) is string value &&
!string.IsNullOrWhiteSpace(value); !string.IsNullOrWhiteSpace(value);
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("WindowsStartup", "Failed to query startup registry state.", ex);
return false; return false;
} }
} }
@@ -67,8 +68,9 @@ public sealed class WindowsStartupService
return IsEnabled() == enabled; return IsEnabled() == enabled;
} }
catch catch (Exception ex)
{ {
AppLogger.Warn("WindowsStartup", $"Failed to set startup registry state. Enabled={enabled}", ex);
return false; return false;
} }
} }

View File

@@ -13,6 +13,7 @@ using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
@@ -1069,12 +1070,12 @@ public partial class MainWindow
private void RenderLauncherHiddenItemsList() private void RenderLauncherHiddenItemsList()
{ {
if (LauncherHiddenItemsListPanel is null || LauncherHiddenItemsEmptyTextBlock is null) if (LauncherHiddenItemsSettingsExpander is null || LauncherHiddenItemsEmptyTextBlock is null)
{ {
return; return;
} }
LauncherHiddenItemsListPanel.Children.Clear(); LauncherHiddenItemsSettingsExpander.Items.Clear();
var hiddenItems = BuildLauncherHiddenItems(); var hiddenItems = BuildLauncherHiddenItems();
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0; LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
if (hiddenItems.Count == 0) if (hiddenItems.Count == 0)
@@ -1084,7 +1085,7 @@ public partial class MainWindow
foreach (var hiddenItem in hiddenItems) foreach (var hiddenItem in hiddenItems)
{ {
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem)); LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
} }
} }
@@ -1186,92 +1187,58 @@ public partial class MainWindow
: fileName; : fileName;
} }
private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem) private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
{ {
Control icon = hiddenItem.IconBitmap is not null
? new Image
{
Source = hiddenItem.IconBitmap,
Width = 24,
Height = 24,
Stretch = Stretch.Uniform
}
: new Border
{
Width = 24,
Height = 24,
CornerRadius = new CornerRadius(999),
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
Child = new TextBlock
{
Text = hiddenItem.Monogram,
FontSize = 10,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
? L("settings.launcher.hidden_type_folder", "Folder") ? L("settings.launcher.hidden_type_folder", "Folder")
: L("settings.launcher.hidden_type_shortcut", "Shortcut"); : L("settings.launcher.hidden_type_shortcut", "Shortcut");
var infoPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
infoPanel.Children.Add(icon);
infoPanel.Children.Add(new StackPanel
{
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Left,
Children =
{
new TextBlock
{
Text = hiddenItem.DisplayName,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 1
},
new TextBlock
{
Text = typeText,
FontSize = 11,
Opacity = 0.7
}
}
});
var restoreButton = new Button var restoreButton = new Button
{ {
Content = L("settings.launcher.restore_button", "Show Again"), Width = 36,
MinWidth = 110, Height = 36,
Padding = new Thickness(12, 6), Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key) Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
}; };
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
{
Symbol = FluentIcons.Common.Symbol.Eye,
IconVariant = FluentIcons.Common.IconVariant.Regular,
FontSize = 18,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
restoreButton.Click += OnRestoreLauncherHiddenItemClick; restoreButton.Click += OnRestoreLauncherHiddenItemClick;
var row = new Grid return new SettingsExpanderItem
{ {
ColumnDefinitions = new ColumnDefinitions("*,Auto"), Content = hiddenItem.DisplayName,
ColumnSpacing = 10 Description = typeText,
IconSource = CreateLauncherHiddenItemIconSource(hiddenItem),
IsClickEnabled = false,
Footer = restoreButton
}; };
row.Children.Add(infoPanel); }
Grid.SetColumn(infoPanel, 0);
row.Children.Add(restoreButton);
Grid.SetColumn(restoreButton, 1);
return new Border private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem)
{
if (hiddenItem.IconBitmap is not null)
{ {
Classes = { "glass-panel" }, return new ImageIconSource
BorderThickness = new Thickness(0), {
CornerRadius = new CornerRadius(14), Source = hiddenItem.IconBitmap
Padding = new Thickness(10, 8), };
Child = row }
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
? FluentIcons.Common.Symbol.Folder
: FluentIcons.Common.Symbol.Apps,
IconVariant = FluentIcons.Common.IconVariant.Regular
}; };
} }

View File

@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -77,7 +78,9 @@ public partial class MainWindow
var result = await dialog.ShowAsync(this); var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary) if (result == ContentDialogResult.Primary)
{ {
if (!AppRestartService.TryRestartApplication()) if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
Source: nameof(MainWindow),
Reason: "User confirmed a pending restart prompt.")) != true)
{ {
UpdatePendingRestartDock(); UpdatePendingRestartDock();
} }

View File

@@ -158,6 +158,7 @@ public partial class MainWindow
} }
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext()); ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
UpdateVideoWallpaperPreviewVisibility();
} }
private void OnNightModeChecked(object? sender, RoutedEventArgs e) private void OnNightModeChecked(object? sender, RoutedEventArgs e)
@@ -344,6 +345,7 @@ public partial class MainWindow
var placement = GetSelectedWallpaperPlacement(); var placement = GetSelectedWallpaperPlacement();
DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false); DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false);
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true); WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
UpdateVideoWallpaperPreviewVisibility();
} }
private void UpdateWallpaperDisplay() private void UpdateWallpaperDisplay()
@@ -628,12 +630,99 @@ public partial class MainWindow
EnableHardwareDecoding = false EnableHardwareDecoding = false
}; };
} }
}
if (_previewVideoWallpaperPlayer is null && WallpaperPreviewVideoView is not null) private void EnsureDesktopVideoFrameRefreshTimer()
{
if (_desktopVideoFrameRefreshTimer is not null)
{ {
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc); return;
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
} }
_desktopVideoFrameRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(33)
};
_desktopVideoFrameRefreshTimer.Tick += OnDesktopVideoFrameRefreshTimerTick;
}
private void StartDesktopVideoFrameRefreshTimer()
{
EnsureDesktopVideoFrameRefreshTimer();
if (_desktopVideoFrameRefreshTimer?.IsEnabled == false)
{
_desktopVideoFrameRefreshTimer.Start();
}
}
private void StopDesktopVideoFrameRefreshTimer()
{
if (_desktopVideoFrameRefreshTimer?.IsEnabled == true)
{
_desktopVideoFrameRefreshTimer.Stop();
}
}
private void OnDesktopVideoFrameRefreshTimerTick(object? sender, EventArgs e)
{
PushDesktopVideoFrameToWallpaperImage();
}
private void UpdateVideoWallpaperPreviewVisibility()
{
var shouldShowPreview =
_wallpaperMediaType == WallpaperMediaType.Video &&
_isSettingsOpen &&
SettingsPage.IsVisible &&
WallpaperSettingsPanel.IsVisible &&
_wallpaperPreviewSnapshotBitmap is not null;
WallpaperPreviewVideoImage.IsVisible = shouldShowPreview;
if (shouldShowPreview && !ReferenceEquals(WallpaperPreviewVideoImage.Source, _wallpaperPreviewSnapshotBitmap))
{
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
}
}
private void InvalidateVideoWallpaperPreviewSnapshot()
{
_wallpaperPreviewSnapshotPending = true;
_wallpaperPreviewSnapshotBitmap?.Dispose();
_wallpaperPreviewSnapshotBitmap = null;
WallpaperPreviewVideoImage.Source = null;
}
private void CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer()
{
if (!_wallpaperPreviewSnapshotPending ||
_desktopVideoStagingBuffer is null ||
_desktopVideoFrameWidth <= 0 ||
_desktopVideoFrameHeight <= 0 ||
_desktopVideoFramePitch <= 0)
{
return;
}
_wallpaperPreviewSnapshotBitmap?.Dispose();
_wallpaperPreviewSnapshotBitmap = new WriteableBitmap(
new PixelSize(_desktopVideoFrameWidth, _desktopVideoFrameHeight),
new Vector(96, 96),
PixelFormat.Bgra8888,
AlphaFormat.Opaque);
using var framebuffer = _wallpaperPreviewSnapshotBitmap.Lock();
var rows = Math.Min(framebuffer.Size.Height, _desktopVideoFrameHeight);
var bytesPerRow = Math.Min(framebuffer.RowBytes, _desktopVideoFramePitch);
for (var row = 0; row < rows; row++)
{
var sourceOffset = row * _desktopVideoFramePitch;
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
Marshal.Copy(_desktopVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
}
_wallpaperPreviewSnapshotPending = false;
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
UpdateVideoWallpaperPreviewVisibility();
} }
private bool ConfigureDesktopVideoRenderer() private bool ConfigureDesktopVideoRenderer()
@@ -685,6 +774,7 @@ public partial class MainWindow
(uint)_desktopVideoFrameHeight, (uint)_desktopVideoFrameHeight,
(uint)_desktopVideoFramePitch); (uint)_desktopVideoFramePitch);
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap; DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
InvalidateVideoWallpaperPreviewSnapshot();
return true; return true;
} }
catch catch
@@ -745,31 +835,6 @@ public partial class MainWindow
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture) private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
{ {
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1); Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1);
ScheduleDesktopVideoFrameUiRefresh();
}
private void ScheduleDesktopVideoFrameUiRefresh()
{
if (Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 1) == 1)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
try
{
PushDesktopVideoFrameToWallpaperImage();
}
finally
{
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
if (Volatile.Read(ref _desktopVideoFrameDirtyFlag) == 1)
{
ScheduleDesktopVideoFrameUiRefresh();
}
}
}, DispatcherPriority.Render);
} }
private void PushDesktopVideoFrameToWallpaperImage() private void PushDesktopVideoFrameToWallpaperImage()
@@ -812,18 +877,22 @@ public partial class MainWindow
{ {
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap; DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
} }
CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer();
} }
private void ReleaseDesktopVideoRendererResources() private void ReleaseDesktopVideoRendererResources()
{ {
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0); Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
if (DesktopVideoWallpaperImage is not null) if (DesktopVideoWallpaperImage is not null)
{ {
DesktopVideoWallpaperImage.Source = null; DesktopVideoWallpaperImage.Source = null;
} }
InvalidateVideoWallpaperPreviewSnapshot();
WallpaperPreviewVideoImage.Source = null;
_desktopVideoBitmap?.Dispose(); _desktopVideoBitmap?.Dispose();
_desktopVideoBitmap = null; _desktopVideoBitmap = null;
_desktopVideoStagingBuffer = null; _desktopVideoStagingBuffer = null;
@@ -855,10 +924,8 @@ public partial class MainWindow
{ {
EnsureVideoWallpaperPlayers(); EnsureVideoWallpaperPlayers();
if (_videoWallpaperPlayer is null || if (_videoWallpaperPlayer is null ||
_previewVideoWallpaperPlayer is null ||
_libVlc is null || _libVlc is null ||
DesktopVideoWallpaperImage is null || DesktopVideoWallpaperImage is null)
WallpaperPreviewVideoView is null)
{ {
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable."); _wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
StopVideoWallpaper(); StopVideoWallpaper();
@@ -873,15 +940,13 @@ public partial class MainWindow
} }
_videoWallpaperMedia?.Dispose(); _videoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia?.Dispose();
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath)); _videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_videoWallpaperMedia.AddOption(":input-repeat=65535"); _videoWallpaperMedia.AddOption(":input-repeat=65535");
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535"); InvalidateVideoWallpaperPreviewSnapshot();
_videoWallpaperPlayer.Play(_videoWallpaperMedia); _videoWallpaperPlayer.Play(_videoWallpaperMedia);
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia); StartDesktopVideoFrameRefreshTimer();
DesktopVideoWallpaperImage.IsVisible = true; DesktopVideoWallpaperImage.IsVisible = true;
WallpaperPreviewVideoView.IsVisible = true; UpdateVideoWallpaperPreviewVisibility();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -897,26 +962,17 @@ public partial class MainWindow
DesktopVideoWallpaperImage.IsVisible = false; DesktopVideoWallpaperImage.IsVisible = false;
} }
if (WallpaperPreviewVideoView is not null) WallpaperPreviewVideoImage.IsVisible = false;
{
WallpaperPreviewVideoView.IsVisible = false;
}
if (_videoWallpaperPlayer is not null) if (_videoWallpaperPlayer is not null)
{ {
_videoWallpaperPlayer.Stop(); _videoWallpaperPlayer.Stop();
} }
if (_previewVideoWallpaperPlayer is not null) StopDesktopVideoFrameRefreshTimer();
{
_previewVideoWallpaperPlayer.Stop();
}
ReleaseDesktopVideoRendererResources(); ReleaseDesktopVideoRendererResources();
_videoWallpaperMedia?.Dispose(); _videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null; _videoWallpaperMedia = null;
_previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = null;
} }
private void PersistSettings() private void PersistSettings()
@@ -2379,7 +2435,14 @@ public partial class MainWindow
_isSettingsOpen = true; _isSettingsOpen = true;
UpdateDesktopPageAwareComponentContext(); UpdateDesktopPageAwareComponentContext();
UpdateAdaptiveTextSystem(); UpdateAdaptiveTextSystem();
ApplyWallpaperBrush(); if (_wallpaperMediaType == WallpaperMediaType.Video)
{
UpdateVideoWallpaperPreviewVisibility();
}
else
{
ApplyWallpaperBrush();
}
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext()); ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
if (_settingsContentPanelTransform is not null) if (_settingsContentPanelTransform is not null)
{ {
@@ -2387,6 +2450,7 @@ public partial class MainWindow
} }
SettingsPage.IsVisible = true; SettingsPage.IsVisible = true;
SettingsPage.Opacity = 0; SettingsPage.Opacity = 0;
UpdateVideoWallpaperPreviewVisibility();
UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize)); UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize));
UpdateWallpaperPreviewLayout(); UpdateWallpaperPreviewLayout();
@@ -2416,7 +2480,11 @@ public partial class MainWindow
_isSettingsOpen = false; _isSettingsOpen = false;
UpdateDesktopPageAwareComponentContext(); UpdateDesktopPageAwareComponentContext();
UpdateAdaptiveTextSystem(); UpdateAdaptiveTextSystem();
ApplyWallpaperBrush(); UpdateVideoWallpaperPreviewVisibility();
if (_wallpaperMediaType != WallpaperMediaType.Video)
{
ApplyWallpaperBrush();
}
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext()); ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
if (immediate) if (immediate)
@@ -2608,7 +2676,7 @@ public partial class MainWindow
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!; internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!; internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!; internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView"); internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!; internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!; internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!; internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
@@ -2805,7 +2873,6 @@ public partial class MainWindow
// --- LauncherSettingsPage --- // --- LauncherSettingsPage ---
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!; internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl<StackPanel>("LauncherHiddenItemsListPanel")!;
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!; internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!; internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;

View File

@@ -1,11 +1,12 @@
using System; using System;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -357,7 +358,8 @@ public partial class MainWindow
{ {
FileName = installerPath, FileName = installerPath,
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory, WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
UseShellExecute = true UseShellExecute = true,
Verb = "runas"
}); });
_updateStatusText = L( _updateStatusText = L(
@@ -365,7 +367,16 @@ public partial class MainWindow
"Installer started. The app will close for update."); "Installer started. The app will close for update.");
UpdateUpdatePanelState(); UpdateUpdatePanelState();
Dispatcher.UIThread.Post(Close, DispatcherPriority.Background); _ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
Source: nameof(MainWindow),
Reason: "Update installer started successfully."));
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
_updateStatusText = L(
"settings.update.status_elevation_cancelled",
"Administrator permission was not granted. Update was cancelled.");
UpdateUpdatePanelState();
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -124,21 +124,21 @@ public partial class MainWindow : Window
private LibVLC? _libVlc; private LibVLC? _libVlc;
private MediaPlayer? _videoWallpaperPlayer; private MediaPlayer? _videoWallpaperPlayer;
private Media? _videoWallpaperMedia; private Media? _videoWallpaperMedia;
private MediaPlayer? _previewVideoWallpaperPlayer;
private Media? _previewVideoWallpaperMedia;
private readonly object _desktopVideoFrameSync = new(); private readonly object _desktopVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback; private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback; private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback; private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
private IntPtr _desktopVideoFrameBufferPtr; private IntPtr _desktopVideoFrameBufferPtr;
private byte[]? _desktopVideoStagingBuffer; private byte[]? _desktopVideoStagingBuffer;
private WriteableBitmap? _desktopVideoBitmap; private WriteableBitmap? _desktopVideoBitmap;
private WriteableBitmap? _wallpaperPreviewSnapshotBitmap;
private int _desktopVideoFrameWidth; private int _desktopVideoFrameWidth;
private int _desktopVideoFrameHeight; private int _desktopVideoFrameHeight;
private int _desktopVideoFramePitch; private int _desktopVideoFramePitch;
private int _desktopVideoFrameBufferSize; private int _desktopVideoFrameBufferSize;
private int _desktopVideoFrameDirtyFlag; private int _desktopVideoFrameDirtyFlag;
private int _desktopVideoFrameUiRefreshScheduledFlag; private bool _wallpaperPreviewSnapshotPending;
private string? _wallpaperPath; private string? _wallpaperPath;
private string _wallpaperStatus = "Current background uses solid color."; private string _wallpaperStatus = "Current background uses solid color.";
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>(); private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
@@ -384,15 +384,15 @@ public partial class MainWindow : Window
{ {
PersistSettings(); PersistSettings();
StopVideoWallpaper(); StopVideoWallpaper();
_previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = null;
_previewVideoWallpaperPlayer?.Dispose();
_previewVideoWallpaperPlayer = null;
DisposeLauncherResources(); DisposeLauncherResources();
_videoWallpaperMedia?.Dispose(); _videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null; _videoWallpaperMedia = null;
_videoWallpaperPlayer?.Dispose(); _videoWallpaperPlayer?.Dispose();
_videoWallpaperPlayer = null; _videoWallpaperPlayer = null;
_desktopVideoFrameRefreshTimer?.Stop();
_desktopVideoFrameRefreshTimer = null;
_wallpaperPreviewSnapshotBitmap?.Dispose();
_wallpaperPreviewSnapshotBitmap = null;
_libVlc?.Dispose(); _libVlc?.Dispose();
_libVlc = null; _libVlc = null;
if (_weatherDataService is IDisposable weatherServiceDisposable) if (_weatherDataService is IDisposable weatherServiceDisposable)

View File

@@ -31,12 +31,6 @@
IsVisible="False" IsVisible="False"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No hidden items." /> Text="No hidden items." />
<ScrollViewer MaxHeight="420"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="LauncherHiddenItemsListPanel"
Spacing="8" />
</ScrollViewer>
</StackPanel> </StackPanel>
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>

View File

@@ -6,7 +6,6 @@
xmlns:fi="using:FluentIcons.Avalonia" xmlns:fi="using:FluentIcons.Avalonia"
xmlns:ic="using:FluentIcons.Avalonia.Fluent" xmlns:ic="using:FluentIcons.Avalonia.Fluent"
xmlns:comp="using:LanMountainDesktop.Views.Components" xmlns:comp="using:LanMountainDesktop.Views.Components"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage"> x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage">
<Grid x:Name="WallpaperSettingsPanel" <Grid x:Name="WallpaperSettingsPanel"
@@ -37,11 +36,12 @@
CornerRadius="12" CornerRadius="12"
Background="#30111827"> Background="#30111827">
<Grid> <Grid>
<vlc:VideoView x:Name="WallpaperPreviewVideoView" <Image x:Name="WallpaperPreviewVideoImage"
IsVisible="False" IsVisible="False"
IsHitTestVisible="False" IsHitTestVisible="False"
HorizontalAlignment="Stretch" Stretch="UniformToFill"
VerticalAlignment="Stretch" /> HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<Grid x:Name="WallpaperPreviewGrid" <Grid x:Name="WallpaperPreviewGrid"
HorizontalAlignment="Center" HorizontalAlignment="Center"

View File

@@ -1,7 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using LibVLCSharp.Avalonia;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
public partial class SettingsWindow public partial class SettingsWindow
@@ -14,7 +13,7 @@ public partial class SettingsWindow
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!; internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!; internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!; internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView"); internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!; internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!; internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!; internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
@@ -211,7 +210,6 @@ public partial class SettingsWindow
// --- LauncherSettingsPage --- // --- LauncherSettingsPage ---
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!; internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl<StackPanel>("LauncherHiddenItemsListPanel")!;
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!; internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!; internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;

View File

@@ -29,6 +29,8 @@ public partial class SettingsWindow
_previewVideoWallpaperPlayer = null; _previewVideoWallpaperPlayer = null;
_previewVideoWallpaperMedia?.Dispose(); _previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = null; _previewVideoWallpaperMedia = null;
_previewVideoFrameRefreshTimer?.Stop();
_previewVideoFrameRefreshTimer = null;
_libVlc?.Dispose(); _libVlc?.Dispose();
_libVlc = null; _libVlc = null;
@@ -254,6 +256,7 @@ public partial class SettingsWindow
} }
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext()); ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
SyncVideoWallpaperPreviewPlayback();
} }
private void PersistSettings() private void PersistSettings()

View File

@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -77,7 +78,9 @@ public partial class SettingsWindow
var result = await dialog.ShowAsync(this); var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary) if (result == ContentDialogResult.Primary)
{ {
if (!AppRestartService.TryRestartApplication()) if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
Source: nameof(SettingsWindow),
Reason: "User confirmed a pending restart prompt from settings.")) != true)
{ {
UpdatePendingRestartDock(); UpdatePendingRestartDock();
} }

View File

@@ -1,13 +1,12 @@
using System; using System;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -250,23 +249,23 @@ public partial class SettingsWindow
{ {
FileName = installerPath, FileName = installerPath,
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory, WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
UseShellExecute = true UseShellExecute = true,
Verb = "runas"
}); });
_updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update."); _updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update.");
UpdateUpdatePanelState(); UpdateUpdatePanelState();
Dispatcher.UIThread.Post(() => _ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
{ Source: nameof(SettingsWindow),
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) Reason: "Update installer started successfully from settings."));
{ }
desktop.Shutdown(); catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
} {
else _updateStatusText = L(
{ "settings.update.status_elevation_cancelled",
Close(); "Administrator permission was not granted. Update was cancelled.");
} UpdateUpdatePanelState();
}, DispatcherPriority.Background);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
@@ -12,6 +13,7 @@ using Avalonia.Media.Imaging;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Theme; using LanMountainDesktop.Theme;
using LibVLCSharp.Shared; using LibVLCSharp.Shared;
@@ -157,7 +159,7 @@ public partial class SettingsWindow
{ {
DesktopWallpaperLayer.Background = Brushes.Transparent; DesktopWallpaperLayer.Background = Brushes.Transparent;
WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground(); WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground();
PlayVideoWallpaper(_wallpaperVideoPath); SyncVideoWallpaperPreviewPlayback();
return; return;
} }
@@ -175,6 +177,36 @@ public partial class SettingsWindow
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true); WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
} }
private void SyncVideoWallpaperPreviewPlayback()
{
var shouldPlay =
_wallpaperMediaType == WallpaperMediaType.Video &&
!string.IsNullOrWhiteSpace(_wallpaperVideoPath) &&
WallpaperSettingsPanel.IsVisible;
if (!shouldPlay)
{
if (_previewVideoWallpaperPlayer?.IsPlaying == true)
{
StopPreviewVideoCapture(clearSnapshot: false);
}
WallpaperPreviewVideoImage.IsVisible = WallpaperPreviewVideoImage.Source is not null && WallpaperSettingsPanel.IsVisible;
return;
}
if (WallpaperPreviewVideoImage.Source is not null)
{
WallpaperPreviewVideoImage.IsVisible = true;
return;
}
if (_previewVideoWallpaperMedia is null || _previewVideoSnapshotPending)
{
PlayVideoWallpaper(_wallpaperVideoPath!);
}
}
private void UpdateWallpaperDisplay() private void UpdateWallpaperDisplay()
{ {
WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath) WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath)
@@ -417,11 +449,239 @@ public partial class SettingsWindow
private void EnsureVideoWallpaperPlayers() private void EnsureVideoWallpaperPlayers()
{ {
Core.Initialize(); Core.Initialize();
_libVlc ??= new LibVLC(); _libVlc ??= new LibVLC("--quiet");
_previewVideoWallpaperPlayer ??= new MediaPlayer(_libVlc); if (_previewVideoWallpaperPlayer is null)
if (WallpaperPreviewVideoView is not null)
{ {
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer; _previewVideoWallpaperPlayer = new MediaPlayer(_libVlc)
{
EnableHardwareDecoding = false
};
}
}
private void EnsurePreviewVideoFrameRefreshTimer()
{
if (_previewVideoFrameRefreshTimer is not null)
{
return;
}
_previewVideoFrameRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(33)
};
_previewVideoFrameRefreshTimer.Tick += OnPreviewVideoFrameRefreshTimerTick;
}
private void StartPreviewVideoFrameRefreshTimer()
{
EnsurePreviewVideoFrameRefreshTimer();
if (_previewVideoFrameRefreshTimer?.IsEnabled == false)
{
_previewVideoFrameRefreshTimer.Start();
}
}
private void StopPreviewVideoFrameRefreshTimer()
{
if (_previewVideoFrameRefreshTimer?.IsEnabled == true)
{
_previewVideoFrameRefreshTimer.Stop();
}
}
private void OnPreviewVideoFrameRefreshTimerTick(object? sender, EventArgs e)
{
PushPreviewVideoFrameToWallpaperImage();
}
private void StopPreviewVideoCapture(bool clearSnapshot)
{
WallpaperPreviewVideoImage.IsVisible = false;
_previewVideoWallpaperPlayer?.Stop();
StopPreviewVideoFrameRefreshTimer();
_previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = null;
_previewVideoSnapshotPending = false;
if (clearSnapshot)
{
ReleasePreviewVideoRendererResources();
}
}
private bool ConfigurePreviewVideoRenderer()
{
if (_previewVideoWallpaperPlayer is null)
{
return false;
}
var hostWidth = Math.Max(1, WallpaperPreviewViewport.Bounds.Width);
var hostHeight = Math.Max(1, WallpaperPreviewViewport.Bounds.Height);
var pixelWidth = Math.Max(1, (int)Math.Round(hostWidth * RenderScaling));
var pixelHeight = Math.Max(1, (int)Math.Round(hostHeight * RenderScaling));
const int maxPixelCount = 1280 * 720;
var pixelCount = (long)pixelWidth * pixelHeight;
if (pixelCount > maxPixelCount)
{
var scale = Math.Sqrt((double)maxPixelCount / pixelCount);
pixelWidth = Math.Max(1, (int)Math.Round(pixelWidth * scale));
pixelHeight = Math.Max(1, (int)Math.Round(pixelHeight * scale));
}
var pitch = pixelWidth * 4;
var bufferSize = pitch * pixelHeight;
if (bufferSize <= 0)
{
return false;
}
if (pixelWidth == _previewVideoFrameWidth &&
pixelHeight == _previewVideoFrameHeight &&
_previewVideoFrameBufferPtr != IntPtr.Zero &&
_previewVideoBitmap is not null)
{
return true;
}
ReleasePreviewVideoRendererResources();
try
{
_previewVideoFrameWidth = pixelWidth;
_previewVideoFrameHeight = pixelHeight;
_previewVideoFramePitch = pitch;
_previewVideoFrameBufferSize = bufferSize;
_previewVideoFrameBufferPtr = Marshal.AllocHGlobal(_previewVideoFrameBufferSize);
_previewVideoStagingBuffer = new byte[_previewVideoFrameBufferSize];
_previewVideoBitmap = new WriteableBitmap(
new PixelSize(_previewVideoFrameWidth, _previewVideoFrameHeight),
new Vector(96, 96),
PixelFormat.Bgra8888,
AlphaFormat.Opaque);
EnsurePreviewVideoCallbacks();
_previewVideoWallpaperPlayer.SetVideoCallbacks(
_previewVideoLockCallback!,
_previewVideoUnlockCallback!,
_previewVideoDisplayCallback!);
_previewVideoWallpaperPlayer.SetVideoFormat(
"RV32",
(uint)_previewVideoFrameWidth,
(uint)_previewVideoFrameHeight,
(uint)_previewVideoFramePitch);
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
return true;
}
catch
{
ReleasePreviewVideoRendererResources();
return false;
}
}
private void EnsurePreviewVideoCallbacks()
{
_previewVideoLockCallback ??= OnPreviewVideoFrameLock;
_previewVideoUnlockCallback ??= OnPreviewVideoFrameUnlock;
_previewVideoDisplayCallback ??= OnPreviewVideoFrameDisplay;
}
private IntPtr OnPreviewVideoFrameLock(IntPtr opaque, IntPtr planes)
{
Monitor.Enter(_previewVideoFrameSync);
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
{
Marshal.WriteIntPtr(planes, IntPtr.Zero);
Monitor.Exit(_previewVideoFrameSync);
return IntPtr.Zero;
}
Marshal.WriteIntPtr(planes, _previewVideoFrameBufferPtr);
return IntPtr.Zero;
}
private void OnPreviewVideoFrameUnlock(IntPtr opaque, IntPtr picture, IntPtr planes)
{
if (Monitor.IsEntered(_previewVideoFrameSync))
{
Monitor.Exit(_previewVideoFrameSync);
}
}
private void OnPreviewVideoFrameDisplay(IntPtr opaque, IntPtr picture)
{
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 1);
}
private void PushPreviewVideoFrameToWallpaperImage()
{
if (Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0) == 0)
{
return;
}
if (_previewVideoBitmap is null ||
_previewVideoStagingBuffer is null ||
_previewVideoFrameBufferPtr == IntPtr.Zero ||
_previewVideoFrameBufferSize <= 0)
{
return;
}
lock (_previewVideoFrameSync)
{
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
{
return;
}
Marshal.Copy(_previewVideoFrameBufferPtr, _previewVideoStagingBuffer, 0, _previewVideoFrameBufferSize);
}
using var framebuffer = _previewVideoBitmap.Lock();
var rows = Math.Min(framebuffer.Size.Height, _previewVideoFrameHeight);
var bytesPerRow = Math.Min(framebuffer.RowBytes, _previewVideoFramePitch);
for (var row = 0; row < rows; row++)
{
var sourceOffset = row * _previewVideoFramePitch;
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
Marshal.Copy(_previewVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
}
if (!ReferenceEquals(WallpaperPreviewVideoImage.Source, _previewVideoBitmap))
{
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
}
if (_previewVideoSnapshotPending)
{
_previewVideoSnapshotPending = false;
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
StopPreviewVideoCapture(clearSnapshot: false);
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
}
}
private void ReleasePreviewVideoRendererResources()
{
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0);
WallpaperPreviewVideoImage.Source = null;
_previewVideoBitmap?.Dispose();
_previewVideoBitmap = null;
_previewVideoStagingBuffer = null;
_previewVideoFrameWidth = 0;
_previewVideoFrameHeight = 0;
_previewVideoFramePitch = 0;
_previewVideoFrameBufferSize = 0;
lock (_previewVideoFrameSync)
{
if (_previewVideoFrameBufferPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(_previewVideoFrameBufferPtr);
_previewVideoFrameBufferPtr = IntPtr.Zero;
}
} }
} }
@@ -437,7 +697,14 @@ public partial class SettingsWindow
try try
{ {
EnsureVideoWallpaperPlayers(); EnsureVideoWallpaperPlayers();
if (_previewVideoWallpaperPlayer is null || _libVlc is null || WallpaperPreviewVideoView is null) if (_previewVideoWallpaperPlayer is null || _libVlc is null)
{
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
StopVideoWallpaper();
return;
}
if (!ConfigurePreviewVideoRenderer())
{ {
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable."); _wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
StopVideoWallpaper(); StopVideoWallpaper();
@@ -447,8 +714,10 @@ public partial class SettingsWindow
_previewVideoWallpaperMedia?.Dispose(); _previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath)); _previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535"); _previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
_previewVideoSnapshotPending = true;
WallpaperPreviewVideoImage.IsVisible = false;
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia); _previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
WallpaperPreviewVideoView.IsVisible = true; StartPreviewVideoFrameRefreshTimer();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -459,14 +728,7 @@ public partial class SettingsWindow
private void StopVideoWallpaper() private void StopVideoWallpaper()
{ {
if (WallpaperPreviewVideoView is not null) StopPreviewVideoCapture(clearSnapshot: true);
{
WallpaperPreviewVideoView.IsVisible = false;
}
_previewVideoWallpaperPlayer?.Stop();
_previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = null;
} }
private void OnRecommendedColorClick(object? sender, RoutedEventArgs e) private void OnRecommendedColorClick(object? sender, RoutedEventArgs e)

View File

@@ -11,6 +11,7 @@ using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using FluentIcons.Common; using FluentIcons.Common;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
@@ -626,18 +627,18 @@ public partial class SettingsWindow
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C"); return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
} }
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight) private static FluentIcons.Common.Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
{ {
return weatherCode switch return weatherCode switch
{ {
0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny, 0 => isNight ? FluentIcons.Common.Symbol.WeatherMoon : FluentIcons.Common.Symbol.WeatherSunny,
1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay, 1 or 2 => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay,
3 or 7 => Symbol.WeatherRainShowersDay, 3 or 7 => FluentIcons.Common.Symbol.WeatherRainShowersDay,
8 or 9 => Symbol.WeatherRain, 8 or 9 => FluentIcons.Common.Symbol.WeatherRain,
4 => Symbol.WeatherThunderstorm, 4 => FluentIcons.Common.Symbol.WeatherThunderstorm,
13 or 14 or 15 or 16 => Symbol.WeatherSnow, 13 or 14 or 15 or 16 => FluentIcons.Common.Symbol.WeatherSnow,
18 or 32 => Symbol.WeatherFog, 18 or 32 => FluentIcons.Common.Symbol.WeatherFog,
_ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay _ => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay
}; };
} }
@@ -782,7 +783,7 @@ public partial class SettingsWindow
private void RenderLauncherHiddenItemsList() private void RenderLauncherHiddenItemsList()
{ {
LauncherHiddenItemsListPanel.Children.Clear(); LauncherHiddenItemsSettingsExpander.Items.Clear();
var hiddenItems = BuildLauncherHiddenItems(); var hiddenItems = BuildLauncherHiddenItems();
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0; LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
if (hiddenItems.Count == 0) if (hiddenItems.Count == 0)
@@ -792,7 +793,7 @@ public partial class SettingsWindow
foreach (var hiddenItem in hiddenItems) foreach (var hiddenItem in hiddenItems)
{ {
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem)); LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
} }
} }
@@ -864,82 +865,58 @@ public partial class SettingsWindow
return string.IsNullOrWhiteSpace(fileName) ? key : fileName; return string.IsNullOrWhiteSpace(fileName) ? key : fileName;
} }
private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem) private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
{ {
Control icon = hiddenItem.IconBitmap is not null
? new Image
{
Source = hiddenItem.IconBitmap,
Width = 24,
Height = 24,
Stretch = Stretch.Uniform
}
: new Border
{
Width = 24,
Height = 24,
CornerRadius = new CornerRadius(999),
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
Child = new TextBlock
{
Text = hiddenItem.Monogram,
FontSize = 10,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
? L("settings.launcher.hidden_type_folder", "Folder") ? L("settings.launcher.hidden_type_folder", "Folder")
: L("settings.launcher.hidden_type_shortcut", "Shortcut"); : L("settings.launcher.hidden_type_shortcut", "Shortcut");
var infoPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
infoPanel.Children.Add(icon);
infoPanel.Children.Add(new StackPanel
{
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Left,
Children =
{
new TextBlock { Text = hiddenItem.DisplayName, TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },
new TextBlock { Text = typeText, FontSize = 11, Opacity = 0.7 }
}
});
var restoreButton = new Button var restoreButton = new Button
{ {
Content = L("settings.launcher.restore_button", "Show Again"), Width = 36,
MinWidth = 110, Height = 36,
Padding = new Thickness(12, 6), Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key) Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
}; };
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
{
Symbol = FluentIcons.Common.Symbol.Eye,
IconVariant = FluentIcons.Common.IconVariant.Regular,
FontSize = 18,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
restoreButton.Click += OnRestoreLauncherHiddenItemClick; restoreButton.Click += OnRestoreLauncherHiddenItemClick;
var row = new Grid return new SettingsExpanderItem
{ {
ColumnDefinitions = new ColumnDefinitions("*,Auto"), Content = hiddenItem.DisplayName,
ColumnSpacing = 10 Description = typeText,
IconSource = CreateLauncherHiddenItemIconSource(hiddenItem),
IsClickEnabled = false,
Footer = restoreButton
}; };
row.Children.Add(infoPanel); }
Grid.SetColumn(infoPanel, 0);
row.Children.Add(restoreButton);
Grid.SetColumn(restoreButton, 1);
return new Border private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem)
{
if (hiddenItem.IconBitmap is not null)
{ {
Classes = { "glass-panel" }, return new ImageIconSource
BorderThickness = new Thickness(0), {
CornerRadius = new CornerRadius(14), Source = hiddenItem.IconBitmap
Padding = new Thickness(10, 8), };
Child = row }
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
? FluentIcons.Common.Symbol.Folder
: FluentIcons.Common.Symbol.Apps,
IconVariant = FluentIcons.Common.IconVariant.Regular
}; };
} }

View File

@@ -129,6 +129,20 @@ public partial class SettingsWindow : Window
private MediaPlayer? _previewVideoWallpaperPlayer; private MediaPlayer? _previewVideoWallpaperPlayer;
private Media? _previewVideoWallpaperMedia; private Media? _previewVideoWallpaperMedia;
private LibVLC? _libVlc; private LibVLC? _libVlc;
private readonly object _previewVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _previewVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _previewVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _previewVideoDisplayCallback;
private DispatcherTimer? _previewVideoFrameRefreshTimer;
private IntPtr _previewVideoFrameBufferPtr;
private byte[]? _previewVideoStagingBuffer;
private WriteableBitmap? _previewVideoBitmap;
private int _previewVideoFrameWidth;
private int _previewVideoFrameHeight;
private int _previewVideoFramePitch;
private int _previewVideoFrameBufferSize;
private int _previewVideoFrameDirtyFlag;
private bool _previewVideoSnapshotPending;
private string? _wallpaperPath; private string? _wallpaperPath;
private string _wallpaperStatus = "Current background uses solid color."; private string _wallpaperStatus = "Current background uses solid color.";
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>(); private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();

View File

@@ -0,0 +1,418 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintained by Zhenghan Yang
; Email: 847320916@QQ.com
; Translation based on network resource
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; If Language Name display incorrect, uncomment next line
; LanguageName=<7B80><4F53><4E2D><6587>
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; About CodePage, to reference link:
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** 应用程序标题
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** 启动错误消息
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序正在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序然后点击“确定”继续或点击“取消”退出。
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序然后点击“确定”继续或点击“取消”退出。
; *** 启动问题
PrivilegesRequiredOverrideTitle=选择安装程序模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
; *** 其他错误
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件因为里面包含太多文件
; *** 安装程序公共消息
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** 按钮
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** “选择语言”对话框消息
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** 公共向导文字
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** “欢迎”向导页
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** “密码”向导页
WizardPassword=密码
PasswordLabel1=这个安装程序有密码保护。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P)
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** “许可协议”向导页
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** “信息”向导页
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** “用户信息”向导页
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U)
UserInfoOrg=组织(&O)
UserInfoSerial=序列号(&S)
UserInfoNameRequired=您必须输入用户名。
; *** “选择目标目录”向导页
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗
; *** “选择组件”向导页
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** “选择附加任务”向导页
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** “选择开始菜单文件夹”向导页
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名。
GroupNameTooLong=文件夹名或路径太长。
InvalidGroupName=无效的文件夹名字。
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** “准备安装”向导页
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TExtractionWizardPage 向导页面与 ExtractArchive
ExtractingLabel=正在解压文件...
ButtonStopExtraction=停止解压(&S)
StopExtraction=您确定要停止解压吗?
ErrorExtractionAborted=解压已中止
ErrorExtractionFailed=解压失败:%1
; *** 压缩文件解压失败详情
ArchiveIncorrectPassword=压缩文件密码不正确
ArchiveIsCorrupted=压缩文件已损坏
ArchiveUnsupportedFormat=不支持的压缩文件格式
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止
ErrorDownloadFailed=下载失败:%1 %2
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
ErrorProgress=无效的进度:%1 / %2
ErrorFileSize=文件大小错误:预期 %1实际 %2
; *** “正在准备安装”向导页
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动
; *** “正在安装”向导页
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
; *** “安装完成”向导页
FinishedHeadingLabel=[name] 安装完成
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启电脑(&Y)
NoRadio=否,稍后重启电脑(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** “安装程序需要下一张磁盘”提示
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到请输入正确的路径或点击“浏览”。
PathLabel=路径(&P)
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** 安装阶段消息
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=关闭安装程序
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消(&C)
; *** 安装状态消息
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** 其他错误
ErrorInternal2=内部错误:%1
ErrorFunctionFailedNoCode=%1 失败
ErrorFunctionFailed=%1 失败;错误代码 %2
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** 注册表错误
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI 错误
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** 文件复制错误
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
SourceIsCorrupted=源文件已损坏
SourceDoesntExist=源文件“%1”不存在
SourceVerificationFailed=源文件验证失败: %1
VerificationSignatureDoesntExist=签名文件“%1”不存在
VerificationSignatureInvalid=签名文件“%1”无效
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
VerificationFileNameIncorrect=文件名不正确
VerificationFileTagIncorrect=文件标签不正确
VerificationFileSizeIncorrect=文件大小不正确
VerificationFileHashIncorrect=文件哈希值不正确
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
ErrorReadingExistingDest=尝试读取现有文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留现有的文件(&K)
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=下载文件时出错:
ErrorExtracting=解压压缩文件时出错:
ErrorReplacingExistingFile=尝试替换现有文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
ErrorRegisterTypeLib=无法注册类库:%1
; *** 卸载显示名字标记
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** 安装后错误
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
; *** 卸载消息
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载。
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的电脑中移除 %1请稍候。
UninstalledAll=已顺利从您的电脑中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
; *** 卸载状态消息
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件但仍有程序在使用这些文件则这些程序可能出现异常。如果您不能确定请选择“否”在系统中保留这些文件以免引发问题。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗

View File

@@ -1,6 +1,8 @@
#define MyAppName "LanMountainDesktop" #define MyAppName "LanMountainDesktop"
#define MyAppPublisher "LanMountainDesktop Team" #define MyAppPublisher "LanMountainDesktop Team"
#define MyAppExeName "LanMountainDesktop.exe" #define MyAppExeName "LanMountainDesktop.exe"
#define MyAppId "{{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
#define MyAppRegistryId "{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
#ifndef MyAppVersion #ifndef MyAppVersion
#define MyAppVersion "0.0.0" #define MyAppVersion "0.0.0"
@@ -19,13 +21,16 @@
#endif #endif
[Setup] [Setup]
AppId={{5A058B0D-F95D-4A18-B9A0-93F843655DDB} AppId={#MyAppId}
AppName={#MyAppName} AppName={#MyAppName}
AppVersion={#MyAppVersion} AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher} AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName} DefaultDirName={autopf}\{#MyAppName}
DisableDirPage=no DisableDirPage=no
UsePreviousAppDir=no UsePreviousAppDir=no
ShowLanguageDialog=yes
UsePreviousLanguage=no
LanguageDetectionMethod=uilanguage
DefaultGroupName={#MyAppName} DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName} UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir={#MyOutputDir} OutputDir={#MyOutputDir}
@@ -33,7 +38,12 @@ OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
Compression=lzma2/ultra64 Compression=lzma2/ultra64
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
; Leave PrivilegesRequiredOverridesAllowed unset so users cannot downgrade
; installation mode via dialog or /ALLUSERS /CURRENTUSER command-line switches.
PrivilegesRequired=admin PrivilegesRequired=admin
CloseApplications=yes
CloseApplicationsFilter={#MyAppExeName}
RestartApplications=no
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
#if MyAppArch == "x64" #if MyAppArch == "x64"
@@ -47,10 +57,55 @@ ArchitecturesAllowed=x86compatible
[Languages] [Languages]
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "{#SourcePath}\ChineseSimplified.isl"
[CustomMessages]
english.StartupTaskDescription=Launch LanMountainDesktop when you sign in to Windows
chinesesimplified.StartupTaskDescription=登录 Windows 时启动 LanMountainDesktop
english.WebView2MissingMessage=Microsoft Edge WebView2 Runtime is required for the browser component.
chinesesimplified.WebView2MissingMessage=浏览器组件需要 Microsoft Edge WebView2 Runtime。
english.WebView2MissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again.
chinesesimplified.WebView2MissingAction=单击“是”打开官方下载页面。请先完成安装,然后重新运行此安装程序。
english.WebView2OpenFailedMessage=Unable to open the download page automatically.
chinesesimplified.WebView2OpenFailedMessage=无法自动打开下载页面。
english.WebView2OpenFailedAction=Please open this URL manually:
chinesesimplified.WebView2OpenFailedAction=请手动打开以下链接:
english.UpgradePageCaption=Upgrade Existing Installation
chinesesimplified.UpgradePageCaption=升级现有安装
english.UpgradePageDescription=LanMountainDesktop is already installed on this computer. Choose how to upgrade it.
chinesesimplified.UpgradePageDescription=此计算机上已安装 LanMountainDesktop。请选择升级方式。
english.UpgradeDetectedVersionLabel=Detected version:
chinesesimplified.UpgradeDetectedVersionLabel=检测到的版本:
english.UpgradeCurrentLocationLabel=Current location:
chinesesimplified.UpgradeCurrentLocationLabel=当前安装位置:
english.UpgradePageSubCaption=Choose "Upgrade existing installation" to reuse the current location, or choose "Change installation location and migrate installation" to move the app without leaving a duplicate copy behind.
chinesesimplified.UpgradePageSubCaption=选择“升级现有安装”可复用当前安装位置;选择“更改安装位置并迁移安装”可移动应用,同时避免留下重复安装。
english.UpgradeOptionInPlace=Upgrade existing installation
chinesesimplified.UpgradeOptionInPlace=升级现有安装
english.UpgradeOptionRelocate=Change installation location and migrate installation
chinesesimplified.UpgradeOptionRelocate=更改安装位置并迁移安装
english.UpgradeUnknownVersion=Unknown
chinesesimplified.UpgradeUnknownVersion=未知
english.UpgradeCleanupMissingUninstaller=Setup found an existing installation, but its uninstaller is unavailable. Please uninstall the current version manually and run this installer again.
chinesesimplified.UpgradeCleanupMissingUninstaller=安装程序发现了现有安装,但无法找到它的卸载程序。请先手动卸载当前版本,再重新运行此安装程序。
english.UpgradeCleanupFailedPrefix=Setup could not remove the existing installation automatically. Error code:
chinesesimplified.UpgradeCleanupFailedPrefix=安装程序无法自动移除现有安装。错误代码:
english.UpgradeCleanupFailedSuffix=Please close LanMountainDesktop, uninstall the current version manually, and then run this installer again.
chinesesimplified.UpgradeCleanupFailedSuffix=请关闭 LanMountainDesktop手动卸载当前版本然后重新运行此安装程序。
[Tasks] [Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
Name: "startup"; Description: "Launch LanMountainDesktop when you sign in to Windows"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "startup"; Description: "{cm:StartupTaskDescription}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Dirs]
Name: "{app}\log"; Permissions: users-modify
[InstallDelete]
Type: files; Name: "{app}\LanMontainDesktop.exe"
Type: files; Name: "{app}\LanMontainDesktop.dll"
Type: files; Name: "{app}\LanMontainDesktop.deps.json"
Type: files; Name: "{app}\LanMontainDesktop.runtimeconfig.json"
Type: files; Name: "{app}\LanMontainDesktop.pdb"
[Files] [Files]
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
@@ -60,19 +115,316 @@ Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Registry] [Registry]
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
[Run] [Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[Code] [Code]
const const
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'; WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'; WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
UpgradeChoiceInPlace = 0;
UpgradeChoiceRelocate = 1;
var
UpgradeModePage: TInputOptionWizardPage;
ExistingInstallFound: Boolean;
ExistingInstallPath: String;
ExistingInstallVersion: String;
ExistingUninstallCommand: String;
ExistingInstallWas64Bit: Boolean;
ExistingInstallIsPerUser: Boolean;
ExistingInstallRemoved: Boolean;
function NormalizePathValue(const Value: String): String;
begin
Result := RemoveBackslashUnlessRoot(Trim(Value));
end;
function CombinePath(const BasePath: String; const ChildName: String): String;
begin
if BasePath = '' then
begin
Result := ChildName;
end
else
begin
Result := NormalizePathValue(BasePath) + '\' + ChildName;
end;
end;
function SamePath(const LeftPath: String; const RightPath: String): Boolean;
begin
Result := CompareText(NormalizePathValue(LeftPath), NormalizePathValue(RightPath)) = 0;
end;
function ExtractExecutableFromCommand(const CommandLine: String): String;
var
CommandText: String;
ClosingQuotePos: Integer;
ExePos: Integer;
begin
Result := '';
CommandText := Trim(CommandLine);
if CommandText = '' then
begin
exit;
end;
if CommandText[1] = '"' then
begin
Delete(CommandText, 1, 1);
ClosingQuotePos := Pos('"', CommandText);
if ClosingQuotePos > 0 then
begin
Result := Copy(CommandText, 1, ClosingQuotePos - 1);
end
else
begin
Result := CommandText;
end;
end
else
begin
ExePos := Pos('.exe', LowerCase(CommandText));
if ExePos > 0 then
begin
Result := Copy(CommandText, 1, ExePos + 3);
end
else
begin
Result := CommandText;
end;
end;
Result := NormalizePathValue(RemoveQuotes(Result));
end;
function GetExistingExecutablePath(): String;
begin
if ExistingInstallPath = '' then
begin
Result := '';
end
else
begin
Result := CombinePath(ExistingInstallPath, '{#MyAppExeName}');
end;
end;
function GetDefaultInstallPath(): String;
begin
Result := NormalizePathValue(ExpandConstant('{autopf}\{#MyAppName}'));
end;
function GetExistingInstallVersionText(): String;
begin
Result := Trim(ExistingInstallVersion);
if Result = '' then
begin
Result := CustomMessage('UpgradeUnknownVersion');
end;
end;
procedure ShowUpgradeCleanupError(const MessageText: String);
begin
Log(MessageText);
if not WizardSilent then
begin
MsgBox(MessageText, mbError, MB_OK);
end;
end;
function TryLoadExistingInstallation(
const RootKey: Integer;
const Is64BitView: Boolean;
const IsPerUser: Boolean): Boolean;
var
InstallLocation: String;
AppPath: String;
UninstallString: String;
DisplayVersion: String;
ResolvedPath: String;
begin
Result := False;
InstallLocation := '';
AppPath := '';
UninstallString := '';
DisplayVersion := '';
if not RegKeyExists(RootKey, UninstallRegSubkey) then
begin
exit;
end;
RegQueryStringValue(RootKey, UninstallRegSubkey, 'InstallLocation', InstallLocation);
RegQueryStringValue(RootKey, UninstallRegSubkey, 'Inno Setup: App Path', AppPath);
RegQueryStringValue(RootKey, UninstallRegSubkey, 'UninstallString', UninstallString);
RegQueryStringValue(RootKey, UninstallRegSubkey, 'DisplayVersion', DisplayVersion);
ResolvedPath := NormalizePathValue(InstallLocation);
if ResolvedPath = '' then
begin
ResolvedPath := NormalizePathValue(AppPath);
end;
if (ResolvedPath = '') and (UninstallString <> '') then
begin
ResolvedPath := NormalizePathValue(ExtractFileDir(ExtractExecutableFromCommand(UninstallString)));
end;
if (ResolvedPath = '') or
(not DirExists(ResolvedPath)) or
(not FileExists(CombinePath(ResolvedPath, '{#MyAppExeName}'))) then
begin
exit;
end;
ExistingInstallFound := True;
ExistingInstallPath := ResolvedPath;
ExistingInstallVersion := Trim(DisplayVersion);
ExistingUninstallCommand := Trim(UninstallString);
ExistingInstallWas64Bit := Is64BitView;
ExistingInstallIsPerUser := IsPerUser;
Result := True;
end;
procedure DetectExistingInstallation;
begin
ExistingInstallFound := False;
ExistingInstallPath := '';
ExistingInstallVersion := '';
ExistingUninstallCommand := '';
ExistingInstallWas64Bit := False;
ExistingInstallIsPerUser := False;
ExistingInstallRemoved := False;
if IsWin64 then
begin
if TryLoadExistingInstallation(HKLM64, True, False) then
begin
exit;
end;
if TryLoadExistingInstallation(HKCU64, True, True) then
begin
exit;
end;
end;
if TryLoadExistingInstallation(HKLM32, False, False) then
begin
exit;
end;
TryLoadExistingInstallation(HKCU32, False, True);
end;
function SelectedUpgradeChoice(): Integer;
begin
if UpgradeModePage <> nil then
begin
Result := UpgradeModePage.SelectedValueIndex;
end
else
begin
Result := UpgradeChoiceInPlace;
end;
end;
procedure ApplySelectedInstallDirectory;
var
CurrentDir: String;
begin
if not ExistingInstallFound then
begin
exit;
end;
if SelectedUpgradeChoice() = UpgradeChoiceInPlace then
begin
WizardForm.DirEdit.Text := ExistingInstallPath;
exit;
end;
CurrentDir := NormalizePathValue(WizardDirValue);
if (CurrentDir = '') or SamePath(CurrentDir, GetDefaultInstallPath()) then
begin
WizardForm.DirEdit.Text := ExistingInstallPath;
end;
end;
function GetSelectedInstallPath(): String;
begin
Result := NormalizePathValue(ExpandConstant('{app}'));
if Result = '' then
begin
Result := NormalizePathValue(WizardDirValue);
end;
if Result = '' then
begin
Result := ExistingInstallPath;
end;
end;
function ExistingInstallRequiresCleanup(): Boolean;
var
TargetPath: String;
begin
Result := False;
if not ExistingInstallFound or ExistingInstallRemoved then
begin
exit;
end;
TargetPath := GetSelectedInstallPath();
Result := ExistingInstallIsPerUser or
(not SamePath(TargetPath, ExistingInstallPath)) or
(ExistingInstallWas64Bit <> Is64BitInstallMode);
end;
function RemoveExistingInstallation(): Boolean;
var
UninstallerPath: String;
ResultCode: Integer;
begin
Result := True;
if not ExistingInstallRequiresCleanup() then
begin
exit;
end;
UninstallerPath := ExtractExecutableFromCommand(ExistingUninstallCommand);
if (UninstallerPath = '') or (not FileExists(UninstallerPath)) then
begin
ShowUpgradeCleanupError(CustomMessage('UpgradeCleanupMissingUninstaller'));
Result := False;
exit;
end;
ResultCode := -1;
if not Exec(
UninstallerPath,
'/VERYSILENT /SUPPRESSMSGBOXES /NORESTART',
ExtractFileDir(UninstallerPath),
SW_SHOWNORMAL,
ewWaitUntilTerminated,
ResultCode) or (ResultCode <> 0) then
begin
ShowUpgradeCleanupError(
CustomMessage('UpgradeCleanupFailedPrefix') + ' ' + IntToStr(ResultCode) + '. ' +
CustomMessage('UpgradeCleanupFailedSuffix'));
Result := False;
exit;
end;
ExistingInstallRemoved := True;
end;
function IsWebView2RuntimeInstalled(): Boolean; function IsWebView2RuntimeInstalled(): Boolean;
var var
VersionValue: string; VersionValue: String;
begin begin
Result := Result :=
RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or
@@ -92,16 +444,16 @@ begin
end; end;
if MsgBox( if MsgBox(
'Microsoft Edge WebView2 Runtime is required for the browser component.'#13#10#13#10 + CustomMessage('WebView2MissingMessage') + #13#10#13#10 +
'Click "Yes" to open the official download page. Install it first, then run this installer again.', CustomMessage('WebView2MissingAction'),
mbConfirmation, mbConfirmation,
MB_YESNO) = IDYES then MB_YESNO) = IDYES then
begin begin
if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
begin begin
MsgBox( MsgBox(
'Unable to open the download page automatically.'#13#10 + CustomMessage('WebView2OpenFailedMessage') + #13#10 +
'Please open this URL manually:'#13#10 + WebView2RuntimeDownloadUrl, CustomMessage('WebView2OpenFailedAction') + #13#10 + WebView2RuntimeDownloadUrl,
mbError, mbError,
MB_OK); MB_OK);
end; end;
@@ -109,3 +461,84 @@ begin
Result := False; Result := False;
end; end;
procedure InitializeWizard;
var
DetailsText: String;
begin
DetectExistingInstallation;
if not ExistingInstallFound then
begin
exit;
end;
DetailsText :=
CustomMessage('UpgradeDetectedVersionLabel') + ' ' + GetExistingInstallVersionText() + #13#10 +
CustomMessage('UpgradeCurrentLocationLabel') + ' ' + ExistingInstallPath + #13#10#13#10 +
CustomMessage('UpgradePageSubCaption');
UpgradeModePage := CreateInputOptionPage(
wpWelcome,
CustomMessage('UpgradePageCaption'),
CustomMessage('UpgradePageDescription'),
DetailsText,
True,
False);
UpgradeModePage.Add(CustomMessage('UpgradeOptionInPlace'));
UpgradeModePage.Add(CustomMessage('UpgradeOptionRelocate'));
UpgradeModePage.SelectedValueIndex := UpgradeChoiceInPlace;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
Result := True;
if (UpgradeModePage <> nil) and (CurPageID = UpgradeModePage.ID) then
begin
ApplySelectedInstallDirectory;
end;
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
Result := False;
if (UpgradeModePage <> nil) and (PageID = UpgradeModePage.ID) then
begin
Result := not ExistingInstallFound;
exit;
end;
if PageID = wpSelectDir then
begin
Result := ExistingInstallFound and (SelectedUpgradeChoice() = UpgradeChoiceInPlace);
end;
end;
procedure RegisterExtraCloseApplicationsResources;
var
ExistingExecutablePath: String;
begin
if not ExistingInstallFound then
begin
exit;
end;
ExistingExecutablePath := GetExistingExecutablePath();
if (ExistingExecutablePath <> '') and FileExists(ExistingExecutablePath) then
begin
RegisterExtraCloseApplicationsResource(False, ExistingExecutablePath);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
begin
if not RemoveExistingInstallation() then
begin
Abort;
end;
end;
end;

View File

@@ -9,6 +9,7 @@ public partial class MainWindow
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!; internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!; internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander ImportPluginPackageSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ImportPluginPackageSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!; internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!; internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
} }

View File

@@ -18,10 +18,14 @@ public partial class MainWindow
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins"); InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L( InstalledPluginsSettingsExpander.Description = L(
"settings.plugins.installed_desc", "settings.plugins.installed_desc",
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages."); "Review installed plugins and remove them here.");
ImportPluginPackageSettingsExpander.Header = L("settings.plugins.import_header", "Install From Package");
ImportPluginPackageSettingsExpander.Description = L(
"settings.plugins.import_desc",
"Open a .laapp package and stage it into the local plugin directory.");
PluginRestartHintTextBlock.Text = L( PluginRestartHintTextBlock.Text = L(
"settings.plugins.restart_hint", "settings.plugins.restart_hint",
"Plugin enable state changes take effect after restarting the app."); "Plugin installation and deletion changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found."); PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime(); PluginSettingsPanel.RefreshFromRuntime();
} }

View File

@@ -10,6 +10,7 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using LanMountainDesktop.Services;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Plugins; namespace LanMountainDesktop.Plugins;
@@ -346,6 +347,9 @@ public sealed class PluginLoader
private string ExtractPackage(string packagePath, string pluginsRootDirectory) private string ExtractPackage(string packagePath, string pluginsRootDirectory)
{ {
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath); var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
AppLogger.Info(
"PluginLoader",
$"Extracting package '{packagePath}' to '{extractionDirectory}'.");
RecreateDirectory(extractionDirectory); RecreateDirectory(extractionDirectory);
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true); ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
return extractionDirectory; return extractionDirectory;
@@ -381,7 +385,7 @@ public sealed class PluginLoader
{ {
if (Directory.Exists(directoryPath)) if (Directory.Exists(directoryPath))
{ {
Directory.Delete(directoryPath, recursive: true); FileOperationRetryHelper.DeleteDirectoryWithRetry(directoryPath, recursive: true, "PluginLoader");
} }
Directory.CreateDirectory(directoryPath); Directory.CreateDirectory(directoryPath);

View File

@@ -61,7 +61,10 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
public PluginMarketEmbeddedView(PluginRuntimeService runtime) public PluginMarketEmbeddedView(PluginRuntimeService runtime)
{ {
_runtime = runtime; _runtime = runtime;
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "Data", "AirAppMarket"); var dataDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"AirAppMarket");
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory)); _indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
_installService = new AirAppMarketInstallService(runtime, dataDirectory); _installService = new AirAppMarketInstallService(runtime, dataDirectory);
_readmeService = new AirAppMarketReadmeService(); _readmeService = new AirAppMarketReadmeService();

View File

@@ -13,6 +13,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketInstallService : IDisposable internal sealed class AirAppMarketInstallService : IDisposable
{ {
private readonly PluginRuntimeService _runtime; private readonly PluginRuntimeService _runtime;
private readonly PluginsInstallHelperClient _helperClient = new();
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService; private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService; private readonly AirAppMarketReleaseResolverService _releaseResolverService;
@@ -44,7 +45,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
try try
{ {
AppLogger.Info(
"PluginMarket",
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken); var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
AppLogger.Info(
"PluginMarket",
$"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'.");
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath)) if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
{ {
@@ -83,15 +90,46 @@ internal sealed class AirAppMarketInstallService : IDisposable
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}."); $"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
} }
var manifest = _runtime.InstallPluginPackage(downloadPath); PluginManifest manifest;
if (OperatingSystem.IsWindows())
{
var helperResult = await _helperClient.InstallPackageAsync(
downloadPath,
_runtime.PluginsDirectory,
cancellationToken);
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
{
return new AirAppMarketInstallResult(
false,
null,
helperResult.ErrorMessage ?? "Plugins install helper failed.");
}
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
}
else
{
manifest = _runtime.InstallPluginPackage(downloadPath);
}
AppLogger.Info(
"PluginMarket",
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
return new AirAppMarketInstallResult(true, manifest, null); return new AirAppMarketInstallResult(true, manifest, null);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
AppLogger.Warn(
"PluginMarket",
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
throw; throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Error(
"PluginMarket",
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.",
ex);
return new AirAppMarketInstallResult(false, null, ex.Message); return new AirAppMarketInstallResult(false, null, ex.Message);
} }
} }

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.Json;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
@@ -16,8 +17,11 @@ namespace LanMountainDesktop.Services;
public sealed class PluginRuntimeService : IDisposable public sealed class PluginRuntimeService : IDisposable
{ {
private const string PendingDeletionFileName = ".pending-plugin-deletions.json";
private readonly PluginLoader _loader; private readonly PluginLoader _loader;
private readonly AppSettingsService _appSettingsService = new(); private readonly AppSettingsService _appSettingsService = new();
private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService();
private readonly IServiceProvider _hostServices; private readonly IServiceProvider _hostServices;
private readonly IPluginPackageManager _packageManager; private readonly IPluginPackageManager _packageManager;
private readonly List<LoadedPlugin> _loadedPlugins = []; private readonly List<LoadedPlugin> _loadedPlugins = [];
@@ -25,12 +29,13 @@ public sealed class PluginRuntimeService : IDisposable
private readonly List<PluginCatalogEntry> _catalog = []; private readonly List<PluginCatalogEntry> _catalog = [];
private readonly List<PluginSettingsPageContribution> _settingsPages = []; private readonly List<PluginSettingsPageContribution> _settingsPages = [];
private readonly List<PluginDesktopComponentContribution> _desktopComponents = []; private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
private readonly object _packageMutationGate = new();
public PluginRuntimeService() public PluginRuntimeService()
{ {
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins"); PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
_packageManager = new PluginRuntimePackageManager(this); _packageManager = new PluginRuntimePackageManager(this);
_hostServices = new PluginHostServiceProvider(_packageManager); _hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle);
_loader = new PluginLoader(CreateOptions()); _loader = new PluginLoader(CreateOptions());
} }
@@ -49,6 +54,7 @@ public sealed class PluginRuntimeService : IDisposable
public void LoadInstalledPlugins() public void LoadInstalledPlugins()
{ {
Directory.CreateDirectory(PluginsDirectory); Directory.CreateDirectory(PluginsDirectory);
ApplyPendingPluginDeletions();
UnloadInstalledPlugins(); UnloadInstalledPlugins();
var disabledPluginIds = GetDisabledPluginIds(); var disabledPluginIds = GetDisabledPluginIds();
@@ -170,6 +176,7 @@ public sealed class PluginRuntimeService : IDisposable
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase) .OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
_appSettingsService.Save(snapshot); _appSettingsService.Save(snapshot);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
for (var i = 0; i < _catalog.Count; i++) for (var i = 0; i < _catalog.Count; i++)
{ {
@@ -184,7 +191,58 @@ public sealed class PluginRuntimeService : IDisposable
public PluginManifest InstallPluginPackage(string packagePath) public PluginManifest InstallPluginPackage(string packagePath)
{ {
return InstallPluginPackageCore(packagePath).Manifest; lock (_packageMutationGate)
{
return InstallPluginPackageCore(packagePath).Manifest;
}
}
public PluginManifest RegisterInstalledPluginPackage(string packagePath)
{
lock (_packageMutationGate)
{
return RegisterInstalledPluginPackageCore(packagePath);
}
}
public bool DeleteInstalledPlugin(string pluginId)
{
lock (_packageMutationGate)
{
return DeleteInstalledPluginCore(pluginId);
}
}
private bool DeleteInstalledPluginCore(string pluginId)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return false;
}
var entry = _catalog.FirstOrDefault(candidate =>
string.Equals(candidate.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
if (entry is null)
{
return false;
}
var targetPath = ResolvePluginRemovalTargetPath(entry);
if (string.IsNullOrWhiteSpace(targetPath))
{
return false;
}
var fullTargetPath = Path.GetFullPath(targetPath);
if (!TryDeletePluginTarget(fullTargetPath))
{
RegisterPendingPluginDeletion(fullTargetPath);
}
RemovePluginFromSnapshot(pluginId);
RemovePluginFromCatalog(pluginId);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
return true;
} }
internal IReadOnlyList<InstalledPluginInfo> GetInstalledPluginsSnapshot() internal IReadOnlyList<InstalledPluginInfo> GetInstalledPluginsSnapshot()
@@ -219,20 +277,45 @@ public sealed class PluginRuntimeService : IDisposable
Directory.CreateDirectory(PluginsDirectory); Directory.CreateDirectory(PluginsDirectory);
var manifest = ReadManifestFromPackage(fullPackagePath); var manifest = ReadManifestFromPackage(fullPackagePath);
AppLogger.Info(
"PluginRuntime",
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath); var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase)) if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
{ {
File.Copy(fullPackagePath, destinationPath, overwrite: true); FileOperationRetryHelper.CopyWithRetry(fullPackagePath, destinationPath, overwrite: true, "PluginRuntime");
} }
UpdateCatalogAfterPackageInstall(manifest, destinationPath); UpdateCatalogAfterPackageInstall(manifest, destinationPath);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true); PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
AppLogger.Info(
"PluginRuntime",
$"Package staged. PluginId='{manifest.Id}'; Destination='{destinationPath}'; ReplacedExisting={replacedExisting}.");
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true); return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
} }
private PluginManifest RegisterInstalledPluginPackageCore(string packagePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
var fullPackagePath = Path.GetFullPath(packagePath);
if (!File.Exists(fullPackagePath))
{
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
}
var manifest = ReadManifestFromPackage(fullPackagePath);
AppLogger.Info(
"PluginRuntime",
$"Registering externally installed package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'.");
UpdateCatalogAfterPackageInstall(manifest, fullPackagePath);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
return manifest;
}
public void Dispose() public void Dispose()
{ {
UnloadInstalledPlugins(); UnloadInstalledPlugins();
@@ -349,7 +432,7 @@ public sealed class PluginRuntimeService : IDisposable
continue; continue;
} }
File.Delete(existingPackagePath); FileOperationRetryHelper.DeleteFileWithRetry(existingPackagePath, "PluginRuntime");
replacedExisting = true; replacedExisting = true;
} }
catch catch
@@ -445,6 +528,150 @@ public sealed class PluginRuntimeService : IDisposable
} }
} }
private void ApplyPendingPluginDeletions()
{
var pendingPaths = ReadPendingPluginDeletions();
if (pendingPaths.Count == 0)
{
return;
}
var remainingPaths = new List<string>();
foreach (var path in pendingPaths)
{
if (!TryDeletePluginTarget(path))
{
remainingPaths.Add(path);
}
}
SavePendingPluginDeletions(remainingPaths);
}
private string ResolvePluginRemovalTargetPath(PluginCatalogEntry entry)
{
if (entry.IsPackage)
{
return entry.SourcePath;
}
var fullSourcePath = Path.GetFullPath(entry.SourcePath);
if (File.Exists(fullSourcePath) &&
string.Equals(Path.GetFileName(fullSourcePath), "plugin.json", StringComparison.OrdinalIgnoreCase))
{
return Path.GetDirectoryName(fullSourcePath) ?? fullSourcePath;
}
return fullSourcePath;
}
private static bool TryDeletePluginTarget(string targetPath)
{
try
{
if (File.Exists(targetPath))
{
File.Delete(targetPath);
}
else if (Directory.Exists(targetPath))
{
Directory.Delete(targetPath, recursive: true);
}
return !File.Exists(targetPath) && !Directory.Exists(targetPath);
}
catch
{
return false;
}
}
private void RegisterPendingPluginDeletion(string targetPath)
{
var pendingPaths = ReadPendingPluginDeletions();
if (pendingPaths.Contains(targetPath, StringComparer.OrdinalIgnoreCase))
{
return;
}
pendingPaths.Add(targetPath);
SavePendingPluginDeletions(pendingPaths);
}
private List<string> ReadPendingPluginDeletions()
{
var pendingDeletionFilePath = GetPendingDeletionFilePath();
if (!File.Exists(pendingDeletionFilePath))
{
return [];
}
try
{
var json = File.ReadAllText(pendingDeletionFilePath);
var paths = JsonSerializer.Deserialize<List<string>>(json);
return paths?
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
}
catch
{
return [];
}
}
private void SavePendingPluginDeletions(IEnumerable<string> pendingPaths)
{
var pendingDeletionFilePath = GetPendingDeletionFilePath();
var normalizedPaths = pendingPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalizedPaths.Length == 0)
{
if (File.Exists(pendingDeletionFilePath))
{
File.Delete(pendingDeletionFilePath);
}
return;
}
Directory.CreateDirectory(PluginsDirectory);
var json = JsonSerializer.Serialize(normalizedPaths, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(pendingDeletionFilePath, json);
}
private string GetPendingDeletionFilePath()
{
return Path.Combine(PluginsDirectory, PendingDeletionFileName);
}
private void RemovePluginFromSnapshot(string pluginId)
{
var snapshot = _appSettingsService.Load();
if (snapshot.DisabledPluginIds.RemoveAll(id => string.Equals(id, pluginId, StringComparison.OrdinalIgnoreCase)) > 0)
{
_appSettingsService.Save(snapshot);
}
}
private void RemovePluginFromCatalog(string pluginId)
{
_catalog.RemoveAll(entry => string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
_settingsPages.RemoveAll(entry => string.Equals(entry.Plugin.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
_desktopComponents.RemoveAll(entry => string.Equals(entry.Plugin.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
_loadResults.RemoveAll(entry => string.Equals(entry.Manifest?.Id, pluginId, StringComparison.OrdinalIgnoreCase));
}
private sealed record PluginCandidate( private sealed record PluginCandidate(
string SourcePath, string SourcePath,
PluginManifest Manifest, PluginManifest Manifest,
@@ -453,17 +680,29 @@ public sealed class PluginRuntimeService : IDisposable
private sealed class PluginHostServiceProvider : IServiceProvider private sealed class PluginHostServiceProvider : IServiceProvider
{ {
private readonly IPluginPackageManager _packageManager; private readonly IPluginPackageManager _packageManager;
private readonly IHostApplicationLifecycle _applicationLifecycle;
public PluginHostServiceProvider(IPluginPackageManager packageManager) public PluginHostServiceProvider(
IPluginPackageManager packageManager,
IHostApplicationLifecycle applicationLifecycle)
{ {
_packageManager = packageManager; _packageManager = packageManager;
_applicationLifecycle = applicationLifecycle;
} }
public object? GetService(Type serviceType) public object? GetService(Type serviceType)
{ {
return serviceType == typeof(IPluginPackageManager) if (serviceType == typeof(IPluginPackageManager))
? _packageManager {
: null; return _packageManager;
}
if (serviceType == typeof(IHostApplicationLifecycle))
{
return _applicationLifecycle;
}
return null;
} }
} }

View File

@@ -8,6 +8,8 @@ using Avalonia.Controls;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using FluentIcons.Avalonia.Fluent;
using FluentIcons.Common;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
@@ -18,6 +20,7 @@ public partial class PluginSettingsPage : UserControl
{ {
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E")); private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C")); private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
private static readonly IBrush DestructiveBrush = new SolidColorBrush(Color.Parse("#FFF87171"));
private readonly AppSettingsService _appSettingsService = new(); private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new(); private readonly LocalizationService _localizationService = new();
@@ -38,8 +41,9 @@ public partial class PluginSettingsPage : UserControl
{ {
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available."); PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
PluginRuntimeSummaryPanel.Children.Clear(); PluginRuntimeSummaryPanel.Children.Clear();
PluginCatalogItemsHost.Children.Clear(); InstalledPluginsSettingsExpander.Items.Clear();
PluginRestartHintTextBlock.IsVisible = false; PluginRestartHintTextBlock.IsVisible = false;
PluginCatalogEmptyTextBlock.IsVisible = false;
return; return;
} }
@@ -74,7 +78,7 @@ public partial class PluginSettingsPage : UserControl
"Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.", "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
runtime.Catalog.Count, runtime.Catalog.Count,
enabledCount, enabledCount,
runtime.LoadedPlugins.Count, runtime.Catalog.Count(entry => entry.IsLoaded),
runtime.SettingsPages.Count, runtime.SettingsPages.Count,
runtime.DesktopComponents.Count, runtime.DesktopComponents.Count,
failures.Length); failures.Length);
@@ -99,7 +103,7 @@ public partial class PluginSettingsPage : UserControl
private void BuildPluginCatalog(PluginRuntimeService runtime) private void BuildPluginCatalog(PluginRuntimeService runtime)
{ {
PluginCatalogItemsHost.Children.Clear(); InstalledPluginsSettingsExpander.Items.Clear();
var plugins = runtime.Catalog var plugins = runtime.Catalog
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase) .OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
@@ -110,86 +114,20 @@ public partial class PluginSettingsPage : UserControl
foreach (var plugin in plugins) foreach (var plugin in plugins)
{ {
PluginCatalogItemsHost.Children.Add(CreatePluginCatalogItem(runtime, plugin)); InstalledPluginsSettingsExpander.Items.Add(CreatePluginCatalogItem(runtime, plugin));
} }
} }
private Control CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry) private SettingsExpanderItem CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry)
{ {
var title = new TextBlock return new SettingsExpanderItem
{ {
Text = entry.Manifest.Name, Content = entry.Manifest.Name,
FontSize = 16, Description = BuildPluginSubtitle(entry),
FontWeight = FontWeight.SemiBold, IconSource = CreatePluginCatalogIconSource(),
TextWrapping = TextWrapping.Wrap IsClickEnabled = false,
Footer = CreatePluginCatalogActions(runtime, entry)
}; };
var subtitle = new TextBlock
{
Text = BuildPluginSubtitle(entry),
Foreground = PluginSystemDescriptionTextBlock.Foreground,
TextWrapping = TextWrapping.Wrap
};
var enabledToggle = new ToggleSwitch
{
IsChecked = entry.IsEnabled,
OnContent = L("settings.plugins.toggle_on", "Enabled"),
OffContent = L("settings.plugins.toggle_off", "Disabled"),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
};
enabledToggle.IsCheckedChanged += (_, _) => OnPluginEnableChanged(runtime, entry, enabledToggle.IsChecked == true);
var header = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 12,
Children =
{
new StackPanel
{
Spacing = 4,
Children = { title, subtitle }
},
enabledToggle
}
};
Grid.SetColumn(enabledToggle, 1);
var details = new TextBlock
{
Text = BuildPluginDetails(entry),
Foreground = PluginSystemDescriptionTextBlock.Foreground,
TextWrapping = TextWrapping.Wrap
};
return new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 10,
Children = { header, details }
}
};
}
private void OnPluginEnableChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled)
{
runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled);
BuildRuntimeSummary(runtime);
BuildPluginCatalog(runtime);
PluginSystemStatusTextBlock.Text = F(
"settings.plugins.toggle_result_format",
"Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
entry.Manifest.Name,
isEnabled
? L("settings.plugins.toggle_state_enabled", "enabled")
: L("settings.plugins.toggle_state_disabled", "disabled"));
} }
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -247,6 +185,7 @@ public partial class PluginSettingsPage : UserControl
var manifest = runtime.InstallPluginPackage(temporaryPackagePath); var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
RefreshFromRuntime(); RefreshFromRuntime();
RefreshPluginNavigation(TopLevel.GetTopLevel(this));
SetPackageImportStatus( SetPackageImportStatus(
F( F(
"settings.plugins.install_success_format", "settings.plugins.install_success_format",
@@ -279,6 +218,79 @@ public partial class PluginSettingsPage : UserControl
} }
} }
private void OnDeletePluginClick(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
try
{
if (!runtime.DeleteInstalledPlugin(entry.Manifest.Id))
{
SetPackageImportStatus(
F(
"settings.plugins.delete_failed_format",
"Failed to delete plugin: {0}",
entry.Manifest.Name),
isError: true);
return;
}
RefreshFromRuntime();
RefreshPluginNavigation(TopLevel.GetTopLevel(this));
PluginSystemStatusTextBlock.Text = F(
"settings.plugins.delete_success_format",
"Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
entry.Manifest.Name);
SetPackageImportStatus(
F(
"settings.plugins.delete_success_format",
"Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
entry.Manifest.Name),
isError: false);
}
catch (Exception ex)
{
SetPackageImportStatus(
F(
"settings.plugins.delete_failed_detail_format",
"Failed to delete plugin '{0}': {1}",
entry.Manifest.Name,
ex.Message),
isError: true);
}
}
private void OnPluginEnabledChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled)
{
try
{
if (!runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled))
{
return;
}
RefreshFromRuntime();
var toggleState = isEnabled
? L("settings.plugins.toggle_state_enabled", "enabled")
: L("settings.plugins.toggle_state_disabled", "disabled");
SetPackageImportStatus(
F(
"settings.plugins.toggle_result_format",
"Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
entry.Manifest.Name,
toggleState),
isError: false);
}
catch (Exception ex)
{
SetPackageImportStatus(
F(
"settings.plugins.toggle_failed_detail_format",
"Failed to update plugin '{0}': {1}",
entry.Manifest.Name,
ex.Message),
isError: true);
}
}
private void RefreshPluginNavigation(TopLevel? topLevel) private void RefreshPluginNavigation(TopLevel? topLevel)
{ {
switch (topLevel) switch (topLevel)
@@ -301,32 +313,13 @@ public partial class PluginSettingsPage : UserControl
private string BuildPluginSubtitle(PluginCatalogEntry entry) private string BuildPluginSubtitle(PluginCatalogEntry entry)
{ {
var source = entry.IsPackage var publisher = string.IsNullOrWhiteSpace(entry.Manifest.Author)
? L("settings.plugins.source_package", ".laapp package") ? L("settings.plugins.publisher_unknown", "Unknown publisher")
: L("settings.plugins.source_manifest", "Loose manifest"); : entry.Manifest.Author;
var state = entry.IsEnabled
? entry.IsLoaded
? L("settings.plugins.state.loaded", "Loaded")
: L("settings.plugins.state.load_failed", "Load failed")
: L("settings.plugins.state.disabled", "Disabled");
return F( return F(
"settings.plugins.subtitle_format", "settings.plugins.publisher_format",
"{0} | {1} | {2}", "Publisher: {0}",
state, publisher);
source,
entry.Manifest.Id);
}
private string BuildPluginDetails(PluginCatalogEntry entry)
{
var detail = F(
"settings.plugins.detail_format",
"Settings pages: {0} | Widgets: {1}",
entry.SettingsPageCount,
entry.WidgetCount);
return string.IsNullOrWhiteSpace(entry.ErrorMessage)
? detail
: detail + Environment.NewLine + entry.ErrorMessage;
} }
private TextBlock CreateSummaryLine(string text) private TextBlock CreateSummaryLine(string text)
@@ -350,6 +343,75 @@ public partial class PluginSettingsPage : UserControl
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args); return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
} }
private FluentIcons.Avalonia.Fluent.SymbolIconSource CreatePluginCatalogIconSource()
{
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = FluentIcons.Common.Symbol.PuzzlePiece,
IconVariant = FluentIcons.Common.IconVariant.Regular
};
}
private Control CreatePluginCatalogActions(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
CreateEnablePluginToggle(runtime, entry),
CreateDeletePluginButton(runtime, entry)
}
};
}
private ToggleSwitch CreateEnablePluginToggle(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
var toggle = new ToggleSwitch
{
IsChecked = entry.IsEnabled,
VerticalAlignment = VerticalAlignment.Center
};
ToolTip.SetTip(
toggle,
entry.IsEnabled
? L("settings.plugins.toggle_off", "Disable")
: L("settings.plugins.toggle_on", "Enable"));
toggle.IsCheckedChanged += (_, _) => OnPluginEnabledChanged(runtime, entry, toggle.IsChecked == true);
return toggle;
}
private Button CreateDeletePluginButton(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
var button = new Button
{
Width = 36,
Height = 36,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
{
Symbol = FluentIcons.Common.Symbol.Delete,
IconVariant = FluentIcons.Common.IconVariant.Regular,
FontSize = 18,
Foreground = DestructiveBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
ToolTip.SetTip(button, L("settings.plugins.delete_button", "Delete plugin"));
button.Click += (_, _) => OnDeletePluginClick(runtime, entry);
return button;
}
private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file) private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
{ {
try try

View File

@@ -44,31 +44,13 @@
<Border Classes="settings-expander-shell"> <Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="InstalledPluginsSettingsExpander" <ui:SettingsExpander x:Name="InstalledPluginsSettingsExpander"
Header="Installed Plugins" Header="Installed Plugins"
Description="Enable plugins here. Detailed settings appear as separate settings pages." Description="Manage installed plugins here."
IsExpanded="True"> IsExpanded="True">
<ui:SettingsExpander.IconSource> <ui:SettingsExpander.IconSource>
<ui:FontIconSource Glyph="&#xe8fd;" FontFamily="{StaticResource SymbolThemeFontFamily}" /> <ui:FontIconSource Glyph="&#xe8fd;" FontFamily="{StaticResource SymbolThemeFontFamily}" />
</ui:SettingsExpander.IconSource> </ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer> <ui:SettingsExpander.Footer>
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Padding="14">
<StackPanel Spacing="10">
<Button x:Name="InstallPluginPackageButton"
HorizontalAlignment="Left"
Click="OnInstallPluginPackageClick"
Content="Open .laapp package" />
<TextBlock x:Name="PluginPackageImportHintTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Open a .laapp package to install it into the local plugin directory." />
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
IsVisible="False" />
</StackPanel>
</Border>
<TextBlock x:Name="PluginRestartHintTextBlock" <TextBlock x:Name="PluginRestartHintTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap" TextWrapping="Wrap"
@@ -77,7 +59,33 @@
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No plugins found." Text="No plugins found."
IsVisible="False" /> IsVisible="False" />
<StackPanel x:Name="PluginCatalogItemsHost" Spacing="12" /> </StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="ImportPluginPackageSettingsExpander"
Header="Install From Package"
Description="Open a .laapp package and stage it into the local plugin directory."
IsExpanded="False">
<ui:SettingsExpander.IconSource>
<ui:FontIconSource Glyph="&#xe8b7;" FontFamily="{StaticResource SymbolThemeFontFamily}" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<Button x:Name="InstallPluginPackageButton"
HorizontalAlignment="Left"
Click="OnInstallPluginPackageClick"
Content="Open .laapp package" />
<TextBlock x:Name="PluginPackageImportHintTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Open a .laapp package to install it into the local plugin directory." />
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
IsVisible="False" />
</StackPanel> </StackPanel>
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>

View File

@@ -9,6 +9,7 @@ public partial class SettingsWindow
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!; internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!; internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander ImportPluginPackageSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ImportPluginPackageSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!; internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!; internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
} }

View File

@@ -10,8 +10,10 @@ public partial class SettingsWindow
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins."); PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes."); PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins"); InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages."); InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Review installed plugins and remove them here.");
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app."); ImportPluginPackageSettingsExpander.Header = L("settings.plugins.import_header", "Install From Package");
ImportPluginPackageSettingsExpander.Description = L("settings.plugins.import_desc", "Open a .laapp package and stage it into the local plugin directory.");
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin installation and deletion changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found."); PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime(); PluginSettingsPanel.RefreshFromRuntime();
} }

View File

@@ -141,6 +141,37 @@ function Create-PackageArchive {
return $archivePath return $archivePath
} }
function Clear-DirectoryContents {
param([Parameter(Mandatory = $true)][string]$TargetDirectory)
[System.IO.Directory]::CreateDirectory($TargetDirectory) | Out-Null
Get-ChildItem -LiteralPath $TargetDirectory -Force -ErrorAction SilentlyContinue | ForEach-Object {
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop
}
}
function Remove-LegacyOutputArtifacts {
param([Parameter(Mandatory = $true)][string]$TargetDirectory)
$legacyArtifacts = @(
"LanMontainDesktop.exe",
"LanMontainDesktop.dll",
"LanMontainDesktop.deps.json",
"LanMontainDesktop.runtimeconfig.json",
"LanMontainDesktop.pdb"
)
foreach ($artifactName in $legacyArtifacts) {
$artifactPath = Join-Path $TargetDirectory $artifactName
if (-not (Test-Path -LiteralPath $artifactPath)) {
continue
}
Remove-Item -LiteralPath $artifactPath -Force -ErrorAction Stop
Write-Host "Removed legacy artifact: $artifactPath"
}
}
function Add-LinuxDesktopAssets { function Add-LinuxDesktopAssets {
param( param(
[Parameter(Mandatory = $true)][string]$PublishedDirectory, [Parameter(Mandatory = $true)][string]$PublishedDirectory,
@@ -187,7 +218,7 @@ if (-not $PublishDir) {
if (-not [System.IO.Path]::IsPathRooted($PublishDir)) { if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
$PublishDir = Join-Path $repoRoot $PublishDir $PublishDir = Join-Path $repoRoot $PublishDir
} }
[System.IO.Directory]::CreateDirectory($PublishDir) | Out-Null Clear-DirectoryContents -TargetDirectory $PublishDir
Write-Host "Publishing project..." Write-Host "Publishing project..."
$publishArgs = @( $publishArgs = @(
@@ -210,6 +241,7 @@ if ($LASTEXITCODE -ne 0) {
} }
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
if ($RuntimeIdentifier -like "linux-*") { if ($RuntimeIdentifier -like "linux-*") {
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot