mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
3688 lines
296 KiB
Plaintext
3688 lines
296 KiB
Plaintext
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();
|