Compare commits

...

2 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
35 changed files with 1558 additions and 150 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.widget_context.title": "PluginDesktopComponentContext",
"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.placement": "Placement {0} | placed: {1}",
"common.dev": "dev",

View File

@@ -16,6 +16,7 @@ public sealed class SamplePlugin : PluginBase, IDisposable
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
var hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available.");
@@ -74,6 +75,19 @@ public sealed class SamplePlugin : PluginBase, IDisposable
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional,
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()

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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginsInstallHelper", "LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj", "{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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}.Release|Any CPU.ActiveCfg = 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
EndGlobal

View File

@@ -12,6 +12,7 @@ using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
using AvaloniaWebView;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop;
@@ -19,12 +20,19 @@ public partial class App : Application
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private bool _exitCleanupCompleted;
private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
public override void Initialize()
{
@@ -51,14 +59,14 @@ public partial class App : Application
desktop.Exit += (_, _) =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
DisposeTrayIcon();
PerformExitCleanup();
};
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
}
base.OnFrameworkInitializationCompleted();
@@ -66,12 +74,9 @@ public partial class App : Application
private void OnTrayExitClick(object? sender, EventArgs e)
{
DisposeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Exit App from the tray menu."));
}
private void OnTraySettingsClick(object? sender, EventArgs e)
@@ -114,18 +119,9 @@ public partial class App : Application
private void OnTrayRestartClick(object? sender, EventArgs e)
{
AppRestartService.TryRestartApplication();
}
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Restart App from the tray menu."));
}
private void DisableAvaloniaDataAnnotationValidation()
@@ -246,6 +242,95 @@ public partial class App : Application
_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)
{
var snapshot = _appSettingsService.Load();

View File

@@ -26,6 +26,8 @@
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
@@ -56,4 +58,22 @@
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</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>

View File

@@ -251,6 +251,7 @@
"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_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.about.title": "About",
"settings.about.version_format": "Version: {0}",

View File

@@ -251,6 +251,7 @@
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
"settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}",

View File

@@ -17,6 +17,15 @@ sealed class Program
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);
@@ -24,6 +33,7 @@ sealed class Program
{
var renderMode = LoadConfiguredRenderMode();
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
@@ -32,6 +42,10 @@ sealed class Program
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
throw;
}
finally
{
App.CurrentSingleInstanceService = null;
}
}
// Avalonia configuration, don't remove; also used by visual designer.
@@ -71,6 +85,16 @@ sealed class Program
}
}
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) =>

View File

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

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();
}
public static void DisposeSharedServices()
{
if (SharedRecorderService.IsValueCreated)
{
SharedRecorderService.Value.Dispose();
}
if (SharedStudyMonitoringService.IsValueCreated)
{
SharedStudyMonitoringService.Value.Dispose();
}
}
}
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService

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

@@ -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

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

View File

@@ -6,9 +6,20 @@ 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())
{
@@ -17,11 +28,11 @@ internal static class WindowsNativeDialogService
try
{
_ = MessageBoxW(IntPtr.Zero, message, caption, Ok | IconWarning);
_ = MessageBoxW(IntPtr.Zero, message, caption, type);
}
catch (Exception ex)
{
AppLogger.Warn("StartupDiagnostics", "Failed to show legacy executable warning dialog.", ex);
AppLogger.Warn(logCategory, "Failed to show native dialog.", ex);
}
}

View File

@@ -1195,11 +1195,22 @@ public partial class MainWindow
var restoreButton = new Button
{
Content = L("settings.launcher.restore_button", "Unhide"),
MinWidth = 110,
Padding = new Thickness(12, 6),
Width = 36,
Height = 36,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
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;
return new SettingsExpanderItem

View File

@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -77,7 +78,9 @@ public partial class MainWindow
var result = await dialog.ShowAsync(this);
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();
}

View File

