mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
* Add Windows system chrome patchers (Harmony) Introduce support for toggling the system chrome on Windows using Harmony patchers. Adds Lib.Harmony.Thin to package props and project, new patcher infrastructure (ChromePatchState, PatcherEntrance) and two Harmony patches that disable FluentAvalonia's Windows chrome when configured. Program.cs now loads the chrome setting and installs patchers conditionally on Windows/x86-x64. Settings viewmodel and view updated: expose IsWindowsOs, require restart on appearance changes, migrate SettingsWindow to FAAppWindow and adapt titlebar/layout (include Windows caption placeholder and footer menu items). Also add a .gitkeep and a build log file. * Refactor settings window UI and theming Improve theming and layout for the Settings window and related services. - MaterialSurfaceService: add special material parameters for SettingsWindowBackground (lower alpha, no blur) and avoid hot-switching real backdrops for non-settings windows. - GlassEffectService: add AdaptiveSettingsWindowTintBrush + ResolveSettingsWindowTintAlpha to provide optional content tinting tied to system material mode. - SettingsWindowService: refactor theme application into ApplyThemeVariantAndResources, ensure settings window material is applied at show/activate times, and tidy theme/resource application flow. - SettingsWindow.axaml / .axaml.cs: restructure title bar (separate Grid.Row=0 border) and FANavigationView host, add pane-footer toggle button for :minimal layout, use dynamic corner radius resource, and update toggle/visibility/icon logic and responsive layout code. - SettingsPages: remove some IconText usages and adjust margins; use DesignCornerRadiusLg for update card corner radius. - Add NuGet.Config to set local globalPackagesFolder and ignore .nuget/packages in .gitignore. These changes aim to improve visuals, avoid backdrop overdraw, and make the settings window behavior consistent across themes and layouts. * Add localization and localize settings pages Add many new localization keys (en-US and zh-CN) for notifications, developer tools, about page, status bar, and video wallpaper. Update Notification, Dev, About and StatusBar view models to use LocalizationService, expose localized ObservableProperties, and refresh localized text at construction. Localize selection options and test notification texts, and fix notification severity handling. Wire up XAML to the new localized properties (About/Dev/StatusBar pages) and update the settings page title for notifications. Also adjust copyright line generation and replace hardcoded placeholders with bound Watermark properties. * Redesign settings window with fluent shell & search Rebuild the settings window as a Fluent shell: adds a custom 48-DIP titlebar with Back, pane toggle, icon/title, search box, restart/more menu, and caption-button spacer; moves compact pane toggle into the titlebar and preserves FANavigationView as the primary navigation surface. Introduces a SettingsSearchService (with UI AutoComplete integration, search indexing, navigation-by-result, and search result highlighting) plus focused tests for search filtering and theme material normalization. Adds navigation history/back stack, updates SettingsViewModels for new bindings and localization keys, and updates General/Apearance pages to expose new strings and options. Implements an "auto" system material mode: default in AppSettingsSnapshot, new MaterialAuto constants and normalization/resolution logic in ThemeAppearanceValues, WindowMaterialService and MaterialSurfaceService adjustments to prefer Mica on Win11 and Acrylic on Win10 using TransparencyLevelHint. GlassEffectService and AppearanceThemeService updated to use effective material mode and to track live theme state changes. Adds localization entries (en-US, zh-CN), spec/tasks docs, and other UI/style tweaks to support the redesign. * fix.修折叠与展开按钮 * Add OOBE startup presentation and settings merge Introduce a new OOBE step for "Startup & Presentation" that exposes startup and UI preferences in OobeWindow (toggles for taskbar, slide/fade transitions, fused popup, and autostart). Add HostAppSettingsOobeMerger to read/write Host settings.json (PascalCase fields) and MergeStartupPresentation behavior, plus LauncherWindowsStartupService to sync the current Launcher into the Windows Run key on Windows. Wire UI handlers, persist choices on Next, and load defaults when entering the step. Include unit tests for the merger, adjust SettingsWindow navigation pane/toggle handling, and update docs/LAUNCHER.md to describe the new OOBE step and implementation files. * Move whiteboard persistence to file storage Switch whiteboard note storage from legacy DB rows to per-note JSON files and add migration support. Update WhiteboardNoteSnapshot schema (version bump, viewport, canvas, expires, PathSvgData) and change IWhiteboardNotePersistenceService.SaveNote to return bool to surface write failures (e.g. read-only files). Implement file-based WhiteboardNotePersistenceService with legacy DB migration/cleanup, retention handling, and logging. Add comprehensive unit tests for persistence, stroke path builder, SVG import and viewport helper. Also add ThirdParty/DotNetCampus.InkCanvas project and reference it in the main csproj, and bump PostHog package to 2.6.0. * Introduce render gate and chart caching Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout. * Use MaterialColorSnapshot in appearance flow Introduce unified material/color spec and tests, and refactor appearance plumbing to use MaterialColorSnapshot as the single source of truth. Add .trae material-color-service spec/checklist/tasks and integration/unit tests for plugin mapping and appearance VM behavior. AppearanceChangedEvent extended with new appearance change flags and HasChanged logic. ComponentEditorMaterialThemeAdapter rewritten to accept MaterialColorSnapshot and derive palette from snapshot data. Simplify AppearanceSettingsPageViewModel and related view code: remove legacy preview/custom-seed UI logic, preserve material/color fields when updating theme or corner radius, and update save calls to use with-expressions. Update ComponentEditorWindow to use adapter-provided OnPrimary brush and minor docs updates. * Add material color services, plugin DTOs, and tests Introduce IPC wire-format appearance DTOs (PluginIsolation.Contracts) and clarify they are distinct from the runtime PluginSdk snapshot. Update PluginSdk comments to document the runtime-facing snapshot shape. Change ComponentColorSchemeHelper to use the HostMaterialColorProvider and add an overload that accepts a MaterialColorSnapshot. Add new services and pipelines (MaterialColorService, MaterialSurfaceService, WindowMaterialService, WallpaperColorPipeline) and refactor AppearanceThemeService to depend on MaterialColorService while removing legacy internal implementations. Add multiple unit tests (ComponentColorSchemeHelper, PluginAppearanceBoundary, SettingsCatalogService, WallpaperSettingsPageViewModel) and update localization resources with new material_color and wallpaper keys. * Add CODE_WIKI and update localization Add a comprehensive CODE_WIKI.md documenting project architecture, modules, startup flow, plugin system, testing and developer workflows. Update localization resources (en-US.json, zh-CN.json) with new/translated keys for wallpaper controls (custom color UI), material & color settings (semantic roles, surfaces, refresh/polling state), appearance (corner radius), status bar font size options, privacy policy text, component library labels, clock settings, and new language entry (Korean). Also modify settings-related ViewModels and Settings page views to surface these new features and texts (MaterialColorSettingsPageViewModel.cs, SettingsViewModels.cs, WallpaperSettingsPageViewModel.cs, MainWindow.SettingsHardCut.Stubs.cs, ComponentsSettingsPage.axaml, WallpaperSettingsPage.axaml). * Add Data settings page and storage scanner Introduce a new "Data" settings page to visualize and manage local app storage. Adds DataStorageService (scanning, disk info, clean operations), DataSettingsPageViewModel, XAML view and code-behind, and HexToColor/HexToBrush converters; registers converters in App.axaml. Also update localization strings for the new page and add icon mapping so the settings entry uses the Database icon. Enables per-category and global cleaning workflows and formatted size display. * Add IPC backoff/retries and safer disposal Introduce exponential backoff, jitter and retry logic across IPC components to improve robustness and avoid tight retry loops; make disposal idempotent and add connection guards. Key changes: - LauncherCoordinatorIpcServer / LauncherIpcServer: add backoff constants, ComputeBackoff(), consecutive error tracking and delayed retries with jitter. - LanMountainDesktopIpcClient / LauncherIpcClient: add connect retry loops, timeouts, delayed retries, improved error logging, and use ArrayPool for buffered async writes; ensure proper cleanup on failures. - PublicIpcHostService: add disposed flag, guard OnPeerConnected and Dispose, and clear connected peers on dispose. - Add many auto-generated commit analysis docs under docs/auto_commit_md and new scripts for analyzing/generating commit docs. These changes aim to make IPC connection handling more resilient and resource-safe. * Add preview controls and settings UI tweaks Introduce GridPreviewControl and CornerRadiusPreviewControl for visual previews and wire them into the Components settings (add ScreenAspectRatio, CornerRadiusPreviewValue, and screen aspect init). Refactor ComponentsSettingsPage UI to show live previews. Improve DataSettingsPage layout and storage bar logic (use item percentages directly, include remaining segment, adjust visuals and visibility triggers). Simplify LauncherSettingsPage header/appearance layout. Add SECURITY_AUDIT_REPORT.md, analysis summary, mockup HTML, and a local .claude settings file. * Add install checkpoint/resume and DDSS workflows Introduce install checkpoint support and resume logic for updates, plus related locking and validation. Adds InstallCheckpoint model, AppJsonContext serialization, and UpdatePaths helpers for deployment lock, apply-in-progress lock and install-checkpoint path. UpdateEngineService gains checkpoint load/save/delete, incoming-state validation, resume logic for PLONDS and legacy updates, apply lock handling, and safer cleanup; ApplyPendingPlondsUpdateAsync and ApplyPendingUpdate flow updated accordingly. Add DeploymentLock contract and extend UpdateState with pause/resume/cancel helpers. Tests updated to cover stale/valid checkpoint resume and legacy/PLONDS flows. CI: enhance ddss-publish to detect release channel, validate S3 assets, prepare and atomically publish channel pointer; add ddss-rollback workflow to publish rollbacks; adjust plonds-build concurrency and release events. * changed.更了好多 * fix.消息盒子媒体播放器组件服务修复 * change.重做天气,为回到系统提供自定义功能。 * feat.airapp与融合桌面 * feat.动画优化与更新界面 * feat.数字时钟,白板功能修复 * feat.完善了时钟轻应用,为启动器提供了多语言支持 * feat.发布与打包优化 * changed.天气选项卡更新
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();
|