Files
LanMountainDesktop/diff.txt
2026-05-17 19:36:07 +08:00

3688 lines
296 KiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> args)
+ {
+ var values = new Dictionary<string, string>(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<string, string> values, string key, string fallback)
+ {
+ return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
+ ? value.Trim()
+ : fallback;
+ }
+
+ private static string? GetOptionalValue(IReadOnlyDictionary<string, string> 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<IAirAppLifecycleService>();
+ _ = 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<IAirAppLifecycleService>();
+ _ = 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<AirApp>()
+ .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<LauncherResult> 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<string> 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<string> 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<string?> _packageRootProvider;
+ private readonly Func<string?> _hostPathProvider;
+
+ public AirAppProcessStarter(
+ AirAppHostLocator locator,
+ Func<string?> packageRootProvider,
+ Func<string?> 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<IAirAppLifecycleService>(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<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
+
+ public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
+ {
+ _processStarter = processStarter;
+ }
+
+ public Task<AirAppOperationResult> 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<AirAppOperationResult> 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<AirAppOperationResult> 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<AirAppInstanceInfo[]> GetInstancesAsync()
+ {
+ lock (_gate)
+ {
+ CleanupExitedInstances();
+ return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray());
+ }
+ }
+
+ public Task<AirAppOperationResult> 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<AirAppOperationResult> 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<AirAppOperationResult> OpenAsync(AirAppOpenRequest request);
+
+ Task<AirAppOperationResult> ActivateAsync(string instanceKey);
+
+ Task<AirAppOperationResult> CloseAsync(string instanceKey);
+
+ Task<AirAppInstanceInfo[]> GetInstancesAsync();
+
+ Task<AirAppOperationResult> RegisterAsync(AirAppRegistrationRequest request);
+
+ Task<AirAppOperationResult> 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<DesktopComponentDefinition> 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<Icon>(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<AirAppOperationResult> 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<IAirAppLifecycleService>();
+ 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<ComponentLibraryItemViewModel> 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<ComponentLibraryItemViewModel> 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<ComponentLibraryItemViewModel>()));
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<ComponentLibraryComponentEntry> 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<string> _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();