@@ -158,6 +158,7 @@ public partial class MainWindow
}
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
UpdateVideoWallpaperPreviewVisibility();
}
private void OnNightModeChecked(object? sender, RoutedEventArgs e)
@@ -344,6 +345,7 @@ public partial class MainWindow
var placement = GetSelectedWallpaperPlacement();
DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false);
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
UpdateVideoWallpaperPreviewVisibility();
}
private void UpdateWallpaperDisplay()
@@ -628,12 +630,99 @@ public partial class MainWindow
EnableHardwareDecoding = false
};
}
}
if (_previewVideoWallpaperPlayer is null && WallpaperPreviewVideoView is not null)
private void EnsureDesktopVideoFrameRefreshTimer()
{
if (_desktopVideoFrameRefreshTimer is not null)
{
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc);
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
return;
}
_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()
@@ -685,6 +774,7 @@ public partial class MainWindow
(uint)_desktopVideoFrameHeight,
(uint)_desktopVideoFramePitch);
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
InvalidateVideoWallpaperPreviewSnapshot();
return true;
}
catch
@@ -745,31 +835,6 @@ public partial class MainWindow
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
{
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()
@@ -812,18 +877,22 @@ public partial class MainWindow
{
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
}
CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer();
}
private void ReleaseDesktopVideoRendererResources()
{
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
if (DesktopVideoWallpaperImage is not null)
{
DesktopVideoWallpaperImage.Source = null;
}
InvalidateVideoWallpaperPreviewSnapshot();
WallpaperPreviewVideoImage.Source = null;
_desktopVideoBitmap?.Dispose();
_desktopVideoBitmap = null;
_desktopVideoStagingBuffer = null;
@@ -855,10 +924,8 @@ public partial class MainWindow
{
EnsureVideoWallpaperPlayers();
if (_videoWallpaperPlayer is null ||
_previewVideoWallpaperPlayer is null ||
_libVlc is null ||
DesktopVideoWallpaperImage is null ||
WallpaperPreviewVideoView is null)
DesktopVideoWallpaperImage is null)
{
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
StopVideoWallpaper();
@@ -873,15 +940,13 @@ public partial class MainWindow
}
_videoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia?.Dispose();
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_videoWallpaperMedia.AddOption(":input-repeat=65535");
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
InvalidateVideoWallpaperPreviewSnapshot();
_videoWallpaperPlayer.Play(_videoWallpaperMedia);
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
StartDesktopVideoFrameRefreshTimer();
DesktopVideoWallpaperImage.IsVisible = true;
WallpaperPreviewVideoView.IsVisible = true;
UpdateVideoWallpaperPreviewVisibility();
}
catch (Exception ex)
{
@@ -897,26 +962,17 @@ public partial class MainWindow
DesktopVideoWallpaperImage.IsVisible = false;
}
if (WallpaperPreviewVideoView is not null)
{
WallpaperPreviewVideoView.IsVisible = false;
}
WallpaperPreviewVideoImage.IsVisible = false;
if (_videoWallpaperPlayer is not null)
{
_videoWallpaperPlayer.Stop();
}
if (_previewVideoWallpaperPlayer is not null)
{
_previewVideoWallpaperPlayer.Stop();
}
StopDesktopVideoFrameRefreshTimer();
ReleaseDesktopVideoRendererResources();
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null;
_previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = null;
}
private void PersistSettings()
@@ -2379,7 +2435,14 @@ public partial class MainWindow
_isSettingsOpen = true;
UpdateDesktopPageAwareComponentContext();
UpdateAdaptiveTextSystem();
ApplyWallpaperBrush();
if (_wallpaperMediaType == WallpaperMediaType.Video)
{
UpdateVideoWallpaperPreviewVisibility();
}
else
{
ApplyWallpaperBrush();
}
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
if (_settingsContentPanelTransform is not null)
{
@@ -2387,6 +2450,7 @@ public partial class MainWindow
}
SettingsPage.IsVisible = true;
SettingsPage.Opacity = 0;
UpdateVideoWallpaperPreviewVisibility();
UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize));
UpdateWallpaperPreviewLayout();
@@ -2416,7 +2480,11 @@ public partial class MainWindow
_isSettingsOpen = false;
UpdateDesktopPageAwareComponentContext();
UpdateAdaptiveTextSystem();
ApplyWallpaperBrush();
UpdateVideoWallpaperPreviewVisibility();
if (_wallpaperMediaType != WallpaperMediaType.Video)
{
ApplyWallpaperBrush();
}
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
if (immediate)
@@ -2608,7 +2676,7 @@ public partial class MainWindow
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
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 Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;

View File

@@ -1,11 +1,12 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -357,7 +358,8 @@ public partial class MainWindow
{
FileName = installerPath,
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
UseShellExecute = true
UseShellExecute = true,
Verb = "runas"
});
_updateStatusText = L(
@@ -365,7 +367,16 @@ public partial class MainWindow
"Installer started. The app will close for update.");
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)
{

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using LibVLCSharp.Avalonia;
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
@@ -14,7 +13,7 @@ public partial class SettingsWindow
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
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 Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;

View File

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

View File

@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -77,7 +78,9 @@ public partial class SettingsWindow
var result = await dialog.ShowAsync(this);
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();
}

View File

