diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml.cs b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs new file mode 100644 index 0000000..fb98789 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class AirApp : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var options = AirAppLaunchOptions.Parse(desktop.Args ?? []); + desktop.MainWindow = new AirAppWindow(options); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs new file mode 100644 index 0000000..fe1a8fc --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs @@ -0,0 +1,64 @@ +namespace LanMountainDesktop.AirAppHost; + +public sealed record AirAppLaunchOptions( + string AppId, + string SessionId, + string? SourceComponentId, + string? SourcePlacementId, + string? LauncherPipeName, + string? InstanceKey) +{ + public const string WorldClockAppId = "world-clock"; + public const string WhiteboardAppId = "whiteboard"; + + public static AirAppLaunchOptions Parse(IReadOnlyList args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Count; index++) + { + var arg = args[index]; + if (!arg.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = arg[2..].Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + values[key] = args[index + 1]; + index++; + } + else + { + values[key] = "true"; + } + } + + return new AirAppLaunchOptions( + GetValue(values, "app-id", WorldClockAppId), + GetValue(values, "session-id", Guid.NewGuid().ToString("N")), + GetOptionalValue(values, "source-component-id"), + GetOptionalValue(values, "source-placement-id"), + GetOptionalValue(values, "launcher-pipe"), + GetOptionalValue(values, "instance-key")); + } + + private static string GetValue(IReadOnlyDictionary values, string key, string fallback) + { + return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : fallback; + } + + private static string? GetOptionalValue(IReadOnlyDictionary values, string key) + { + return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : null; + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs new file mode 100644 index 0000000..3df352f --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs @@ -0,0 +1,228 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using LanMountainDesktop.Views.Components; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class AirAppWindow : Window +{ + private readonly AirAppLaunchOptions _options; + private readonly AirAppWindowDescriptor _descriptor; + private string _instanceKey = string.Empty; + + public AirAppWindow() + : this(AirAppLaunchOptions.Parse([])) + { + } + + public AirAppWindow(AirAppLaunchOptions options) + { + _options = options; + _descriptor = AirAppWindowDescriptor.Create(options); + InitializeComponent(); + ConfigureWindow(); + } + + private void ConfigureWindow() + { + ApplyWindowDescriptor(_descriptor); + + if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase)) + { + ContentHost.Content = new WorldClockAirAppView(_options); + return; + } + + if (string.Equals(_options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase)) + { + ConfigureWhiteboardWindow(); + return; + } + + ContentHost.Content = new TextBlock + { + Text = $"Unsupported Air APP: {_options.AppId}", + Margin = new Avalonia.Thickness(18) + }; + } + + private void ApplyWindowDescriptor(AirAppWindowDescriptor descriptor) + { + Title = descriptor.Title; + TitleTextBlock.Text = descriptor.TitleText; + SubtitleTextBlock.Text = descriptor.SubtitleText; + Width = descriptor.Width; + Height = descriptor.Height; + MinWidth = descriptor.MinWidth; + MinHeight = descriptor.MinHeight; + ShowInTaskbar = descriptor.ShowInTaskbar; + CanResize = descriptor.CanResize; + WindowDecorations = WindowDecorations.None; + ExtendClientAreaToDecorationsHint = true; + ExtendClientAreaTitleBarHeightHint = -1; + + TitleBar.IsVisible = true; + Grid.SetRow(ContentHost, 1); + Grid.SetRowSpan(ContentHost, 1); + WindowState = WindowState.Normal; + + switch (descriptor.ChromeMode) + { + case AirAppWindowChromeMode.Standard: + break; + + case AirAppWindowChromeMode.Borderless: + HideCustomTitleBar(); + break; + + case AirAppWindowChromeMode.FullScreen: + HideCustomTitleBar(); + WindowShell.CornerRadius = new Avalonia.CornerRadius(0); + WindowShell.BorderThickness = new Avalonia.Thickness(0); + WindowShell.BoxShadow = default; + WindowState = WindowState.FullScreen; + break; + + case AirAppWindowChromeMode.Tool: + ShowInTaskbar = false; + CanResize = false; + break; + + case AirAppWindowChromeMode.BackgroundOnly: + // Reserved for future background-only Air APPs. Keep a normal window for now + // so accidental launches remain visible and debuggable. + break; + } + } + + private void HideCustomTitleBar() + { + TitleBar.IsVisible = false; + Grid.SetRow(ContentHost, 0); + Grid.SetRowSpan(ContentHost, 2); + } + + private void ConfigureWhiteboardWindow() + { + var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId) + ? BuiltInComponentIds.DesktopWhiteboard + : _options.SourceComponentId.Trim(); + var baseWidthCells = string.Equals(componentId, BuiltInComponentIds.DesktopBlackboardLandscape, StringComparison.OrdinalIgnoreCase) + ? 4 + : 2; + var widget = new WhiteboardWidget(baseWidthCells); + widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId); + widget.SetSurfaceMode( + WhiteboardWidgetSurfaceMode.AirApp, + () => + { + widget.ForceSaveNote(); + Close(); + }); + + ContentHost.Content = widget; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + _ = RegisterWithLauncherAsync(); + AppLogger.Info( + "AirAppWindow", + $"Opened. WindowRole=AirApp; AppId='{_options.AppId}'; ForegroundActivationRequested=True."); + Dispatcher.UIThread.Post(() => + { + Activate(); + }, DispatcherPriority.Background); + } + + protected override void OnClosed(EventArgs e) + { + _ = UnregisterWithLauncherAsync(); + base.OnClosed(e); + } + + private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + BeginMoveDrag(e); + } + } + + private void OnCloseClick(object? sender, RoutedEventArgs e) + { + Close(); + } + + private async Task RegisterWithLauncherAsync() + { + if (string.IsNullOrWhiteSpace(_options.LauncherPipeName)) + { + return; + } + + _instanceKey = ResolveInstanceKey(); + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + _ = await proxy.RegisterAsync(new AirAppRegistrationRequest( + _instanceKey, + _options.AppId, + _options.SessionId, + Environment.ProcessId, + Title ?? "Air APP", + _options.SourceComponentId, + _options.SourcePlacementId)).ConfigureAwait(false); + } + catch + { + // Registration is best-effort; Launcher also tracks the process it started. + } + } + + private async Task UnregisterWithLauncherAsync() + { + if (string.IsNullOrWhiteSpace(_options.LauncherPipeName)) + { + return; + } + + var instanceKey = string.IsNullOrWhiteSpace(_instanceKey) ? ResolveInstanceKey() : _instanceKey; + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + _ = await proxy.UnregisterAsync(instanceKey, Environment.ProcessId).ConfigureAwait(false); + } + catch + { + // Unregister is best-effort; Launcher prunes dead processes. + } + } + + private string ResolveInstanceKey() + { + if (!string.IsNullOrWhiteSpace(_options.InstanceKey)) + { + return _options.InstanceKey.Trim(); + } + + var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId) + ? "none" + : _options.SourceComponentId.Trim(); + var placementId = string.IsNullOrWhiteSpace(_options.SourcePlacementId) + ? "none" + : _options.SourcePlacementId.Trim(); + return $"{_options.AppId}:{componentId}:{placementId}"; + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs new file mode 100644 index 0000000..1fa8a19 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs @@ -0,0 +1,10 @@ +namespace LanMountainDesktop.AirAppHost; + +public enum AirAppWindowChromeMode +{ + Standard, + Borderless, + FullScreen, + Tool, + BackgroundOnly +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs new file mode 100644 index 0000000..3ee33e3 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs @@ -0,0 +1,137 @@ +namespace LanMountainDesktop.AirAppHost; + +public sealed record AirAppWindowDescriptor( + string WindowTitle, + string TitleBarTitle, + string TitleBarSubtitle, + AirAppWindowChromeMode ChromeMode, + bool CanResize, + bool ShowInTaskbar, + double Width, + double Height, + double MinWidth, + double MinHeight) +{ + public string Title => WindowTitle; + + public string TitleText => TitleBarTitle; + + public string SubtitleText => TitleBarSubtitle; + + public static AirAppWindowDescriptor Create(AirAppLaunchOptions options) + { + if (string.Equals(options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase)) + { + return Standard( + "World Clock - Air APP", + "World Clock", + "Air APP"); + } + + if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase)) + { + return FullScreen( + "Whiteboard - Air APP", + "Whiteboard", + "Air APP"); + } + + return Standard( + "Air APP", + "Air APP", + options.AppId); + } + + public static AirAppWindowDescriptor Standard( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle, + double width = 520, + double height = 360, + double minWidth = 360, + double minHeight = 260) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.Standard, + CanResize: true, + ShowInTaskbar: true, + width, + height, + minWidth, + minHeight); + } + + public static AirAppWindowDescriptor FullScreen( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.FullScreen, + CanResize: false, + ShowInTaskbar: true, + Width: 1280, + Height: 720, + MinWidth: 360, + MinHeight: 260); + } + + public static AirAppWindowDescriptor Borderless( + string windowTitle, + double width = 520, + double height = 360) + { + return new AirAppWindowDescriptor( + windowTitle, + string.Empty, + string.Empty, + AirAppWindowChromeMode.Borderless, + CanResize: true, + ShowInTaskbar: true, + width, + height, + MinWidth: 240, + MinHeight: 180); + } + + public static AirAppWindowDescriptor Tool( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle, + double width = 360, + double height = 260) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.Tool, + CanResize: false, + ShowInTaskbar: false, + width, + height, + MinWidth: 240, + MinHeight: 180); + } + + public static AirAppWindowDescriptor BackgroundOnly(string appId) + { + return new AirAppWindowDescriptor( + $"{appId} - Air APP", + string.Empty, + string.Empty, + AirAppWindowChromeMode.BackgroundOnly, + CanResize: false, + ShowInTaskbar: false, + Width: 1, + Height: 1, + MinWidth: 1, + MinHeight: 1); + } +} diff --git a/LanMountainDesktop.AirAppHost/Program.cs b/LanMountainDesktop.AirAppHost/Program.cs new file mode 100644 index 0000000..e86d13f --- /dev/null +++ b/LanMountainDesktop.AirAppHost/Program.cs @@ -0,0 +1,21 @@ +using Avalonia; + +namespace LanMountainDesktop.AirAppHost; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + + private static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs new file mode 100644 index 0000000..d9f8df1 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class WorldClockAirAppView : UserControl +{ + private readonly DispatcherTimer _timer = new() + { + Interval = TimeSpan.FromSeconds(1) + }; + + private readonly AirAppLaunchOptions _options; + + public WorldClockAirAppView() + : this(AirAppLaunchOptions.Parse([])) + { + } + + public WorldClockAirAppView(AirAppLaunchOptions options) + { + _options = options; + InitializeComponent(); + + SessionTextBlock.Text = string.IsNullOrWhiteSpace(_options.SourcePlacementId) + ? "World Clock" + : $"World Clock / {_options.SourcePlacementId}"; + + _timer.Tick += OnTimerTick; + AttachedToVisualTree += (_, _) => + { + UpdateTime(); + _timer.Start(); + }; + DetachedFromVisualTree += (_, _) => _timer.Stop(); + UpdateTime(); + } + + private void OnTimerTick(object? sender, EventArgs e) + { + UpdateTime(); + } + + private void UpdateTime() + { + var now = DateTime.Now; + TimeTextBlock.Text = now.ToString("HH:mm:ss", CultureInfo.CurrentCulture); + DateTextBlock.Text = now.ToString("yyyy-MM-dd dddd", CultureInfo.CurrentCulture); + TimeZoneTextBlock.Text = TimeZoneInfo.Local.DisplayName; + } +} diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index 306c087..3117fbc 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Services.AirApp; using LanMountainDesktop.Launcher.Services.Ipc; using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; @@ -61,6 +62,13 @@ public partial class App : Application return; } + if (context.IsAirAppBrokerCommand) + { + _ = RunAirAppBrokerAsync(desktop, context); + base.OnFrameworkInitializationCompleted(); + return; + } + // 调试模式:只显示 DevDebugWindow,不走正常启动流程 // 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI if (context.IsDebugMode && !context.IsPreviewCommand && @@ -90,6 +98,45 @@ public partial class App : Application base.OnFrameworkInitializationCompleted(); } + private static async Task RunAirAppBrokerAsync( + IClassicDesktopStyleApplicationLifetime desktop, + CommandContext context) + { + var appRoot = Commands.ResolveAppRoot(context); + var requesterPid = context.GetIntOption("requester-pid", 0); + Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}."); + + using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( + new LauncherAirAppLifecycleService( + new AirAppProcessStarter( + new AirAppHostLocator(), + () => appRoot, + () => null))); + airAppIpcHost.Start(); + + await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); + + Logger.Info("Air APP broker exiting."); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background); + } + + internal static async Task WaitForAirAppBrokerExitAsync( + int requesterPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + while (ShouldKeepAirAppBrokerAlive(requesterPid, airAppLifecycleService)) + { + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + } + + internal static bool ShouldKeepAirAppBrokerAlive( + int requesterPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + return TryGetLiveProcess(requesterPid) || airAppLifecycleService.HasLiveAirApps(); + } + private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop) { switch (context.Command.ToLowerInvariant()) @@ -236,7 +283,6 @@ public partial class App : Application var startupAttemptRegistry = new StartupAttemptRegistry(); var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context); - if (!startupAttemptRegistry.TryReserveCoordinator( context.LaunchSource, successPolicy, @@ -257,6 +303,14 @@ public partial class App : Application return; } + using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( + new LauncherAirAppLifecycleService( + new AirAppProcessStarter( + new AirAppHostLocator(), + () => appRoot, + () => null))); + airAppIpcHost.Start(); + using var coordinatorServer = new LauncherCoordinatorIpcServer( coordinatorPipeName, BuildCoordinatorStatusFromAttempt(reservedAttempt), @@ -334,9 +388,45 @@ public partial class App : Application await WriteLauncherResultAsync(context, result).ConfigureAwait(false); Environment.ExitCode = result.Success ? 0 : 1; + if (result.Success) + { + var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); + await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); + } + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); } + private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid) + { + if (result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var hostPid)) + { + return hostPid; + } + + if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) && + int.TryParse(existingHostPidText, out var existingHostPid)) + { + return existingHostPid; + } + + return fallbackHostPid; + } + + private static async Task WaitForManagedProcessesToExitAsync( + int hostPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}."); + while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps()) + { + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + + Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains."); + } + private static async Task AttachToExistingCoordinatorAsync( CommandContext context, SplashWindow? splashWindow, diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs index fcad276..2203bb1 100644 --- a/LanMountainDesktop.Launcher/CommandContext.cs +++ b/LanMountainDesktop.Launcher/CommandContext.cs @@ -4,11 +4,14 @@ namespace LanMountainDesktop.Launcher; internal sealed class CommandContext { + public const string AirAppBrokerCommand = "air-app-broker"; + private const string LaunchSourceOptionName = "launch-source"; private static readonly string[] GuiCommands = [ "launch", + AirAppBrokerCommand, "apply-update", "preview-splash", "preview-error", @@ -60,6 +63,9 @@ internal sealed class CommandContext public bool IsPreviewCommand => Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase); + public bool IsAirAppBrokerCommand => + string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase); + public bool IsGuiCommand => GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase); diff --git a/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs b/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs new file mode 100644 index 0000000..3967eb4 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs @@ -0,0 +1,88 @@ +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal sealed class AirAppHostLocator +{ + private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe"; + private const string DllName = "LanMountainDesktop.AirAppHost.dll"; + + public string Resolve(string? packageRoot, string? hostPath = null) + { + foreach (var candidate in EnumerateCandidates(packageRoot, hostPath)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + throw new FileNotFoundException("Unable to find LanMountainDesktop.AirAppHost output."); + } + + private static IEnumerable EnumerateCandidates(string? packageRoot, string? hostPath) + { + foreach (var root in EnumerateRoots(packageRoot, hostPath)) + { + yield return Path.Combine(root, "AirAppHost", WindowsExecutableName); + yield return Path.Combine(root, "AirAppHost", DllName); + yield return Path.Combine(root, WindowsExecutableName); + yield return Path.Combine(root, DllName); + + if (Directory.Exists(root)) + { + foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)) + { + yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName); + yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName); + } + } + } + + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent) + { + yield return Path.Combine( + current.FullName, + "LanMountainDesktop.AirAppHost", + "bin", +#if DEBUG + "Debug", +#else + "Release", +#endif + "net10.0", + WindowsExecutableName); + + yield return Path.Combine( + current.FullName, + "LanMountainDesktop.AirAppHost", + "bin", +#if DEBUG + "Debug", +#else + "Release", +#endif + "net10.0", + DllName); + } + } + + private static IEnumerable EnumerateRoots(string? packageRoot, string? hostPath) + { + if (!string.IsNullOrWhiteSpace(packageRoot)) + { + yield return Path.GetFullPath(packageRoot); + } + + if (!string.IsNullOrWhiteSpace(hostPath)) + { + var hostDirectory = Path.GetDirectoryName(Path.GetFullPath(hostPath)); + if (!string.IsNullOrWhiteSpace(hostDirectory)) + { + yield return hostDirectory; + } + } + + yield return AppContext.BaseDirectory; + yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..")); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs b/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs new file mode 100644 index 0000000..bc57e45 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs @@ -0,0 +1,19 @@ +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal static class AirAppInstanceKey +{ + public static string Build(string appId, string? sourceComponentId, string? sourcePlacementId) + { + var normalizedAppId = Normalize(appId, "unknown"); + var normalizedComponentId = Normalize(sourceComponentId, "none"); + var normalizedPlacementId = Normalize(sourcePlacementId, "none"); + return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}"; + } + + private static string Normalize(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) + ? fallback + : value.Trim(); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs new file mode 100644 index 0000000..031e050 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; + +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal interface IAirAppProcessStarter +{ + Process? Start(string appId, string sessionId, string instanceKey, string? sourceComponentId, string? sourcePlacementId); +} + +internal sealed class AirAppProcessStarter : IAirAppProcessStarter +{ + private readonly AirAppHostLocator _locator; + private readonly Func _packageRootProvider; + private readonly Func _hostPathProvider; + + public AirAppProcessStarter( + AirAppHostLocator locator, + Func packageRootProvider, + Func hostPathProvider) + { + _locator = locator; + _packageRootProvider = packageRootProvider; + _hostPathProvider = hostPathProvider; + } + + public Process? Start( + string appId, + string sessionId, + string instanceKey, + string? sourceComponentId, + string? sourcePlacementId) + { + var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider()); + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory + }; + + if (OperatingSystem.IsWindows() && + string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase)) + { + startInfo.FileName = hostPath; + } + else + { + startInfo.FileName = "dotnet"; + startInfo.ArgumentList.Add(hostPath); + } + + AddArgument(startInfo, "--app-id", appId); + AddArgument(startInfo, "--session-id", sessionId); + AddArgument(startInfo, "--instance-key", instanceKey); + AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName); + + if (!string.IsNullOrWhiteSpace(sourceComponentId)) + { + AddArgument(startInfo, "--source-component-id", sourceComponentId.Trim()); + } + + if (!string.IsNullOrWhiteSpace(sourcePlacementId)) + { + AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim()); + } + + return Process.Start(startInfo); + } + + private static void AddArgument(ProcessStartInfo startInfo, string name, string value) + { + startInfo.ArgumentList.Add(name); + startInfo.ArgumentList.Add(value); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs new file mode 100644 index 0000000..ec1b142 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs @@ -0,0 +1,29 @@ +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable +{ + private readonly PublicIpcHostService _host; + + public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService) + { + LifecycleService = lifecycleService; + _host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName); + _host.RegisterPublicService(lifecycleService); + } + + public LauncherAirAppLifecycleService LifecycleService { get; } + + public void Start() + { + _host.Start(); + Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'."); + } + + public void Dispose() + { + _host.Dispose(); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs new file mode 100644 index 0000000..db45807 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs @@ -0,0 +1,332 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService +{ + private readonly object _gate = new(); + private readonly IAirAppProcessStarter _processStarter; + private readonly Dictionary _instances = new(StringComparer.OrdinalIgnoreCase); + + public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter) + { + _processStarter = processStarter; + } + + public Task OpenAsync(AirAppOpenRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var appId = Normalize(request.AppId, "unknown"); + var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId); + Logger.Info( + $"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}."); + + lock (_gate) + { + CleanupExitedInstances(); + + if (_instances.TryGetValue(instanceKey, out var existing) && IsProcessAlive(existing.ProcessId)) + { + TryActivateProcess(existing.ProcessId); + existing.Touch(); + return Task.FromResult(BuildResult(true, "activated_existing", "Activated existing Air APP instance.", existing)); + } + + var sessionId = Guid.NewGuid().ToString("N"); + try + { + var process = _processStarter.Start( + appId, + sessionId, + instanceKey, + request.SourceComponentId, + request.SourcePlacementId); + if (process is null) + { + return Task.FromResult(BuildResult(false, "start_failed", "AirAppHost process was not created.", null)); + } + + var instance = new ManagedAirAppInstance( + instanceKey, + appId, + sessionId, + process.Id, + $"{appId} - Air APP", + request.SourceComponentId, + request.SourcePlacementId); + _instances[instanceKey] = instance; + Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}."); + return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance)); + } + catch (Exception ex) + { + Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}"); + return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null)); + } + } + } + + public Task ActivateAsync(string instanceKey) + { + lock (_gate) + { + CleanupExitedInstances(); + if (!_instances.TryGetValue(instanceKey, out var instance)) + { + return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null)); + } + + var accepted = TryActivateProcess(instance.ProcessId); + instance.Touch(); + return Task.FromResult(BuildResult( + accepted, + accepted ? "activated" : "activation_failed", + accepted ? "Air APP instance activated." : "Failed to activate Air APP instance.", + instance)); + } + } + + public Task CloseAsync(string instanceKey) + { + lock (_gate) + { + CleanupExitedInstances(); + if (!_instances.TryGetValue(instanceKey, out var instance)) + { + return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null)); + } + + var accepted = TryCloseProcess(instance.ProcessId); + instance.Touch(); + return Task.FromResult(BuildResult( + accepted, + accepted ? "close_requested" : "close_failed", + accepted ? "Air APP close requested." : "Failed to request Air APP close.", + instance)); + } + } + + public Task GetInstancesAsync() + { + lock (_gate) + { + CleanupExitedInstances(); + return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray()); + } + } + + public Task RegisterAsync(AirAppRegistrationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + lock (_gate) + { + var instanceKey = string.IsNullOrWhiteSpace(request.InstanceKey) + ? AirAppInstanceKey.Build(request.AppId, request.SourceComponentId, request.SourcePlacementId) + : request.InstanceKey.Trim(); + var instance = new ManagedAirAppInstance( + instanceKey, + Normalize(request.AppId, "unknown"), + Normalize(request.SessionId, Guid.NewGuid().ToString("N")), + request.ProcessId, + Normalize(request.WindowTitle, $"{request.AppId} - Air APP"), + request.SourceComponentId, + request.SourcePlacementId); + _instances[instanceKey] = instance; + Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}."); + return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance)); + } + } + + public Task UnregisterAsync(string instanceKey, int processId) + { + lock (_gate) + { + if (_instances.TryGetValue(instanceKey, out var instance) && + (processId <= 0 || instance.ProcessId == processId)) + { + _instances.Remove(instanceKey); + Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}."); + return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance)); + } + + return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null)); + } + } + + public bool HasLiveAirApps() + { + lock (_gate) + { + CleanupExitedInstances(); + return _instances.Values.Any(static instance => IsProcessAlive(instance.ProcessId)); + } + } + + private void CleanupExitedInstances() + { + var exitedKeys = _instances + .Where(static pair => !IsProcessAlive(pair.Value.ProcessId)) + .Select(static pair => pair.Key) + .ToList(); + + foreach (var key in exitedKeys) + { + _instances.Remove(key); + Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'."); + } + } + + private static AirAppOperationResult BuildResult( + bool accepted, + string code, + string message, + ManagedAirAppInstance? instance) + { + return new AirAppOperationResult(accepted, code, message, instance?.ToInfo()); + } + + private static bool TryActivateProcess(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + if (process.HasExited) + { + return false; + } + + if (!OperatingSystem.IsWindows()) + { + return true; + } + + process.Refresh(); + var handle = process.MainWindowHandle; + if (handle == IntPtr.Zero) + { + return true; + } + + _ = ShowWindow(handle, SW_SHOWNORMAL); + _ = SetForegroundWindow(handle); + return true; + } + catch + { + return false; + } + } + + private static bool TryCloseProcess(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + if (process.HasExited) + { + return false; + } + + return process.CloseMainWindow(); + } + catch + { + return false; + } + } + + private static bool IsProcessAlive(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + return false; + } + } + + private static string Normalize(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) + ? fallback + : value.Trim(); + } + + private const int SW_SHOWNORMAL = 1; + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + private sealed class ManagedAirAppInstance + { + private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow; + + public ManagedAirAppInstance( + string instanceKey, + string appId, + string sessionId, + int processId, + string windowTitle, + string? sourceComponentId, + string? sourcePlacementId) + { + InstanceKey = instanceKey; + AppId = appId; + SessionId = sessionId; + ProcessId = processId; + WindowTitle = windowTitle; + SourceComponentId = sourceComponentId; + SourcePlacementId = sourcePlacementId; + UpdatedAtUtc = _startedAtUtc; + } + + public string InstanceKey { get; } + + public string AppId { get; } + + public string SessionId { get; } + + public int ProcessId { get; } + + public string WindowTitle { get; } + + public string? SourceComponentId { get; } + + public string? SourcePlacementId { get; } + + public DateTimeOffset UpdatedAtUtc { get; private set; } + + public void Touch() + { + UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + public AirAppInstanceInfo ToInfo() + { + return new AirAppInstanceInfo( + InstanceKey, + AppId, + SessionId, + ProcessId, + WindowTitle, + SourceComponentId, + SourcePlacementId, + IsProcessAlive(ProcessId), + _startedAtUtc, + UpdatedAtUtc); + } + } +} diff --git a/LanMountainDesktop.Shared.IPC/Abstractions/Services/IAirAppLifecycleService.cs b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IAirAppLifecycleService.cs new file mode 100644 index 0000000..68c6474 --- /dev/null +++ b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IAirAppLifecycleService.cs @@ -0,0 +1,52 @@ +using dotnetCampus.Ipc.CompilerServices.Attributes; + +namespace LanMountainDesktop.Shared.IPC.Abstractions.Services; + +[IpcPublic(IgnoresIpcException = true)] +public interface IAirAppLifecycleService +{ + Task OpenAsync(AirAppOpenRequest request); + + Task ActivateAsync(string instanceKey); + + Task CloseAsync(string instanceKey); + + Task GetInstancesAsync(); + + Task RegisterAsync(AirAppRegistrationRequest request); + + Task UnregisterAsync(string instanceKey, int processId); +} + +public sealed record AirAppOpenRequest( + string AppId, + string? SourceComponentId, + string? SourcePlacementId, + int RequesterProcessId); + +public sealed record AirAppRegistrationRequest( + string InstanceKey, + string AppId, + string SessionId, + int ProcessId, + string WindowTitle, + string? SourceComponentId, + string? SourcePlacementId); + +public sealed record AirAppInstanceInfo( + string InstanceKey, + string AppId, + string SessionId, + int ProcessId, + string WindowTitle, + string? SourceComponentId, + string? SourcePlacementId, + bool ProcessAlive, + DateTimeOffset StartedAtUtc, + DateTimeOffset UpdatedAtUtc); + +public sealed record AirAppOperationResult( + bool Accepted, + string Code, + string Message, + AirAppInstanceInfo? Instance); diff --git a/LanMountainDesktop.Shared.IPC/IpcConstants.cs b/LanMountainDesktop.Shared.IPC/IpcConstants.cs index 338cdc0..1acee85 100644 --- a/LanMountainDesktop.Shared.IPC/IpcConstants.cs +++ b/LanMountainDesktop.Shared.IPC/IpcConstants.cs @@ -6,6 +6,10 @@ public static class IpcConstants public const string ProtocolVersion = "external-ipc-public-api.v1"; + public const string AirAppLifecyclePipeName = "LanMountainDesktop.Launcher.AirApp.v1"; + + public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1"; + public static class Routes { public const string SessionGetInfo = "lanmountain.session.get-info"; diff --git a/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs b/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs new file mode 100644 index 0000000..acbefa2 --- /dev/null +++ b/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs @@ -0,0 +1,81 @@ +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class AirAppLauncherServiceTests +{ + [Fact] + public void BuildOpenRequest_IncludesWorldClockSourceContext() + { + var request = AirAppLauncherService.BuildOpenRequest( + AirAppLauncherService.WorldClockAppId, + BuiltInComponentIds.DesktopWorldClock, + "placement-7", + 42); + + Assert.Equal("world-clock", request.AppId); + Assert.Equal(BuiltInComponentIds.DesktopWorldClock, request.SourceComponentId); + Assert.Equal("placement-7", request.SourcePlacementId); + Assert.Equal(42, request.RequesterProcessId); + } + + [Fact] + public void BuildOpenRequest_NormalizesEmptyOptionalContext() + { + var request = AirAppLauncherService.BuildOpenRequest( + AirAppLauncherService.WorldClockAppId, + null, + " ", + 42); + + Assert.Equal("world-clock", request.AppId); + Assert.Null(request.SourceComponentId); + Assert.Null(request.SourcePlacementId); + Assert.Equal(42, request.RequesterProcessId); + } + + [Fact] + public void BuildOpenRequest_IncludesWhiteboardSourceContext() + { + var request = AirAppLauncherService.BuildOpenRequest( + AirAppLauncherService.WhiteboardAppId, + BuiltInComponentIds.DesktopWhiteboard, + "whiteboard-placement", + 99); + + Assert.Equal("whiteboard", request.AppId); + Assert.Equal(BuiltInComponentIds.DesktopWhiteboard, request.SourceComponentId); + Assert.Equal("whiteboard-placement", request.SourcePlacementId); + Assert.Equal(99, request.RequesterProcessId); + } + + [Fact] + public void BuildSingleInstanceKey_UsesWhiteboardComponentAndPlacement() + { + var key = AirAppLauncherService.BuildSingleInstanceKey( + AirAppLauncherService.WhiteboardAppId, + BuiltInComponentIds.DesktopBlackboardLandscape, + "placement-3"); + + Assert.Equal( + $"whiteboard:{BuiltInComponentIds.DesktopBlackboardLandscape}:placement-3", + key); + } + + [Fact] + public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid() + { + var startInfo = AirAppLauncherService.CreateBrokerStartInfo( + @"C:\Apps\LanMountainDesktop.Launcher.exe", + 12345); + + Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName); + Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory); + Assert.False(startInfo.UseShellExecute); + Assert.Equal( + ["air-app-broker", "--requester-pid", "12345"], + startInfo.ArgumentList); + } +} diff --git a/LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs b/LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs new file mode 100644 index 0000000..d6fa02f --- /dev/null +++ b/LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs @@ -0,0 +1,110 @@ +using FluentIcons.Common; +using LanMountainDesktop.ComponentSystem; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class ComponentCategoryIconResolverTests +{ + [Fact] + public void ResolveCategoryIcon_AllCategory_ReturnsApps() + { + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("all", []); + Assert.Equal(Icon.Apps, result); + } + + [Fact] + public void ResolveCategoryIcon_ResolvesFromFirstComponentIconKey() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Clock", "Clock", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Clock", components); + Assert.Equal(Icon.Clock, result); + } + + [Fact] + public void ResolveCategoryIcon_WeatherSunny_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "WeatherSunny", "Weather", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Weather", components); + Assert.Equal(Icon.WeatherSunny, result); + } + + [Fact] + public void ResolveCategoryIcon_News_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "News", "Info", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Info", components); + Assert.Equal(Icon.News, result); + } + + [Fact] + public void ResolveCategoryIcon_Edit_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Edit", "Board", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Board", components); + Assert.Equal(Icon.Edit, result); + } + + [Fact] + public void ResolveCategoryIcon_InvalidIconKey_FallsBackToApps() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "NonExistentIcon", "Other", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Other", components); + Assert.Equal(Icon.Apps, result); + } + + [Fact] + public void ResolveCategoryIcon_EmptyComponents_FallsBackToApps() + { + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Unknown", []); + Assert.Equal(Icon.Apps, result); + } + + [Fact] + public void ResolveCategoryIcon_Play_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Play", "Media", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Media", components); + Assert.Equal(Icon.Play, result); + } + + [Fact] + public void ResolveCategoryIcon_Calculator_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Calculator", "Calculator", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Calculator", components); + Assert.Equal(Icon.Calculator, result); + } + + [Fact] + public void ResolveCategoryIcon_Folder_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Folder", "File", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("File", components); + Assert.Equal(Icon.Folder, result); + } +} diff --git a/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs b/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs index b0e6c11..a08b476 100644 --- a/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs +++ b/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs @@ -117,6 +117,40 @@ public sealed class DesktopComponentRenderModeTests Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 999, "Unknown", isDaylight: true)); } + [Theory] + [InlineData(WeatherVisualStyleId.GoogleWeatherV4, "google")] + [InlineData(WeatherVisualStyleId.Geometric, "geometric")] + [InlineData(WeatherVisualStyleId.Breezy, "breezy")] + [InlineData(WeatherVisualStyleId.LemonFlutter, "lemon")] + public void WeatherSceneProfileResolver_UsesDistinctRendererPerVisualStyle(string styleId, string expectedRenderer) + { + var profile = WeatherSceneProfileResolver.Resolve(styleId, MaterialWeatherCondition.Rain, isNight: false, isLive: true); + + Assert.Equal(expectedRenderer, profile.RendererId); + Assert.Equal("rain", profile.WeatherLayerId); + Assert.True(profile.IsLive); + } + + [Theory] + [InlineData(MaterialWeatherCondition.Clear, "clear")] + [InlineData(MaterialWeatherCondition.PartlyCloudy, "partly-cloudy")] + [InlineData(MaterialWeatherCondition.Cloudy, "cloudy")] + [InlineData(MaterialWeatherCondition.Rain, "rain")] + [InlineData(MaterialWeatherCondition.Storm, "storm")] + [InlineData(MaterialWeatherCondition.Snow, "snow")] + [InlineData(MaterialWeatherCondition.Fog, "fog")] + [InlineData(MaterialWeatherCondition.Haze, "haze")] + [InlineData(MaterialWeatherCondition.Unknown, "ambient")] + public void WeatherSceneProfileResolver_UsesDistinctWeatherLayerPerCondition(MaterialWeatherCondition condition, string expectedLayer) + { + var profile = WeatherSceneProfileResolver.Resolve(WeatherVisualStyleId.Breezy, condition, isNight: true, isLive: false); + + Assert.Equal("breezy", profile.RendererId); + Assert.Equal(expectedLayer, profile.WeatherLayerId); + Assert.True(profile.IsNight); + Assert.False(profile.IsLive); + } + private static DesktopComponentRuntimeDescriptor CreateDescriptor() { Assert.True(CreateRuntimeRegistry().TryGetDescriptor(ComponentId, out var descriptor)); diff --git a/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs b/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs new file mode 100644 index 0000000..d38206e --- /dev/null +++ b/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs @@ -0,0 +1,164 @@ +using System.Diagnostics; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Services.AirApp; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class LauncherAirAppLifecycleServiceTests +{ + [Fact] + public async Task OpenAsync_ReusesExistingInstanceForSameKey() + { + var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess()); + var service = new LauncherAirAppLifecycleService(starter); + var request = new AirAppOpenRequest( + "whiteboard", + BuiltInComponentIds.DesktopWhiteboard, + "placement-1", + Environment.ProcessId); + + var first = await service.OpenAsync(request); + var second = await service.OpenAsync(request); + + Assert.True(first.Accepted); + Assert.True(second.Accepted); + Assert.Equal("started", first.Code); + Assert.Equal("activated_existing", second.Code); + Assert.Equal(1, starter.StartCount); + Assert.Equal(first.Instance!.InstanceKey, second.Instance!.InstanceKey); + } + + [Fact] + public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart() + { + var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess()); + var service = new LauncherAirAppLifecycleService(starter); + var instanceKey = AirAppInstanceKey.Build( + "whiteboard", + BuiltInComponentIds.DesktopWhiteboard, + "placement-2"); + + _ = await service.RegisterAsync(new AirAppRegistrationRequest( + instanceKey, + "whiteboard", + "dead-session", + int.MaxValue, + "Dead Air APP", + BuiltInComponentIds.DesktopWhiteboard, + "placement-2")); + + var result = await service.OpenAsync(new AirAppOpenRequest( + "whiteboard", + BuiltInComponentIds.DesktopWhiteboard, + "placement-2", + Environment.ProcessId)); + + Assert.True(result.Accepted); + Assert.Equal("started", result.Code); + Assert.Equal(1, starter.StartCount); + Assert.Equal(Environment.ProcessId, result.Instance!.ProcessId); + } + + [Fact] + public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess())); + var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1"); + + _ = await service.RegisterAsync(new AirAppRegistrationRequest( + instanceKey, + "world-clock", + "session", + Environment.ProcessId, + "World Clock", + BuiltInComponentIds.DesktopWorldClock, + "clock-1")); + + Assert.True(service.HasLiveAirApps()); + + _ = await service.UnregisterAsync(instanceKey, Environment.ProcessId); + + Assert.False(service.HasLiveAirApps()); + } + + [Fact] + public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); + + Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service)); + } + + [Fact] + public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); + + Assert.False(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); + } + + [Fact] + public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); + var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2"); + + _ = await service.RegisterAsync(new AirAppRegistrationRequest( + instanceKey, + "world-clock", + "session", + Environment.ProcessId, + "World Clock", + BuiltInComponentIds.DesktopWorldClock, + "clock-2")); + + Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); + } + + [Fact] + public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment() + { + var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); + + var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]); + + Assert.True(context.IsGuiCommand); + Assert.True(context.IsAirAppBrokerCommand); + Assert.True(context.IsDebugMode); + Assert.Equal(42, context.GetIntOption("requester-pid", 0)); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment); + } + } + + private sealed class TestAirAppProcessStarter : IAirAppProcessStarter + { + private readonly Process? _process; + + public TestAirAppProcessStarter(Process? process) + { + _process = process; + } + + public int StartCount { get; private set; } + + public Process? Start( + string appId, + string sessionId, + string instanceKey, + string? sourceComponentId, + string? sourcePlacementId) + { + StartCount++; + return _process; + } + } +} diff --git a/LanMountainDesktop.Tests/MusicControlViewModelTests.cs b/LanMountainDesktop.Tests/MusicControlViewModelTests.cs new file mode 100644 index 0000000..b2e459d --- /dev/null +++ b/LanMountainDesktop.Tests/MusicControlViewModelTests.cs @@ -0,0 +1,42 @@ +using LanMountainDesktop.Services; +using LanMountainDesktop.ViewModels; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class MusicControlViewModelTests : IDisposable +{ + private readonly MusicControlViewModel _viewModel; + + public MusicControlViewModelTests() + { + _viewModel = new MusicControlViewModel(); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + _viewModel.Dispose(); + _viewModel.Dispose(); + } + + [Fact] + public async Task Dispose_StopsRefreshAfterCancellation() + { + var refreshTask = _viewModel.RefreshAsync(); + _viewModel.Dispose(); + + await Task.Delay(100); + } + + [Fact] + public void ViewModel_InitializesWithNoSession() + { + Assert.True(_viewModel.IsNoMedia); + } + + public void Dispose() + { + _viewModel.Dispose(); + } +} diff --git a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs new file mode 100644 index 0000000..2a73f03 --- /dev/null +++ b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs @@ -0,0 +1,88 @@ +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class WindowLayerIsolationTests +{ + [Fact] + public void AirAppWindow_DoesNotUseDesktopBottomMostOrTopmostPromotion() + { + var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs"); + + Assert.DoesNotContain("WindowBottomMostServiceFactory", source); + Assert.DoesNotContain("IWindowBottomMostService", source); + Assert.DoesNotContain("SendToBottom", source); + Assert.DoesNotContain("Topmost = true", source); + Assert.DoesNotContain("Topmost=true", source); + } + + [Fact] + public void AirAppWindowDescriptor_DefinesSupportedChromeModes() + { + var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindowDescriptor.cs"); + + Assert.Contains("AirAppWindowChromeMode", source); + Assert.Contains("Standard", source); + Assert.Contains("Borderless", source); + Assert.Contains("FullScreen", source); + Assert.Contains("Tool", source); + Assert.Contains("BackgroundOnly", source); + } + + [Fact] + public void AirAppWindowDescriptor_MapsBuiltInAppsToExpectedChromeModes() + { + var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindowDescriptor.cs"); + + Assert.Contains("AirAppLaunchOptions.WorldClockAppId", source); + Assert.Contains("AirAppWindowChromeMode.Standard", source); + Assert.Contains("AirAppLaunchOptions.WhiteboardAppId", source); + Assert.Contains("AirAppWindowChromeMode.FullScreen", source); + } + + [Fact] + public void FusedDesktopWindows_KeepDesktopBottomMostBoundary() + { + var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs"); + var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs"); + + Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow); + Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow); + Assert.Contains("SendToBottom", desktopWidgetWindow); + + Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow); + Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow); + Assert.Contains("SendToBottom", transparentOverlayWindow); + } + + [Fact] + public void FusedDesktopManager_RefreshesDesktopLayerAfterShowingWidgets() + { + var source = ReadRepositoryFile("LanMountainDesktop", "Services", "FusedDesktopManagerService.cs"); + + Assert.Contains("existingWindow.RefreshDesktopLayer()", source); + Assert.Contains("window.RefreshDesktopLayer()", source); + } + + private static string ReadRepositoryFile(params string[] segments) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray()); + if (File.Exists(candidate)) + { + return File.ReadAllText(candidate); + } + + if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx"))) + { + break; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'."); + } +} diff --git a/LanMountainDesktop/ComponentSystem/ComponentCategoryIconResolver.cs b/LanMountainDesktop/ComponentSystem/ComponentCategoryIconResolver.cs new file mode 100644 index 0000000..1c85e44 --- /dev/null +++ b/LanMountainDesktop/ComponentSystem/ComponentCategoryIconResolver.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using FluentIcons.Common; + +namespace LanMountainDesktop.ComponentSystem; + +public static class ComponentCategoryIconResolver +{ + public static Icon ResolveCategoryIcon( + string categoryId, + IEnumerable categoryComponents) + { + if (string.Equals(categoryId, "all", StringComparison.OrdinalIgnoreCase)) + { + return Icon.Apps; + } + + var firstComponent = categoryComponents.FirstOrDefault(); + if (firstComponent is null || string.IsNullOrWhiteSpace(firstComponent.IconKey)) + { + return Icon.Apps; + } + + if (Enum.TryParse(firstComponent.IconKey, ignoreCase: true, out var icon)) + { + return icon; + } + + return Icon.Apps; + } +} diff --git a/LanMountainDesktop/Services/AirAppLauncherService.cs b/LanMountainDesktop/Services/AirAppLauncherService.cs new file mode 100644 index 0000000..2778474 --- /dev/null +++ b/LanMountainDesktop/Services/AirAppLauncherService.cs @@ -0,0 +1,160 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Services; + +public interface IAirAppLauncherService +{ + void OpenWorldClock(string? sourcePlacementId); + + void OpenWhiteboard(string componentId, string? sourcePlacementId); +} + +internal sealed class AirAppLauncherService : IAirAppLauncherService +{ + public const string WorldClockAppId = "world-clock"; + public const string WhiteboardAppId = "whiteboard"; + + private const int LauncherIpcRetryCount = 4; + + public void OpenWorldClock(string? sourcePlacementId) + { + _ = OpenAsync(WorldClockAppId, BuiltInComponentIds.DesktopWorldClock, sourcePlacementId); + } + + public void OpenWhiteboard(string componentId, string? sourcePlacementId) + { + _ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId); + } + + internal static AirAppOpenRequest BuildOpenRequest( + string appId, + string? sourceComponentId, + string? sourcePlacementId, + int requesterProcessId) + { + return new AirAppOpenRequest( + appId.Trim(), + string.IsNullOrWhiteSpace(sourceComponentId) ? null : sourceComponentId.Trim(), + string.IsNullOrWhiteSpace(sourcePlacementId) ? null : sourcePlacementId.Trim(), + requesterProcessId); + } + + internal static string BuildSingleInstanceKey(string appId, string? sourceComponentId, string? sourcePlacementId) + { + var normalizedAppId = string.IsNullOrWhiteSpace(appId) ? "unknown" : appId.Trim(); + var normalizedComponentId = string.IsNullOrWhiteSpace(sourceComponentId) ? "none" : sourceComponentId.Trim(); + var normalizedPlacementId = string.IsNullOrWhiteSpace(sourcePlacementId) ? "none" : sourcePlacementId.Trim(); + return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}"; + } + + private static async Task OpenAsync(string appId, string sourceComponentId, string? sourcePlacementId) + { + var request = BuildOpenRequest(appId, sourceComponentId, sourcePlacementId, Environment.ProcessId); + try + { + var result = await SendOpenRequestAsync(request).ConfigureAwait(false); + if (result.Accepted) + { + AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'."); + return; + } + + AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'."); + } + catch (Exception ex) + { + AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex); + } + } + + private static async Task SendOpenRequestAsync(AirAppOpenRequest request) + { + Exception? lastException = null; + for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++) + { + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + return await proxy.OpenAsync(request).ConfigureAwait(false); + } + catch (Exception ex) + { + lastException = ex; + if (attempt == 1) + { + AppLogger.Warn( + "AirAppLauncher", + $"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.", + ex); + TryStartLauncher(); + } + + await Task.Delay(250 * attempt).ConfigureAwait(false); + } + } + + throw new InvalidOperationException( + $"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.", + lastException); + } + + internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId) + { + var startInfo = new ProcessStartInfo + { + FileName = launcherPath, + WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("air-app-broker"); + startInfo.ArgumentList.Add("--requester-pid"); + startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return startInfo; + } + + private static void TryStartLauncher() + { + try + { + var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath(); + if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath)) + { + AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found."); + return; + } + + var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId); + _ = Process.Start(startInfo); + AppLogger.Info( + "AirAppLauncher", + $"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'."); + } + catch (Exception ex) + { + AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex); + } + } +} + +public static class AirAppLauncherServiceProvider +{ + private static readonly object Gate = new(); + private static IAirAppLauncherService? _instance; + + public static IAirAppLauncherService GetOrCreate() + { + lock (Gate) + { + _instance ??= new AirAppLauncherService(); + return _instance; + } + } +} diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs index 789d511..1162004 100644 --- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs +++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs @@ -124,6 +124,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService { existingWindow.Show(); } + + existingWindow.RefreshDesktopLayer(); } else { @@ -136,6 +138,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService _widgetWindows[placement.PlacementId] = window; window.Show(); window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y); + window.RefreshDesktopLayer(); } } catch (Exception ex) diff --git a/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs b/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs index 3ede26d..f2b67ed 100644 --- a/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs +++ b/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs @@ -33,7 +33,7 @@ public sealed class ComponentLibraryCategoryViewModel public ComponentLibraryCategoryViewModel( string id, string title, - Symbol icon, + Icon icon, IReadOnlyList components) { Id = id; @@ -46,7 +46,7 @@ public sealed class ComponentLibraryCategoryViewModel public string Title { get; } - public Symbol Icon { get; } + public Icon Icon { get; } public IReadOnlyList Components { get; } } diff --git a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs index e95741f..5a1716c 100644 --- a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs +++ b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs @@ -58,7 +58,9 @@ public partial class ComponentLibraryWindow : Window _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( category.Id, GetLocalizedCategoryTitle(category.Id), - ResolveCategoryIcon(category.Id), + ComponentCategoryIconResolver.ResolveCategoryIcon( + category.Id, + _componentLibraryService.GetDefinitions().Where(d => string.Equals(d.Category, category.Id, StringComparison.OrdinalIgnoreCase))), itemModels)); } @@ -176,50 +178,6 @@ public partial class ComponentLibraryWindow : Window } } - private Symbol ResolveCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Clock; - } - - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.CalendarDate; - } - - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.WeatherSunny; - } - - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Edit; - } - - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Play; - } - - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Info; - } - - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Calculator; - } - - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Hourglass; - } - - return Symbol.Apps; - } private string GetLocalizedCategoryTitle(string categoryId) { diff --git a/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs b/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs index 9e52c7f..6fbc3d2 100644 --- a/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs +++ b/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs @@ -1,4 +1,4 @@ -using LanMountainDesktop.Services; +using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; @@ -7,6 +7,11 @@ public interface IDesktopComponentWidget void ApplyCellSize(double cellSize); } +public interface IDesktopComponentLifecycleWidget +{ + void OnWidgetDestroyed(); +} + public interface ITimeZoneAwareComponentWidget { void SetTimeZoneService(TimeZoneService timeZoneService); diff --git a/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs b/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs index 7198252..f85bbec 100644 --- a/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs +++ b/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs @@ -7,6 +7,47 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; +internal readonly record struct WeatherSceneProfile( + string StyleId, + MaterialWeatherCondition Condition, + string RendererId, + string WeatherLayerId, + bool IsNight, + bool IsLive) +{ + public string Signature => $"{RendererId}:{WeatherLayerId}:{(IsNight ? "night" : "day")}:{(IsLive ? "live" : "still")}"; +} + +internal static class WeatherSceneProfileResolver +{ + public static WeatherSceneProfile Resolve(string? styleId, MaterialWeatherCondition condition, bool isNight, bool isLive) + { + var normalized = WeatherVisualStyleCatalog.Normalize(styleId); + var rendererId = normalized switch + { + WeatherVisualStyleId.Geometric => "geometric", + WeatherVisualStyleId.Breezy => "breezy", + WeatherVisualStyleId.LemonFlutter => "lemon", + _ => "google" + }; + + var layerId = condition switch + { + MaterialWeatherCondition.Clear => "clear", + MaterialWeatherCondition.PartlyCloudy => "partly-cloudy", + MaterialWeatherCondition.Cloudy => "cloudy", + MaterialWeatherCondition.Rain => "rain", + MaterialWeatherCondition.Storm => "storm", + MaterialWeatherCondition.Snow => "snow", + MaterialWeatherCondition.Fog => "fog", + MaterialWeatherCondition.Haze => "haze", + _ => "ambient" + }; + + return new WeatherSceneProfile(normalized, condition, rendererId, layerId, isNight, isLive); + } +} + public sealed class MaterialWeatherSceneControl : Control { private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(66) }; @@ -16,32 +57,37 @@ public sealed class MaterialWeatherSceneControl : Control private double _phase; private bool _isLive; private bool _isAttached; - - private static readonly Random _rng = new(42); + private bool _isNight; public MaterialWeatherSceneControl() { IsHitTestVisible = false; _timer.Tick += (_, _) => { - _phase = (_phase + 0.008) % 1d; + _phase = (_phase + 0.0065) % 1d; InvalidateVisual(); }; } - public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive) + public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive, bool isNight) { _styleId = WeatherVisualStyleCatalog.Normalize(styleId); _condition = condition; _palette = palette; _isLive = isLive; + _isNight = isNight; UpdateTimer(); InvalidateVisual(); } + public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive) + { + Apply(styleId, condition, palette, isLive, EstimateNightFromPalette(palette)); + } + public void Apply(MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive) { - Apply(_styleId, condition, palette, isLive); + Apply(_styleId, condition, palette, isLive, EstimateNightFromPalette(palette)); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) @@ -63,26 +109,29 @@ public sealed class MaterialWeatherSceneControl : Control base.Render(context); var rect = new Rect(Bounds.Size); - if (rect.Width <= 1 || rect.Height <= 1) return; + if (rect.Width <= 1 || rect.Height <= 1) + { + return; + } + var profile = WeatherSceneProfileResolver.Resolve(_styleId, _condition, _isNight, _isLive); context.DrawRectangle(CreateLinearBrush(_palette.BackgroundTop, _palette.BackgroundBottom, 0, 0, 1, 1), null, rect); using (context.PushClip(rect)) { - DrawStyleDecoration(context, rect); - - switch (_condition) + switch (profile.RendererId) { - case MaterialWeatherCondition.Rain: - case MaterialWeatherCondition.Storm: - DrawRain(context, rect, _condition == MaterialWeatherCondition.Storm); + case "geometric": + RenderGeometricScene(context, rect, profile); break; - case MaterialWeatherCondition.Snow: - DrawSnow(context, rect); + case "breezy": + RenderBreezyScene(context, rect, profile); break; - case MaterialWeatherCondition.Fog: - case MaterialWeatherCondition.Haze: - DrawFog(context, rect); + case "lemon": + RenderLemonScene(context, rect, profile); + break; + default: + RenderGoogleScene(context, rect, profile); break; } } @@ -90,287 +139,537 @@ public sealed class MaterialWeatherSceneControl : Control private void UpdateTimer() { - if (_isLive && _isAttached) _timer.Start(); - else _timer.Stop(); + if (_isLive && _isAttached) + { + _timer.Start(); + } + else + { + _timer.Stop(); + } } - private void DrawStyleDecoration(DrawingContext ctx, Rect r) + private void RenderGoogleScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { - var t = Math.Sin(_phase * Math.PI * 2d); - switch (_styleId) + var min = Math.Min(r.Width, r.Height); + var t = Oscillate(0); + + DrawSoftBlob(ctx, r.Width * 0.78 + t * 8, r.Height * 0.18 + Oscillate(0.7) * 5, min * 0.52, _palette.PrimaryShape, 0.20); + DrawSoftBlob(ctx, r.Width * 0.15 - t * 6, r.Height * 0.76, min * 0.36, _palette.SecondaryShape, 0.13); + DrawSoftBlob(ctx, r.Width * 0.58, r.Height * 0.92 - t * 7, min * 0.46, _palette.AccentShape, 0.08); + + switch (profile.Condition) { - case WeatherVisualStyleId.Geometric: - DrawGeometricDecoration(ctx, r, t); + case MaterialWeatherCondition.Clear: + case MaterialWeatherCondition.Unknown: + DrawSunDisk(ctx, r, 0.74, 0.24, 0.24, 0.32, rays: false); + DrawArc(ctx, r.Width * 0.76, r.Height * 0.24, min * 0.28, 205, 110, _palette.AccentShape, 0.12, min * 0.012); break; - case WeatherVisualStyleId.Breezy: - DrawBreezyDecoration(ctx, r, t); + case MaterialWeatherCondition.PartlyCloudy: + DrawSunDisk(ctx, r, 0.76, 0.22, 0.21, 0.25, rays: false); + DrawCloudCluster(ctx, r, 0.58 + t * 0.015, 0.38, 0.34, _palette.SurfaceTint, 0.34, filled: true); break; - case WeatherVisualStyleId.LemonFlutter: - DrawLemonDecoration(ctx, r, t); + case MaterialWeatherCondition.Cloudy: + DrawCloudCluster(ctx, r, 0.48 + t * 0.012, 0.32, 0.42, _palette.SurfaceTint, 0.36, filled: true); + DrawCloudCluster(ctx, r, 0.70 - t * 0.010, 0.52, 0.32, _palette.SecondaryShape, 0.20, filled: true); + break; + case MaterialWeatherCondition.Rain: + DrawCloudCluster(ctx, r, 0.54 + t * 0.010, 0.28, 0.38, _palette.SurfaceTint, 0.30, filled: true); + DrawRainField(ctx, r, 0.34, 0.17, _palette.AccentShape, 0.55, storm: false); + break; + case MaterialWeatherCondition.Storm: + DrawCloudCluster(ctx, r, 0.50 + t * 0.010, 0.26, 0.42, _palette.SecondaryShape, 0.34, filled: true); + DrawRainField(ctx, r, 0.36, 0.21, _palette.SurfaceTint, 0.50, storm: true); + DrawLightning(ctx, r, 0.67, 0.44, 0.22, _palette.AccentShape, LightningOpacity()); + break; + case MaterialWeatherCondition.Snow: + DrawCloudCluster(ctx, r, 0.52 + t * 0.008, 0.28, 0.36, _palette.SurfaceTint, 0.24, filled: true); + DrawSnowField(ctx, r, _palette.AccentShape, 0.68, geometric: false); + break; + case MaterialWeatherCondition.Fog: + case MaterialWeatherCondition.Haze: + DrawFogBands(ctx, r, _palette.SurfaceTint, 0.23, curved: false); + DrawSoftBlob(ctx, r.Width * 0.50, r.Height * 0.42, min * 0.44, _palette.SecondaryShape, 0.08); break; } } - private void DrawGeometricDecoration(DrawingContext ctx, Rect r, double t) + private void RenderGeometricScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { var min = Math.Min(r.Width, r.Height); + var t = Oscillate(0.2); - DrawRadialGlow(ctx, r.Width * 0.78 + t * 6, r.Height * 0.20 + t * 4, min * 0.55, _palette.PrimaryShape, 0.22, 0.0); - DrawRadialGlow(ctx, r.Width * 0.12 - t * 4, r.Height * 0.68 + t * 3, min * 0.42, _palette.SecondaryShape, 0.18, 0.0); - DrawRadialGlow(ctx, r.Width * 0.52, r.Height * 0.82 - t * 5, min * 0.32, _palette.AccentShape, 0.14, 0.0); - - DrawRadialGlow(ctx, r.Width * 0.35 + t * 3, r.Height * 0.12, min * 0.28, _palette.AccentShape, 0.08, 0.0); - DrawRadialGlow(ctx, r.Width * 0.88 - t * 2, r.Height * 0.55, min * 0.22, _palette.PrimaryShape, 0.10, 0.0); + DrawCircle(ctx, r.Width * 0.82 + t * 5, r.Height * 0.18, min * 0.33, _palette.PrimaryShape, 0.12); + DrawArc(ctx, r.Width * 0.34, r.Height * 0.52 + t * 4, min * 0.42, 25, 135, _palette.SecondaryShape, 0.18, min * 0.018); + DrawArc(ctx, r.Width * 0.72, r.Height * 0.76, min * 0.32, 198, 112, _palette.AccentShape, 0.16, min * 0.014); - DrawArcSegment(ctx, r.Width * 0.65 + t * 4, r.Height * 0.35, min * 0.38, -30, 120, _palette.SecondaryShape, 0.12, 2.5); - DrawArcSegment(ctx, r.Width * 0.25 - t * 3, r.Height * 0.50, min * 0.30, 45, 90, _palette.AccentShape, 0.10, 2); + switch (profile.Condition) + { + case MaterialWeatherCondition.Clear: + case MaterialWeatherCondition.Unknown: + DrawCircle(ctx, r.Width * 0.72, r.Height * 0.28, min * 0.21, _palette.PrimaryShape, 0.34); + DrawSunRays(ctx, r.Width * 0.72, r.Height * 0.28, min * 0.24, min * 0.38, 12, _palette.PrimaryShape, 0.18); + DrawArc(ctx, r.Width * 0.72, r.Height * 0.28, min * 0.30, -20, 230, _palette.AccentShape, 0.22, min * 0.016); + break; + case MaterialWeatherCondition.PartlyCloudy: + DrawCircle(ctx, r.Width * 0.72, r.Height * 0.24, min * 0.18, _palette.PrimaryShape, 0.25); + DrawCloudCluster(ctx, r, 0.56 + t * 0.012, 0.40, 0.34, _palette.SecondaryShape, 0.28, filled: false); + DrawCircle(ctx, r.Width * 0.49, r.Height * 0.42, min * 0.18, _palette.SurfaceTint, 0.12); + break; + case MaterialWeatherCondition.Cloudy: + DrawCloudCluster(ctx, r, 0.44 + t * 0.010, 0.34, 0.40, _palette.SecondaryShape, 0.27, filled: false); + DrawCloudCluster(ctx, r, 0.68 - t * 0.010, 0.52, 0.31, _palette.AccentShape, 0.16, filled: false); + DrawArc(ctx, r.Width * 0.58, r.Height * 0.44, min * 0.36, 190, 135, _palette.SurfaceTint, 0.19, min * 0.012); + break; + case MaterialWeatherCondition.Rain: + DrawCloudCluster(ctx, r, 0.50, 0.28, 0.38, _palette.SecondaryShape, 0.24, filled: false); + DrawGeometricRainGrid(ctx, r, _palette.AccentShape, 0.60, storm: false); + break; + case MaterialWeatherCondition.Storm: + DrawCloudCluster(ctx, r, 0.48, 0.26, 0.42, _palette.SecondaryShape, 0.24, filled: false); + DrawGeometricRainGrid(ctx, r, _palette.SurfaceTint, 0.52, storm: true); + DrawLightning(ctx, r, 0.65, 0.43, 0.26, _palette.AccentShape, LightningOpacity()); + DrawTriangle(ctx, r.Width * 0.33, r.Height * 0.68, min * 0.18, _palette.PrimaryShape, 0.12, rotate: 0.35); + break; + case MaterialWeatherCondition.Snow: + DrawCloudCluster(ctx, r, 0.50, 0.28, 0.36, _palette.SecondaryShape, 0.18, filled: false); + DrawSnowField(ctx, r, _palette.AccentShape, 0.72, geometric: true); + break; + case MaterialWeatherCondition.Fog: + case MaterialWeatherCondition.Haze: + DrawFogBands(ctx, r, _palette.SurfaceTint, 0.25, curved: false); + DrawArc(ctx, r.Width * 0.44, r.Height * 0.50, min * 0.36, 0, 180, _palette.SecondaryShape, 0.16, min * 0.016); + DrawArc(ctx, r.Width * 0.64, r.Height * 0.62, min * 0.30, 180, 170, _palette.AccentShape, 0.12, min * 0.012); + break; + } } - private void DrawBreezyDecoration(DrawingContext ctx, Rect r, double t) + private void RenderBreezyScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { var min = Math.Min(r.Width, r.Height); + var t = Oscillate(0.4); - DrawRadialGlow(ctx, r.Width * 0.72 + t * 5, r.Height * 0.25 + t * 3, min * 0.48, _palette.PrimaryShape, 0.20, 0.0); - DrawRadialGlow(ctx, r.Width * 0.20 - t * 4, r.Height * 0.60 + t * 4, min * 0.36, _palette.SecondaryShape, 0.16, 0.0); - DrawRadialGlow(ctx, r.Width * 0.50, r.Height * 0.80 - t * 3, min * 0.28, _palette.AccentShape, 0.12, 0.0); + DrawSoftBlob(ctx, r.Width * 0.76 + t * 7, r.Height * 0.18, min * 0.48, _palette.PrimaryShape, 0.18); + DrawSoftBlob(ctx, r.Width * 0.18 - t * 5, r.Height * 0.62, min * 0.42, _palette.SecondaryShape, 0.12); + DrawWaveField(ctx, r, _palette.SurfaceTint, 0.11, 4, amplitudeScale: 1.0); - for (var i = 0; i < 4; i++) + switch (profile.Condition) { - var y = r.Height * (0.25 + i * 0.18); - var shift = Math.Sin(_phase * Math.PI * 2 + i * 1.1) * r.Width * 0.05; - DrawWaveLine(ctx, r, y, shift, i, _palette.SurfaceTint, 0.10 + i * 0.02); + case MaterialWeatherCondition.Clear: + case MaterialWeatherCondition.Unknown: + DrawSunDisk(ctx, r, 0.72, 0.28, 0.23, 0.24, rays: false); + DrawWaveField(ctx, r, _palette.AccentShape, 0.12, 3, amplitudeScale: 0.75); + DrawArc(ctx, r.Width * 0.76, r.Height * 0.28, min * 0.30, 205, 145, _palette.PrimaryShape, 0.16, min * 0.012); + break; + case MaterialWeatherCondition.PartlyCloudy: + DrawSunDisk(ctx, r, 0.73, 0.24, 0.18, 0.18, rays: false); + DrawBreezyCloudBands(ctx, r, yBase: 0.42, density: 3, alpha: 0.24); + DrawWaveField(ctx, r, _palette.AccentShape, 0.10, 3, amplitudeScale: 0.65); + break; + case MaterialWeatherCondition.Cloudy: + DrawBreezyCloudBands(ctx, r, yBase: 0.30, density: 5, alpha: 0.26); + DrawSoftBlob(ctx, r.Width * 0.58, r.Height * 0.44, min * 0.35, _palette.SurfaceTint, 0.14); + break; + case MaterialWeatherCondition.Rain: + DrawBreezyCloudBands(ctx, r, yBase: 0.26, density: 4, alpha: 0.26); + DrawRainBands(ctx, r, _palette.AccentShape, 0.48, storm: false); + DrawWaveField(ctx, r, _palette.SecondaryShape, 0.14, 4, amplitudeScale: 1.25); + break; + case MaterialWeatherCondition.Storm: + DrawBreezyCloudBands(ctx, r, yBase: 0.24, density: 5, alpha: 0.30); + DrawRainBands(ctx, r, _palette.SurfaceTint, 0.48, storm: true); + DrawLightning(ctx, r, 0.64, 0.42, 0.23, _palette.AccentShape, LightningOpacity()); + DrawWaveField(ctx, r, _palette.AccentShape, 0.16, 5, amplitudeScale: 1.35); + break; + case MaterialWeatherCondition.Snow: + DrawBreezyCloudBands(ctx, r, yBase: 0.28, density: 3, alpha: 0.20); + DrawSnowField(ctx, r, _palette.AccentShape, 0.68, geometric: true); + DrawWaveField(ctx, r, Colors.White, 0.13, 3, amplitudeScale: 0.85); + break; + case MaterialWeatherCondition.Fog: + case MaterialWeatherCondition.Haze: + DrawFogBands(ctx, r, _palette.SurfaceTint, 0.28, curved: true); + DrawWaveField(ctx, r, _palette.SecondaryShape, 0.18, 5, amplitudeScale: 0.55); + break; } - - DrawArcSegment(ctx, r.Width * 0.80 + t * 3, r.Height * 0.15, min * 0.25, 0, 180, _palette.PrimaryShape, 0.08, 1.5); - DrawArcSegment(ctx, r.Width * 0.15 - t * 2, r.Height * 0.75, min * 0.20, 90, 180, _palette.AccentShape, 0.08, 1.5); } - private void DrawLemonDecoration(DrawingContext ctx, Rect r, double t) + private void RenderLemonScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { var min = Math.Min(r.Width, r.Height); + var t = Oscillate(0.6); + + DrawSoftBlob(ctx, r.Width * 0.78 + t * 6, r.Height * 0.20, min * 0.45, _palette.PrimaryShape, 0.18); + DrawCircle(ctx, r.Width * 0.18, r.Height * 0.78 - t * 5, min * 0.20, _palette.SecondaryShape, 0.13); + DrawCircle(ctx, r.Width * 0.88, r.Height * 0.64, min * 0.16, _palette.AccentShape, 0.10); - switch (_condition) + switch (profile.Condition) { case MaterialWeatherCondition.Clear: - case MaterialWeatherCondition.PartlyCloudy: case MaterialWeatherCondition.Unknown: - DrawSunScene(ctx, r, min, t); + DrawSunDisk(ctx, r, 0.70, 0.30, 0.23, 0.30, rays: true); + DrawCircle(ctx, r.Width * 0.36, r.Height * 0.30, min * 0.07, _palette.SecondaryShape, 0.16); + break; + case MaterialWeatherCondition.PartlyCloudy: + DrawSunDisk(ctx, r, 0.73, 0.24, 0.20, 0.24, rays: true); + DrawCloudCluster(ctx, r, 0.56 + t * 0.012, 0.40, 0.34, _palette.SurfaceTint, 0.30, filled: true); break; case MaterialWeatherCondition.Cloudy: - DrawCloudScene(ctx, r, min, t); + DrawCloudCluster(ctx, r, 0.48 + t * 0.012, 0.34, 0.42, _palette.SurfaceTint, 0.31, filled: true); + DrawCloudCluster(ctx, r, 0.70 - t * 0.010, 0.53, 0.28, _palette.SecondaryShape, 0.18, filled: true); + DrawCircle(ctx, r.Width * 0.28, r.Height * 0.44, min * 0.08, _palette.AccentShape, 0.12); break; case MaterialWeatherCondition.Rain: + DrawCloudCluster(ctx, r, 0.52, 0.28, 0.40, _palette.SurfaceTint, 0.28, filled: true); + DrawRainField(ctx, r, 0.36, 0.18, _palette.AccentShape, 0.55, storm: false); + DrawCircle(ctx, r.Width * 0.23, r.Height * 0.72, min * 0.09, _palette.PrimaryShape, 0.12); + break; case MaterialWeatherCondition.Storm: - DrawRainScene(ctx, r, min, t); + DrawCloudCluster(ctx, r, 0.50, 0.26, 0.42, _palette.SurfaceTint, 0.30, filled: true); + DrawRainField(ctx, r, 0.36, 0.22, _palette.SecondaryShape, 0.52, storm: true); + DrawLightning(ctx, r, 0.66, 0.42, 0.24, _palette.AccentShape, LightningOpacity()); break; case MaterialWeatherCondition.Snow: - DrawSnowScene(ctx, r, min, t); + DrawCloudCluster(ctx, r, 0.52, 0.30, 0.38, _palette.SurfaceTint, 0.22, filled: true); + DrawSnowField(ctx, r, _palette.AccentShape, 0.72, geometric: true); break; - default: - DrawSunScene(ctx, r, min, t); + case MaterialWeatherCondition.Fog: + case MaterialWeatherCondition.Haze: + DrawFogBands(ctx, r, _palette.SurfaceTint, 0.26, curved: true); + DrawCircle(ctx, r.Width * 0.70, r.Height * 0.28, min * 0.16, _palette.SecondaryShape, 0.10); break; } + } - DrawRadialGlow(ctx, r.Width * 0.15 - t * 3, r.Height * 0.70 + t * 4, min * 0.30, _palette.SecondaryShape, 0.10, 0.0); - DrawRadialGlow(ctx, r.Width * 0.85 + t * 2, r.Height * 0.55 - t * 3, min * 0.22, _palette.AccentShape, 0.08, 0.0); + private void DrawSunDisk(DrawingContext ctx, Rect r, double nx, double ny, double radiusScale, double alpha, bool rays) + { + var min = Math.Min(r.Width, r.Height); + var cx = r.Width * nx + Oscillate(0.1) * min * 0.015; + var cy = r.Height * ny + Oscillate(0.9) * min * 0.012; + var radius = min * radiusScale; + + DrawSoftBlob(ctx, cx, cy, radius * 1.85, _palette.PrimaryShape, alpha * 0.55); + DrawCircle(ctx, cx, cy, radius, _palette.PrimaryShape, alpha); + DrawCircle(ctx, cx - radius * 0.25, cy - radius * 0.28, radius * 0.36, _palette.AccentShape, alpha * 0.32); + if (rays) + { + DrawSunRays(ctx, cx, cy, radius * 1.05, radius * 1.78, 14, _palette.PrimaryShape, alpha * 0.38); + } } - private void DrawSunScene(DrawingContext ctx, Rect r, double min, double t) + private void DrawCloudCluster(DrawingContext ctx, Rect r, double nx, double ny, double scale, Color color, double alpha, bool filled) { - var cx = r.Width * 0.70; - var cy = r.Height * 0.25; + var min = Math.Min(r.Width, r.Height); + var cx = r.Width * nx; + var cy = r.Height * ny; + var brush = filled ? new SolidColorBrush(color, alpha) : null; + var pen = filled ? null : new Pen(new SolidColorBrush(color, alpha), Math.Max(1.4, min * 0.012), lineCap: PenLineCap.Round); + var radius = min * scale; - DrawRadialGlow(ctx, cx, cy, min * 0.35, _palette.PrimaryShape, 0.28, 0.0); - DrawRadialGlow(ctx, cx, cy, min * 0.18, _palette.PrimaryShape, 0.45, 0.10); + DrawEllipse(ctx, brush, pen, cx - radius * 0.34, cy + radius * 0.04, radius * 0.34, radius * 0.18); + DrawEllipse(ctx, brush, pen, cx, cy - radius * 0.06, radius * 0.42, radius * 0.24); + DrawEllipse(ctx, brush, pen, cx + radius * 0.34, cy + radius * 0.08, radius * 0.30, radius * 0.17); - var rayCount = 14; - var pen = new Pen(new SolidColorBrush(_palette.PrimaryShape, 0.18), Math.Max(2, min * 0.012), lineCap: PenLineCap.Round); - for (var i = 0; i < rayCount; i++) + if (filled) { - var angle = (i / (double)rayCount) * Math.PI * 2 + t * 0.25; - var innerR = min * 0.16; - var outerR = min * 0.30 + Math.Sin(angle * 3 + t * 2) * min * 0.04; - ctx.DrawLine(pen, - new Point(cx + Math.Cos(angle) * innerR, cy + Math.Sin(angle) * innerR), - new Point(cx + Math.Cos(angle) * outerR, cy + Math.Sin(angle) * outerR)); + var baseRect = new Rect(cx - radius * 0.66, cy + radius * 0.04, radius * 1.24, radius * 0.25); + ctx.DrawRectangle(new SolidColorBrush(color, alpha * 0.78), null, baseRect, radius * 0.12, radius * 0.12); } } - private void DrawCloudScene(DrawingContext ctx, Rect r, double min, double t) + private void DrawBreezyCloudBands(DrawingContext ctx, Rect r, double yBase, int density, double alpha) { - DrawRadialGlow(ctx, r.Width * 0.60 + t * 5, r.Height * 0.30, min * 0.40, _palette.PrimaryShape, 0.16, 0.0); - DrawRadialGlow(ctx, r.Width * 0.35 - t * 3, r.Height * 0.55, min * 0.32, _palette.SecondaryShape, 0.12, 0.0); - - var pen = new Pen(new SolidColorBrush(_palette.PrimaryShape, 0.14), Math.Max(1.5, min * 0.010), lineCap: PenLineCap.Round); - var drift = t * 6; - - DrawCloudOutline(ctx, r.Width * 0.42 + drift, r.Height * 0.32, min * 0.18, min * 0.12, pen); - DrawCloudOutline(ctx, r.Width * 0.58 + drift * 0.7, r.Height * 0.26, min * 0.22, min * 0.15, pen); - DrawCloudOutline(ctx, r.Width * 0.72 + drift * 0.5, r.Height * 0.35, min * 0.14, min * 0.10, pen); + var min = Math.Min(r.Width, r.Height); + for (var i = 0; i < density; i++) + { + var y = r.Height * (yBase + i * 0.085); + var shift = Oscillate(i * 0.32) * r.Width * 0.035; + var thickness = Math.Max(8, min * (0.075 - i * 0.006)); + var brush = new SolidColorBrush(i % 2 == 0 ? _palette.SurfaceTint : _palette.SecondaryShape, alpha * (1 - i * 0.10)); + ctx.DrawRectangle( + brush, + null, + new Rect(r.Width * (0.06 + i * 0.025) + shift, y, r.Width * (0.84 - i * 0.055), thickness), + thickness * 0.5, + thickness * 0.5); + } } - private void DrawRainScene(DrawingContext ctx, Rect r, double min, double t) + private void DrawRainField(DrawingContext ctx, Rect r, double startY, double densityScale, Color color, double alpha, bool storm) { - DrawRadialGlow(ctx, r.Width * 0.65 + t * 4, r.Height * 0.25, min * 0.38, _palette.PrimaryShape, 0.14, 0.0); - DrawRadialGlow(ctx, r.Width * 0.30 - t * 3, r.Height * 0.50, min * 0.30, _palette.SecondaryShape, 0.10, 0.0); + var count = Math.Clamp((int)(r.Width * densityScale), 8, storm ? 32 : 24); + var pen = new Pen(new SolidColorBrush(color, alpha), Math.Max(1.2, r.Width / 160), lineCap: PenLineCap.Round); + for (var i = 0; i < count; i++) + { + var p = (_phase * (storm ? 1.4 : 0.95) + i * 0.137) % 1d; + var lane = (i + 0.37 * (i % 3)) / count; + var x = r.Width * (0.08 + lane * 0.84); + var y = r.Height * (startY + p * 0.74); + var dx = -r.Width * (storm ? 0.040 : 0.026); + var dy = r.Height * (storm ? 0.13 : 0.095); + ctx.DrawLine(pen, new Point(x, y), new Point(x + dx, y + dy)); + } + } - var pen = new Pen(new SolidColorBrush(_palette.PrimaryShape, 0.10), Math.Max(1, r.Width / 200), lineCap: PenLineCap.Round); - var streaks = Math.Clamp((int)(r.Width / 28), 6, 16); - for (var i = 0; i < streaks; i++) + private void DrawGeometricRainGrid(DrawingContext ctx, Rect r, Color color, double alpha, bool storm) + { + var min = Math.Min(r.Width, r.Height); + var count = Math.Clamp((int)(r.Width / 18), 9, storm ? 28 : 22); + var pen = new Pen(new SolidColorBrush(color, alpha), Math.Max(1.3, min * 0.009), lineCap: PenLineCap.Square); + for (var i = 0; i < count; i++) { - var progress = (_phase * 0.5 + i * 0.12) % 1d; - var x = r.Width * (0.12 + (i % streaks) / (double)streaks * 0.78); - var y = r.Height * (0.15 + progress * 0.75); - var len = r.Height * 0.08; - ctx.DrawLine(pen, new Point(x, y), new Point(x - r.Width * 0.018, y + len)); + var p = (_phase * (storm ? 1.15 : 0.75) + i * 0.091) % 1d; + var x = r.Width * (0.12 + (i / (double)count) * 0.78); + var y = r.Height * (0.36 + p * 0.58); + ctx.DrawLine(pen, new Point(x, y), new Point(x - min * 0.075, y + min * 0.145)); } } - private void DrawSnowScene(DrawingContext ctx, Rect r, double min, double t) + private void DrawRainBands(DrawingContext ctx, Rect r, Color color, double alpha, bool storm) { - DrawRadialGlow(ctx, r.Width * 0.68 + t * 3, r.Height * 0.22, min * 0.35, _palette.PrimaryShape, 0.16, 0.0); - DrawRadialGlow(ctx, r.Width * 0.25 - t * 2, r.Height * 0.55, min * 0.28, _palette.AccentShape, 0.10, 0.0); + var min = Math.Min(r.Width, r.Height); + var count = Math.Clamp((int)(r.Width / 22), 8, storm ? 26 : 20); + var pen = new Pen(new SolidColorBrush(color, alpha), Math.Max(2.2, min * 0.014), lineCap: PenLineCap.Round); + for (var i = 0; i < count; i++) + { + var p = (_phase * (storm ? 1.35 : 0.85) + i * 0.118) % 1d; + var x = r.Width * (0.10 + (i / (double)count) * 0.86); + var y = r.Height * (0.34 + p * 0.62); + ctx.DrawLine(pen, new Point(x, y), new Point(x - min * 0.09, y + min * 0.16)); + } + } - var cx = r.Width * 0.72; - var cy = r.Height * 0.28; - var sr = min * 0.12; - var pen = new Pen(new SolidColorBrush(_palette.PrimaryShape, 0.16), Math.Max(1.2, min * 0.008), lineCap: PenLineCap.Round); - for (var i = 0; i < 6; i++) + private void DrawSnowField(DrawingContext ctx, Rect r, Color color, double alpha, bool geometric) + { + var min = Math.Min(r.Width, r.Height); + var count = Math.Clamp((int)(r.Width / 22), 8, 24); + var brush = new SolidColorBrush(color, alpha); + var pen = new Pen(brush, Math.Max(1.1, min * 0.007), lineCap: PenLineCap.Round); + for (var i = 0; i < count; i++) { - var a = (i / 6d) * Math.PI * 2 + t * 0.15; - var ex = cx + Math.Cos(a) * sr; - var ey = cy + Math.Sin(a) * sr; - ctx.DrawLine(pen, new Point(cx, cy), new Point(ex, ey)); - var br = sr * 0.35; - var mx = cx + Math.Cos(a) * sr * 0.6; - var my = cy + Math.Sin(a) * sr * 0.6; - ctx.DrawLine(pen, new Point(mx, my), new Point(mx + Math.Cos(a + 0.5) * br, my + Math.Sin(a + 0.5) * br)); - ctx.DrawLine(pen, new Point(mx, my), new Point(mx + Math.Cos(a - 0.5) * br, my + Math.Sin(a - 0.5) * br)); + var p = (_phase * 0.45 + i * 0.119) % 1d; + var x = r.Width * (0.10 + (i / (double)count) * 0.82) + Math.Sin(p * Math.PI * 2 + i) * min * 0.025; + var y = r.Height * (0.22 + p * 0.78); + if (geometric && i % 3 == 0) + { + DrawSnowflake(ctx, x, y, min * 0.025, pen); + } + else + { + ctx.DrawEllipse(brush, null, new Point(x, y), Math.Max(1.8, min * 0.012), Math.Max(1.8, min * 0.012)); + } } } - private void DrawRadialGlow(DrawingContext ctx, double cx, double cy, double radius, Color baseColor, double peakAlpha, double centerBoost) + private void DrawFogBands(DrawingContext ctx, Rect r, Color color, double alpha, bool curved) { - if (radius < 1) return; + var min = Math.Min(r.Width, r.Height); + var count = 5; + for (var i = 0; i < count; i++) + { + var y = r.Height * (0.35 + i * 0.105); + var shift = Oscillate(i * 0.25) * r.Width * 0.045; + var pen = new Pen(new SolidColorBrush(color, alpha * (1 - i * 0.08)), Math.Max(2.2, min * 0.015), lineCap: PenLineCap.Round); + if (curved) + { + DrawWavePath(ctx, r.Width * 0.10 + shift, y, r.Width * 0.82, min * 0.020, i, pen); + } + else + { + ctx.DrawLine(pen, new Point(r.Width * 0.12 + shift, y), new Point(r.Width * 0.88 + shift, y)); + } + } + } - var peak = (byte)Math.Clamp(peakAlpha * 255, 0, 255); - var edge = (byte)0; - var center = (byte)Math.Clamp(centerBoost * 255, 0, 255); + private void DrawWaveField(DrawingContext ctx, Rect r, Color color, double alpha, int lines, double amplitudeScale) + { + var min = Math.Min(r.Width, r.Height); + for (var i = 0; i < lines; i++) + { + var y = r.Height * (0.22 + i * 0.16); + var shift = Oscillate(i * 0.22) * r.Width * 0.06; + var pen = new Pen(new SolidColorBrush(color, alpha * (1 - i * 0.06)), Math.Max(1.6, min * 0.010), lineCap: PenLineCap.Round); + DrawWavePath(ctx, r.Width * 0.06 + shift, y, r.Width * 0.88, min * 0.030 * amplitudeScale, i, pen); + } + } - var brush = new RadialGradientBrush + private void DrawWavePath(DrawingContext ctx, double startX, double baseY, double width, double amplitude, int index, Pen pen) + { + var stream = new StreamGeometry(); + using (var g = stream.Open()) { - Center = new RelativePoint(0.5, 0.5, RelativeUnit.Relative), - GradientStops = + g.BeginFigure(new Point(startX, baseY), false); + var step = Math.Max(3, width / 48); + for (var x = 0d; x <= width; x += step) { - new GradientStop(new Color(Math.Clamp((byte)(peak + center), (byte)0, (byte)255), baseColor.R, baseColor.G, baseColor.B), 0), - new GradientStop(new Color((byte)(peak * 0.6), baseColor.R, baseColor.G, baseColor.B), 0.4), - new GradientStop(new Color(edge, baseColor.R, baseColor.G, baseColor.B), 1) + var y = baseY + Math.Sin((x / width) * Math.PI * 3.2 + _phase * Math.PI * 2 + index * 0.85) * amplitude; + g.LineTo(new Point(startX + x, y)); } - }; + g.EndFigure(false); + } - ctx.DrawEllipse(brush, null, new Point(cx, cy), radius, radius); + ctx.DrawGeometry(null, pen, stream); } - private void DrawArcSegment(DrawingContext ctx, double cx, double cy, double radius, double startDeg, double sweepDeg, Color color, double alpha, double thickness) + private void DrawLightning(DrawingContext ctx, Rect r, double nx, double ny, double scale, Color color, double alpha) { - if (radius < 2) return; + var min = Math.Min(r.Width, r.Height); + var cx = r.Width * nx; + var cy = r.Height * ny; + var s = min * scale; + var bolt = new StreamGeometry(); + using (var g = bolt.Open()) + { + g.BeginFigure(new Point(cx, cy), true); + g.LineTo(new Point(cx - s * 0.28, cy + s * 0.46)); + g.LineTo(new Point(cx - s * 0.03, cy + s * 0.40)); + g.LineTo(new Point(cx - s * 0.36, cy + s * 0.98)); + g.LineTo(new Point(cx + s * 0.18, cy + s * 0.25)); + g.LineTo(new Point(cx - s * 0.05, cy + s * 0.31)); + g.EndFigure(true); + } - var pen = new Pen(new SolidColorBrush(color, (float)alpha), thickness, lineCap: PenLineCap.Round); + ctx.DrawGeometry(new SolidColorBrush(color, alpha), null, bolt); + } - var stream = new StreamGeometry(); - var g = stream.Open(); + private void DrawSunRays(DrawingContext ctx, double cx, double cy, double inner, double outer, int count, Color color, double alpha) + { + var pen = new Pen(new SolidColorBrush(color, alpha), Math.Max(1.4, inner * 0.055), lineCap: PenLineCap.Round); + for (var i = 0; i < count; i++) + { + var angle = (i / (double)count) * Math.PI * 2 + _phase * 0.45; + var outRadius = outer + Math.Sin(angle * 2.4 + _phase * Math.PI * 2) * inner * 0.16; + ctx.DrawLine( + pen, + new Point(cx + Math.Cos(angle) * inner, cy + Math.Sin(angle) * inner), + new Point(cx + Math.Cos(angle) * outRadius, cy + Math.Sin(angle) * outRadius)); + } + } - var startRad = startDeg * Math.PI / 180d; - var sweepRad = sweepDeg * Math.PI / 180d; - var steps = Math.Max(8, (int)(sweepDeg / 5)); + private void DrawSnowflake(DrawingContext ctx, double cx, double cy, double radius, Pen pen) + { + for (var i = 0; i < 6; i++) + { + var a = (i / 6d) * Math.PI * 2 + _phase * 0.35; + ctx.DrawLine(pen, new Point(cx - Math.Cos(a) * radius * 0.45, cy - Math.Sin(a) * radius * 0.45), new Point(cx + Math.Cos(a) * radius, cy + Math.Sin(a) * radius)); + } + } - g.BeginFigure(new Point(cx + Math.Cos(startRad) * radius, cy + Math.Sin(startRad) * radius), false); - for (var i = 1; i <= steps; i++) + private void DrawTriangle(DrawingContext ctx, double cx, double cy, double radius, Color color, double alpha, double rotate) + { + var triangle = new StreamGeometry(); + using (var g = triangle.Open()) { - var a = startRad + sweepRad * (i / (double)steps); - g.LineTo(new Point(cx + Math.Cos(a) * radius, cy + Math.Sin(a) * radius)); + for (var i = 0; i < 3; i++) + { + var a = rotate + (i / 3d) * Math.PI * 2; + var p = new Point(cx + Math.Cos(a) * radius, cy + Math.Sin(a) * radius); + if (i == 0) + { + g.BeginFigure(p, true); + } + else + { + g.LineTo(p); + } + } + + g.EndFigure(true); } - g.EndFigure(false); - ctx.DrawGeometry(null, pen, stream); + ctx.DrawGeometry(new SolidColorBrush(color, alpha), null, triangle); } - private void DrawWaveLine(DrawingContext ctx, Rect r, double baseY, double shift, int index, Color color, double alpha) + private void DrawCircle(DrawingContext ctx, double cx, double cy, double radius, Color color, double alpha) { - var pen = new Pen(new SolidColorBrush(color, (float)alpha), Math.Max(1.5, r.Width / 100), lineCap: PenLineCap.Round); - var startX = r.Width * 0.05 + shift; - var endX = r.Width * 0.95 + shift; + if (radius <= 0) + { + return; + } - var stream = new StreamGeometry(); - var g = stream.Open(); - g.BeginFigure(new Point(startX, baseY), false); - for (var x = startX; x <= endX; x += 3) + ctx.DrawEllipse(new SolidColorBrush(color, alpha), null, new Point(cx, cy), radius, radius); + } + + private void DrawSoftBlob(DrawingContext ctx, double cx, double cy, double radius, Color color, double peakAlpha) + { + if (radius <= 0) { - var waveY = baseY + Math.Sin((x - startX) / (endX - startX) * Math.PI * 3 + _phase * Math.PI * 2 + index * 1.3) * (5 + index * 2.5); - g.LineTo(new Point(x, waveY)); + return; } - g.EndFigure(false); - ctx.DrawGeometry(null, pen, stream); + + var brush = new RadialGradientBrush + { + Center = new RelativePoint(0.5, 0.5, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(WithAlpha(color, peakAlpha), 0), + new GradientStop(WithAlpha(color, peakAlpha * 0.52), 0.42), + new GradientStop(WithAlpha(color, 0), 1) + } + }; + + ctx.DrawEllipse(brush, null, new Point(cx, cy), radius, radius); } - private void DrawCloudOutline(DrawingContext ctx, double cx, double cy, double rx, double ry, Pen pen) + private static void DrawEllipse(DrawingContext ctx, IBrush? brush, Pen? pen, double cx, double cy, double rx, double ry) { - ctx.DrawEllipse(null, pen, new Point(cx, cy), rx, ry); - ctx.DrawEllipse(null, pen, new Point(cx + rx * 0.6, cy - ry * 0.3), rx * 0.7, ry * 0.7); - ctx.DrawEllipse(null, pen, new Point(cx - rx * 0.4, cy + ry * 0.2), rx * 0.5, ry * 0.5); + ctx.DrawEllipse(brush, pen, new Point(cx, cy), Math.Max(0.1, rx), Math.Max(0.1, ry)); } - private void DrawRain(DrawingContext ctx, Rect rect, bool storm) + private void DrawArc(DrawingContext ctx, double cx, double cy, double radius, double startDeg, double sweepDeg, Color color, double alpha, double thickness) { - var drops = Math.Clamp((int)(rect.Width / 22), 8, 22); - var brush = new SolidColorBrush(_palette.AccentShape, storm ? 0.72 : 0.52); - var pen = new Pen(brush, Math.Max(1.4, rect.Width / 150), lineCap: PenLineCap.Round); - for (var i = 0; i < drops; i++) + if (radius < 2) { - var t = (_phase + i * 0.137) % 1d; - var x = rect.Width * (0.18 + (i % drops) / (double)drops * 0.72); - var y = rect.Height * (0.36 + t * 0.66); - ctx.DrawLine(pen, new Point(x, y), new Point(x - rect.Width * 0.025, y + rect.Height * 0.09)); + return; } - if (storm) + var stream = new StreamGeometry(); + using (var g = stream.Open()) { - var bolt = new StreamGeometry(); - var g = bolt.Open(); - g.BeginFigure(new Point(rect.Width * 0.70, rect.Height * 0.42), true); - g.LineTo(new Point(rect.Width * 0.61, rect.Height * 0.64)); - g.LineTo(new Point(rect.Width * 0.69, rect.Height * 0.61)); - g.LineTo(new Point(rect.Width * 0.58, rect.Height * 0.86)); - g.EndFigure(true); - ctx.DrawGeometry(new SolidColorBrush(_palette.AccentShape, 0.86), null, bolt); + var startRad = startDeg * Math.PI / 180d; + var sweepRad = sweepDeg * Math.PI / 180d; + var steps = Math.Max(10, (int)(Math.Abs(sweepDeg) / 4)); + g.BeginFigure(new Point(cx + Math.Cos(startRad) * radius, cy + Math.Sin(startRad) * radius), false); + for (var i = 1; i <= steps; i++) + { + var a = startRad + sweepRad * (i / (double)steps); + g.LineTo(new Point(cx + Math.Cos(a) * radius, cy + Math.Sin(a) * radius)); + } + + g.EndFigure(false); } + + ctx.DrawGeometry(null, new Pen(new SolidColorBrush(color, alpha), Math.Max(1, thickness), lineCap: PenLineCap.Round), stream); } - private void DrawSnow(DrawingContext ctx, Rect rect) + private double Oscillate(double offset) { - var flakes = Math.Clamp((int)(rect.Width / 24), 7, 20); - var brush = new SolidColorBrush(Colors.White, 0.72); - for (var i = 0; i < flakes; i++) - { - var t = (_phase * 0.45 + i * 0.113) % 1d; - var x = rect.Width * (0.12 + (i % flakes) / (double)flakes * 0.78) + Math.Sin(t * Math.PI * 2) * 8; - var y = rect.Height * (0.20 + t * 0.82); - ctx.DrawEllipse(brush, null, new Point(x, y), 2.2, 2.2); - } + return Math.Sin((_phase + offset) * Math.PI * 2d); } - private void DrawFog(DrawingContext ctx, Rect rect) + private double LightningOpacity() { - var pen = new Pen(new SolidColorBrush(_palette.TextSecondary, 0.28), Math.Max(2, rect.Height / 56), lineCap: PenLineCap.Round); - for (var i = 0; i < 4; i++) + if (!_isLive) { - var y = rect.Height * (0.48 + i * 0.11); - var shift = Math.Sin(_phase * Math.PI * 2 + i) * rect.Width * 0.04; - ctx.DrawLine(pen, new Point(rect.Width * 0.18 + shift, y), new Point(rect.Width * 0.82 + shift, y)); + return 0.58; } + + var pulse = Math.Pow(Math.Max(0, Math.Sin((_phase * 2.8 + 0.15) * Math.PI * 2)), 7); + return 0.42 + pulse * 0.46; + } + + private static bool EstimateNightFromPalette(MaterialWeatherPalette palette) + { + static double Luma(Color color) => (0.2126 * color.R + 0.7152 * color.G + 0.0722 * color.B) / 255d; + return (Luma(palette.BackgroundTop) + Luma(palette.BackgroundBottom)) * 0.5 < 0.36; + } + + private static Color WithAlpha(Color color, double alpha) + { + return new Color((byte)Math.Clamp(alpha * 255, 0, 255), color.R, color.G, color.B); } - private IBrush CreateLinearBrush(Color top, Color bottom, double sx, double sy, double ex, double ey) + private static IBrush CreateLinearBrush(Color top, Color bottom, double sx, double sy, double ex, double ey) { return new LinearGradientBrush { diff --git a/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs b/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs index 0ddac0a..d3c9f6e 100644 --- a/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/MusicControlWidget.axaml.cs @@ -15,7 +15,7 @@ using LanMountainDesktop.ViewModels; namespace LanMountainDesktop.Views.Components; -public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable { private readonly DispatcherTimer _refreshTimer = new() { @@ -28,6 +28,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, private double _currentCellSize = 48; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isDisposed; public MusicControlWidget() { @@ -44,6 +45,19 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, ApplyViewModel(); } + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _refreshTimer.Stop(); + _viewModel.PropertyChanged -= OnViewModelPropertyChanged; + _viewModel.Dispose(); + } + public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); diff --git a/LanMountainDesktop/Views/Components/WeatherWidgetBase.cs b/LanMountainDesktop/Views/Components/WeatherWidgetBase.cs index 9b641cb..37d3349 100644 --- a/LanMountainDesktop/Views/Components/WeatherWidgetBase.cs +++ b/LanMountainDesktop/Views/Components/WeatherWidgetBase.cs @@ -71,6 +71,8 @@ public abstract class WeatherWidgetBase : UserControl, protected string CurrentVisualStyleId { get; private set; } = WeatherVisualStyleId.Default; + protected bool CurrentIsNight { get; private set; } + protected bool IsLiveRenderMode => _renderMode == DesktopComponentRenderMode.Live; protected double CurrentCellSize => _cellSize; @@ -200,7 +202,7 @@ public abstract class WeatherWidgetBase : UserControl, protected void ApplyCurrentScene() { - SceneControl.Apply(CurrentVisualStyleId, CurrentCondition, CurrentPalette, IsLiveRenderMode && _isAttached && _isOnActivePage && !_isEditMode); + SceneControl.Apply(CurrentVisualStyleId, CurrentCondition, CurrentPalette, IsLiveRenderMode && _isAttached && _isOnActivePage && !_isEditMode, CurrentIsNight); } protected string ResolveIconKey(int? weatherCode, string? weatherText, bool isDaylight = true) @@ -320,6 +322,7 @@ public abstract class WeatherWidgetBase : UserControl, : _settingsFacade.Theme.Get().IsNightMode; CurrentVisualStyleId = WeatherVisualStyleCatalog.Normalize(_settingsFacade.Weather.Get().IconPackId); CurrentCondition = MaterialWeatherVisualTheme.ResolveCondition(snapshot); + CurrentIsNight = isNight; CurrentPalette = MaterialWeatherVisualTheme.ResolvePalette(CurrentVisualStyleId, CurrentCondition, isNight); ApplyCurrentScene(); RenderWeather(); @@ -361,6 +364,8 @@ public abstract class WeatherWidgetBase : UserControl, } CurrentVisualStyleId = WeatherVisualStyleCatalog.Normalize(_settingsFacade.Weather.Get().IconPackId); + CurrentPalette = MaterialWeatherVisualTheme.ResolvePalette(CurrentVisualStyleId, CurrentCondition, CurrentIsNight); + ApplyCurrentScene(); RenderWeather(); } diff --git a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs index 2b5a627..6ace53e 100644 --- a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs @@ -16,6 +16,7 @@ using Avalonia.Threading; using DotNetCampus.Inking; using DotNetCampus.Inking.Primitive; using FluentIcons.Avalonia; +using FluentIcons.Common; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Models; using LanMountainDesktop.Services; @@ -23,6 +24,12 @@ using SkiaSharp; namespace LanMountainDesktop.Views.Components; +public enum WhiteboardWidgetSurfaceMode +{ + Component, + AirApp +} + public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable { private enum WhiteboardToolMode @@ -64,6 +71,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC private bool _noteDirty; private int _noteSaveRevision; private int _noteLoadRevision; + private WhiteboardWidgetSurfaceMode _surfaceMode = WhiteboardWidgetSurfaceMode.Component; + private Action? _airAppCloseAction; private bool _disposed; public WhiteboardWidget() @@ -190,7 +199,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC ComponentChromeCornerRadiusHelper.SafeValue(toolbarPaddingVertical, 4, 8)); ToolbarButtonsPanel.Spacing = toolbarSpacing; - foreach (var button in new[] { PenButton, EraserButton, HandButton, ClearButton, FileButton }) + foreach (var button in new[] { PenButton, EraserButton, HandButton, ClearButton, FileButton, SurfaceModeButton }) { button.Width = buttonSize; button.Height = buttonSize; @@ -274,6 +283,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC SchedulePersistedNoteLoad(); } + public void SetSurfaceMode(WhiteboardWidgetSurfaceMode mode, Action? airAppCloseAction = null) + { + _surfaceMode = mode; + _airAppCloseAction = airAppCloseAction; + RefreshSurfaceModeButton(); + } + public void RefreshFromSettings() { try @@ -475,6 +491,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC ApplyToolButtonVisual(HandButton, _toolMode == WhiteboardToolMode.PanZoom, activeBackground, activeForeground, idleBackground, idleForeground); ApplyToolButtonVisual(ClearButton, false, activeBackground, activeForeground, idleBackground, idleForeground); ApplyToolButtonVisual(FileButton, false, activeBackground, activeForeground, idleBackground, idleForeground); + ApplyToolButtonVisual(SurfaceModeButton, false, activeBackground, activeForeground, idleBackground, idleForeground); } private static void ApplyToolButtonVisual( @@ -553,6 +570,42 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC QueueNoteSave(); } + private void OnSurfaceModeButtonClick(object? sender, RoutedEventArgs e) + { + if (_surfaceMode == WhiteboardWidgetSurfaceMode.AirApp) + { + ForceSaveNote(); + _airAppCloseAction?.Invoke(); + return; + } + + if (!HasValidPersistenceContext()) + { + return; + } + + AirAppLauncherServiceProvider + .GetOrCreate() + .OpenWhiteboard(_componentId, _placementId); + } + + private void RefreshSurfaceModeButton() + { + if (SurfaceModeIcon is not null) + { + SurfaceModeIcon.Symbol = _surfaceMode == WhiteboardWidgetSurfaceMode.AirApp + ? Symbol.Subtract + : Symbol.ArrowExport; + } + + if (SurfaceModeButton is not null) + { + ToolTip.SetTip( + SurfaceModeButton, + _surfaceMode == WhiteboardWidgetSurfaceMode.AirApp ? "Exit" : "Full screen"); + } + } + private void OnViewportPointerPressed(object? sender, PointerPressedEventArgs e) { if (_toolMode != WhiteboardToolMode.PanZoom) diff --git a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs index 70ba562..5f92ca5 100644 --- a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs @@ -4,6 +4,7 @@ using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Shapes; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Styling; @@ -13,7 +14,11 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; -public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware +public partial class WorldClockWidget : UserControl, + IDesktopComponentWidget, + ITimeZoneAwareComponentWidget, + IComponentPlacementContextAware, + IComponentRuntimeContextAware { private const int BaseWidthCells = 4; private const int BaseHeightCells = 2; @@ -106,6 +111,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT private bool _isNightVisual = true; private string _componentId = BuiltInComponentIds.DesktopWorldClock; private string _placementId = string.Empty; + private DesktopComponentRenderMode _renderMode = DesktopComponentRenderMode.Live; public WorldClockWidget() { @@ -122,6 +128,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; ActualThemeVariantChanged += OnActualThemeVariantChanged; + PointerReleased += OnPointerReleased; } public void SetTimeZoneService(TimeZoneService timeZoneService) @@ -159,6 +166,15 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT RefreshFromSettings(); } + public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context) + { + _componentId = string.IsNullOrWhiteSpace(context.ComponentId) + ? BuiltInComponentIds.DesktopWorldClock + : context.ComponentId.Trim(); + _placementId = context.PlacementId?.Trim() ?? string.Empty; + _renderMode = context.RenderMode; + } + public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); @@ -316,6 +332,20 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT UpdateClockVisuals(); } + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + _ = sender; + if (e.InitialPressMouseButton != MouseButton.Left || + _renderMode != DesktopComponentRenderMode.Live || + !string.Equals(_componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_placementId); + e.Handled = true; + } + private void BuildClockEntryVisuals() { ClockHostGrid.Children.Clear(); diff --git a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs index d7d03a3b..bfcf875 100644 --- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs +++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs @@ -15,6 +15,7 @@ public partial class DesktopWidgetWindow : Window public DesktopWidgetWindow() { InitializeComponent(); + AppLogger.Info("DesktopWidgetWindow", "Initialized. WindowRole=DesktopSurface."); if (OperatingSystem.IsWindows()) { @@ -44,15 +45,23 @@ public partial class DesktopWidgetWindow : Window } } + public void RefreshDesktopLayer() + { + if (!OperatingSystem.IsWindows() || !IsVisible) + { + return; + } + + _bottomMostService.SendToBottom(this); + Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render); + AppLogger.Info("DesktopWidgetWindow", "Refreshed desktop layer. WindowRole=DesktopSurface."); + } + protected override void OnOpened(EventArgs e) { base.OnOpened(e); - if (OperatingSystem.IsWindows()) - { - _bottomMostService.SendToBottom(this); - Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render); - } + RefreshDesktopLayer(); } protected override void OnSizeChanged(SizeChangedEventArgs e) @@ -72,4 +81,14 @@ public partial class DesktopWidgetWindow : Window new(0, 0, Bounds.Width, Bounds.Height) }); } + + protected override void OnClosing(WindowClosingEventArgs e) + { + if (ComponentContainer.Child is IDisposable disposable) + { + disposable.Dispose(); + } + ComponentContainer.Child = null; + base.OnClosing(e); + } } diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs index 79f757e..2ca6392 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs @@ -81,7 +81,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( "all", L(languageCode, "component_category.all", "All"), - Symbol.Apps, + Icon.Apps, Array.Empty())); var usedCategories = _allDefinitions @@ -97,28 +97,18 @@ public partial class FusedDesktopComponentLibraryControl : UserControl .Select(definition => CreateComponentItem(definition, languageCode)) .ToArray(); + var categoryDefinitions = _allDefinitions + .Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase)) + .ToList(); + _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( category, GetLocalizedCategoryTitle(languageCode, category), - ResolveCategoryIcon(category), + ComponentCategoryIconResolver.ResolveCategoryIcon(category, categoryDefinitions), categoryComponents)); } } - private static Symbol ResolveCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock; - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return Symbol.CalendarDate; - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny; - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit; - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play; - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Info; - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator; - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass; - if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder; - return Symbol.Apps; - } - private string GetLocalizedCategoryTitle(string languageCode, string categoryId) { if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock"); diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index f1575ab..efd4f9b 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -58,7 +58,7 @@ public partial class MainWindow : Window private sealed record ComponentLibraryCategory( string Id, - Symbol Icon, + Icon Icon, string Title, IReadOnlyList Components); @@ -2873,7 +2873,13 @@ public partial class MainWindow : Window private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e) { - if (!_isComponentLibraryOpen || HasActiveDesktopEditSession) + if (!_isComponentLibraryOpen) + { + TryOpenAirAppFromDesktopComponent(sender, e); + return; + } + + if (HasActiveDesktopEditSession) { return; } @@ -2917,6 +2923,29 @@ public partial class MainWindow : Window e.Handled = true; } + private void TryOpenAirAppFromDesktopComponent(object? sender, PointerPressedEventArgs e) + { + if (HasActiveDesktopEditSession || + DesktopPagesViewport is null || + sender is not Border host || + host.Tag is not string placementId || + !e.GetCurrentPoint(host).Properties.IsLeftButtonPressed) + { + return; + } + + var placement = _desktopComponentPlacements.FirstOrDefault(p => + string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + if (placement is null || + !string.Equals(placement.ComponentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _airAppLauncherService.OpenWorldClock(placement.PlacementId); + e.Handled = true; + } + private void SetSelectedDesktopComponent(Border? host) { ClearSelectedLauncherTile(refreshTaskbar: false); @@ -3390,9 +3419,9 @@ public partial class MainWindow : Window var row = new RowDefinition(GridLength.Auto); ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(row); - var icon = new SymbolIcon + var icon = new FluentIcon { - Symbol = category.Icon, + Icon = category.Icon, IconVariant = IconVariant.Regular, FontSize = 18, VerticalAlignment = VerticalAlignment.Center @@ -3461,62 +3490,14 @@ public partial class MainWindow : Window return categories .Select(category => new ComponentLibraryCategory( category.Id, - ResolveComponentLibraryCategoryIcon(category.Id), + ComponentCategoryIconResolver.ResolveCategoryIcon( + category.Id, + _componentRegistry.GetAll().Where(d => string.Equals(d.Category, category.Id, StringComparison.OrdinalIgnoreCase))), GetLocalizedComponentLibraryCategoryTitle(category.Id), category.Components)) .ToList(); } - private Symbol ResolveComponentLibraryCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Clock; - } - - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.CalendarDate; - } - - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.WeatherSunny; - } - - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Edit; - } - - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Play; - } - - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Apps; - } - - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Calculator; - } - - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Hourglass; - } - - if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Folder; - } - - return Symbol.Apps; - } - private string GetLocalizedComponentLibraryCategoryTitle(string categoryId) { if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 43d0549..630f9ef 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -106,6 +106,7 @@ public partial class MainWindow : Window private readonly IComponentLibraryService _componentLibraryService; private readonly IComponentEditorWindowService _componentEditorWindowService; private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService(); + private readonly IAirAppLauncherService _airAppLauncherService = AirAppLauncherServiceProvider.GetOrCreate(); private ComponentLibraryWindow? _detachedComponentLibraryWindow; private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme; private readonly HashSet _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase); diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs index b6d41e9..3cb56fd 100644 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs @@ -224,17 +224,27 @@ public partial class TransparentOverlayWindow : Window _layout = _layoutService.Load(); RenderAllComponents(); - AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components."); + AppLogger.Info( + "TransparentOverlay", + $"Opened with {_layout.ComponentPlacements.Count} components. WindowRole=DesktopSurface."); - if (OperatingSystem.IsWindows()) - { - _bottomMostService.SendToBottom(this); - } + RefreshDesktopLayer(); Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background); DispatcherTimer.RunOnce(LogTransparencyDiagnostics, TimeSpan.FromMilliseconds(250)); } + public void RefreshDesktopLayer() + { + if (!OperatingSystem.IsWindows() || !IsVisible) + { + return; + } + + _bottomMostService.SendToBottom(this); + AppLogger.Info("TransparentOverlay", "Refreshed desktop layer. WindowRole=DesktopSurface."); + } + protected override void OnClosed(EventArgs e) { SaveLayout();