@@ -1,13 +1,12 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -250,23 +249,23 @@ public partial class SettingsWindow
{
FileName = installerPath,
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.");
UpdateUpdatePanelState();
Dispatcher.UIThread.Post(() =>
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
else
{
Close();
}
}, DispatcherPriority.Background);
_ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
Source: nameof(SettingsWindow),
Reason: "Update installer started successfully from settings."));
}
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)
{

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
@@ -12,6 +13,7 @@ using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Styling;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
using LibVLCSharp.Shared;
@@ -157,7 +159,7 @@ public partial class SettingsWindow
{
DesktopWallpaperLayer.Background = Brushes.Transparent;
WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground();
PlayVideoWallpaper(_wallpaperVideoPath);
SyncVideoWallpaperPreviewPlayback();
return;
}
@@ -175,6 +177,36 @@ public partial class SettingsWindow
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()
{
WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath)
@@ -417,11 +449,239 @@ public partial class SettingsWindow
private void EnsureVideoWallpaperPlayers()
{
Core.Initialize();
_libVlc ??= new LibVLC();
_previewVideoWallpaperPlayer ??= new MediaPlayer(_libVlc);
if (WallpaperPreviewVideoView is not null)
_libVlc ??= new LibVLC("--quiet");
if (_previewVideoWallpaperPlayer is 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
{
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.");
StopVideoWallpaper();
@@ -447,8 +714,10 @@ public partial class SettingsWindow
_previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
_previewVideoSnapshotPending = true;
WallpaperPreviewVideoImage.IsVisible = false;
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
WallpaperPreviewVideoView.IsVisible = true;
StartPreviewVideoFrameRefreshTimer();
}
catch (Exception ex)
{
@@ -459,14 +728,7 @@ public partial class SettingsWindow
private void StopVideoWallpaper()
{
if (WallpaperPreviewVideoView is not null)
{
WallpaperPreviewVideoView.IsVisible = false;
}
_previewVideoWallpaperPlayer?.Stop();
_previewVideoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia = null;
StopPreviewVideoCapture(clearSnapshot: true);
}
private void OnRecommendedColorClick(object? sender, RoutedEventArgs e)

View File

@@ -873,11 +873,22 @@ public partial class SettingsWindow
var restoreButton = new Button
{
Content = L("settings.launcher.restore_button", "Unhide"),
MinWidth = 110,
Padding = new Thickness(12, 6),
Width = 36,
Height = 36,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
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;
return new SettingsExpanderItem

View File

@@ -129,6 +129,20 @@ public partial class SettingsWindow : Window
private MediaPlayer? _previewVideoWallpaperPlayer;
private Media? _previewVideoWallpaperMedia;
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 _wallpaperStatus = "Current background uses solid color.";
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();

View File

@@ -38,6 +38,8 @@ OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
; Leave PrivilegesRequiredOverridesAllowed unset so users cannot downgrade
; installation mode via dialog or /ALLUSERS /CURRENTUSER command-line switches.
PrivilegesRequired=admin
CloseApplications=yes
CloseApplicationsFilter={#MyAppExeName}
@@ -113,7 +115,7 @@ Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[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]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

@@ -13,6 +13,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly PluginRuntimeService _runtime;
private readonly PluginsInstallHelperClient _helperClient = new();
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
@@ -89,7 +90,28 @@ internal sealed class AirAppMarketInstallService : IDisposable
$"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}'.");

View File

@@ -21,6 +21,7 @@ public sealed class PluginRuntimeService : IDisposable
private readonly PluginLoader _loader;
private readonly AppSettingsService _appSettingsService = new();
private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService();
private readonly IServiceProvider _hostServices;
private readonly IPluginPackageManager _packageManager;
private readonly List<LoadedPlugin> _loadedPlugins = [];
@@ -34,7 +35,7 @@ public sealed class PluginRuntimeService : IDisposable
{
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
_packageManager = new PluginRuntimePackageManager(this);
_hostServices = new PluginHostServiceProvider(_packageManager);
_hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle);
_loader = new PluginLoader(CreateOptions());
}
@@ -196,6 +197,14 @@ public sealed class PluginRuntimeService : IDisposable
}
}
public PluginManifest RegisterInstalledPluginPackage(string packagePath)
{
lock (_packageMutationGate)
{
return RegisterInstalledPluginPackageCore(packagePath);
}
}
public bool DeleteInstalledPlugin(string pluginId)
{
lock (_packageMutationGate)
@@ -288,6 +297,25 @@ public sealed class PluginRuntimeService : IDisposable
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()
{
UnloadInstalledPlugins();
@@ -652,17 +680,29 @@ public sealed class PluginRuntimeService : IDisposable
private sealed class PluginHostServiceProvider : IServiceProvider
{
private readonly IPluginPackageManager _packageManager;
private readonly IHostApplicationLifecycle _applicationLifecycle;
public PluginHostServiceProvider(IPluginPackageManager packageManager)
public PluginHostServiceProvider(
IPluginPackageManager packageManager,
IHostApplicationLifecycle applicationLifecycle)
{
_packageManager = packageManager;
_applicationLifecycle = applicationLifecycle;
}
public object? GetService(Type serviceType)
{
return serviceType == typeof(IPluginPackageManager)
? _packageManager
: null;
if (serviceType == typeof(IPluginPackageManager))
{
return _packageManager;
}
if (serviceType == typeof(IHostApplicationLifecycle))
{
return _applicationLifecycle;
}
return null;
}
}