diff --git a/.trae/specs/air-app-whiteboard/checklist.md b/.trae/specs/air-app-whiteboard/checklist.md new file mode 100644 index 0000000..09e5399 --- /dev/null +++ b/.trae/specs/air-app-whiteboard/checklist.md @@ -0,0 +1,7 @@ +# Checklist + +- [x] Main app builds in Debug. +- [x] AirAppHost builds in Debug. +- [x] Tests project builds in Debug. +- [x] `AirAppLauncherServiceTests` pass. +- [ ] Manual UI verification on a running desktop session. diff --git a/.trae/specs/air-app-whiteboard/spec.md b/.trae/specs/air-app-whiteboard/spec.md new file mode 100644 index 0000000..4d3dd95 --- /dev/null +++ b/.trae/specs/air-app-whiteboard/spec.md @@ -0,0 +1,26 @@ +# Air APP Whiteboard + +## Goal + +Allow the built-in whiteboard desktop components to open a full-screen Air APP that runs in `LanMountainDesktop.AirAppHost` and reuses the same persisted whiteboard note as the source component instance. + +## Scope + +- Add a toolbar surface-mode button to `WhiteboardWidget`. +- In component mode, the button opens the `whiteboard` Air APP through `IAirAppLauncherService`. +- In Air APP mode, the same button saves the current note and closes the Air APP window. +- `DesktopWhiteboard` and `DesktopBlackboardLandscape` share the same mechanism and keep using their component id plus placement id as the note identity. +- `LanMountainDesktop.AirAppHost` may reference the host assembly to reuse built-in UI controls, but the host app must not reference AirAppHost as a normal assembly dependency. + +## Out of Scope + +- Third-party Air APP SDK declarations. +- Whiteboard feature rewrites or alternate whiteboard persistence. +- Taskbar minimization behavior; v1 closes the Air APP window when the user exits from the bottom toolbar. + +## Acceptance + +- Building the main app also builds and copies `LanMountainDesktop.AirAppHost` output. +- Clicking the whiteboard toolbar full-screen button launches a separate AirAppHost process. +- Repeated opens of the same whiteboard component instance activate the existing process instead of spawning duplicates. +- Closing and reopening the Air APP keeps the same whiteboard contents. diff --git a/.trae/specs/air-app-whiteboard/tasks.md b/.trae/specs/air-app-whiteboard/tasks.md new file mode 100644 index 0000000..ac10974 --- /dev/null +++ b/.trae/specs/air-app-whiteboard/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Add `whiteboard` launch support to `AirAppLauncherService`. +- [x] Add whiteboard single-instance keys based on component id and placement id. +- [x] Add component/Air APP surface modes to `WhiteboardWidget`. +- [x] Render `WhiteboardWidget` full screen from `LanMountainDesktop.AirAppHost`. +- [x] Keep AirAppHost build/copy output available from the main app build. +- [x] Add launcher argument and instance-key tests. diff --git a/.trae/specs/air-app-window-chrome/checklist.md b/.trae/specs/air-app-window-chrome/checklist.md new file mode 100644 index 0000000..15f1249 --- /dev/null +++ b/.trae/specs/air-app-window-chrome/checklist.md @@ -0,0 +1,8 @@ +# Checklist + +- [x] Descriptor supports Standard, Borderless, FullScreen, Tool, and BackgroundOnly modes. +- [x] World Clock Air APP keeps the LanMountain custom title bar. +- [x] Whiteboard Air APP opens as a fullscreen titlebar-less window. +- [x] Air APP windows do not use fused desktop bottom-most services. +- [x] Air APP windows do not use `Topmost=true` promotion. +- [ ] Manual verification for each chrome mode once non-built-in Air APP declarations are added. diff --git a/.trae/specs/air-app-window-chrome/spec.md b/.trae/specs/air-app-window-chrome/spec.md new file mode 100644 index 0000000..f9cd7e9 --- /dev/null +++ b/.trae/specs/air-app-window-chrome/spec.md @@ -0,0 +1,22 @@ +# Air APP Window Chrome + +## Goal + +Give Air APPs explicit window chrome modes so title bars, fullscreen windows, borderless windows, tool windows, and future background-only apps are configured by the Air APP host instead of ad hoc component code. + +## Behavior + +- Air APP host resolves an `AirAppWindowDescriptor` from launch options before creating content. +- Supported chrome modes are `Standard`, `Borderless`, `FullScreen`, `Tool`, and `BackgroundOnly`. +- `Standard` uses the LanMountain custom title bar and normal app-window behavior. +- `Borderless` hides the custom title bar while keeping a normal app window. +- `FullScreen` hides the custom title bar, removes rounded shell chrome, and enters fullscreen. +- `Tool` keeps host-owned chrome but disables resizing and hides the taskbar entry. +- `BackgroundOnly` is reserved for a later background Air APP lifecycle and is not used by built-in v1 apps. +- Built-in `world-clock` uses `Standard`; built-in `whiteboard` uses `FullScreen`. + +## Out of Scope + +- Third-party plugin Air APP declarations. +- Replacing Launcher lifecycle IPC. +- Moving title-bar rendering into desktop components. diff --git a/.trae/specs/air-app-window-chrome/tasks.md b/.trae/specs/air-app-window-chrome/tasks.md new file mode 100644 index 0000000..33bf37f --- /dev/null +++ b/.trae/specs/air-app-window-chrome/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Add `AirAppWindowChromeMode` and `AirAppWindowDescriptor`. +- [x] Map built-in `world-clock` to `Standard` chrome. +- [x] Map built-in `whiteboard` to `FullScreen` chrome. +- [x] Apply descriptor settings from `AirAppWindow`. +- [x] Add regression tests for supported modes and built-in mode mapping. diff --git a/.trae/specs/fused-desktop-category-icon-unification/checklist.md b/.trae/specs/fused-desktop-category-icon-unification/checklist.md new file mode 100644 index 0000000..de56722 --- /dev/null +++ b/.trae/specs/fused-desktop-category-icon-unification/checklist.md @@ -0,0 +1,14 @@ +- [x] ComponentCategoryIconResolver 基于 IconKey 正确解析分类图标 +- [x] IconKey 为 "Clock" 时解析为 Icon.Clock +- [x] IconKey 为 "WeatherSunny" 时解析为 Icon.WeatherSunny +- [x] IconKey 为 "News" 时解析为 Icon.News +- [x] IconKey 为 "Edit" 时解析为 Icon.Edit +- [x] IconKey 为无效值时回退到 Icon.Apps +- [x] 分类 ID 为 "all" 时返回 Icon.Apps +- [x] ComponentLibraryCategoryViewModel.Icon 类型为 FluentIcons.Common.Icon +- [x] FusedDesktopComponentLibraryControl.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法 +- [x] ComponentLibraryWindow.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法 +- [x] MainWindow.ComponentSystem.cs 不再包含硬编码 ResolveComponentLibraryCategoryIcon 方法 +- [x] 三处组件库入口对同一分类显示相同图标 +- [x] dotnet build 无编译错误 +- [x] dotnet test 全部通过 diff --git a/.trae/specs/fused-desktop-category-icon-unification/spec.md b/.trae/specs/fused-desktop-category-icon-unification/spec.md new file mode 100644 index 0000000..7cca3d8 --- /dev/null +++ b/.trae/specs/fused-desktop-category-icon-unification/spec.md @@ -0,0 +1,73 @@ +# 融合桌面组件库分类图标统一规格 + +## Why + +融合桌面组件库窗口(FusedDesktopComponentLibraryControl)的分类图标使用了手动硬编码的 `ResolveCategoryIcon` 方法映射分类 ID 到 `Symbol` 枚举,与阑山桌面主窗口(MainWindow)中的映射存在不一致(例如 `Info` 分类在主窗口映射到 `Symbol.Apps`,在融合桌面映射到 `Symbol.Info`)。同时,`DesktopComponentDefinition.IconKey` 字段已经存储了正确的 FluentIcon 枚举名称字符串,但未被利用。需要统一三处图标映射逻辑,确保所有组件库入口的分类图标一致且正确。 + +## What Changes + +- **统一分类图标映射**:将三处分散的 `ResolveCategoryIcon`/`ResolveComponentLibraryCategoryIcon` 方法合并为共享的统一映射 +- **使用 `IconKey` 驱动图标**:分类图标应基于该分类下组件的 `IconKey` 字段推导,而非硬编码的分类 ID 映射 +- **使用 `FluentIcons.Common.Icon` 枚举**:`fi:FluentIcon` 控件使用 `Icon` 枚举(非 `Symbol` 枚举),分类图标应使用 `Icon` 枚举以与 `fi:FluentIcon` 兼容 +- **修改 ViewModel**:`ComponentLibraryCategoryViewModel.Icon` 属性类型从 `Symbol` 改为 `Icon` + +## Impact + +- 受影响文件: + - `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(Icon 属性类型从 Symbol 改为 Icon) + - `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`(绑定路径不变,但 Icon 类型变化) + - `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`(移除硬编码映射,使用统一方法) + - `LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs`(移除硬编码映射,使用统一方法) + - `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`(移除硬编码映射,使用统一方法) + - 新增共享映射工具类(或在现有服务中添加) + +## ADDED Requirements + +### Requirement: 统一分类图标映射 + +系统 SHALL 提供一个共享的分类图标映射方法,所有组件库入口(阑山桌面主窗口、融合桌面组件库、独立组件库窗口)均使用此方法。 + +#### Scenario: 图标映射来源 +- **GIVEN** 一个组件分类 ID +- **WHEN** 需要获取该分类的图标 +- **THEN** 系统应基于该分类下组件的 `IconKey` 字段推导分类图标 +- **AND** 推导规则为:取该分类下第一个组件的 `IconKey`,解析为 `FluentIcons.Common.Icon` 枚举值 +- **AND** 若 `IconKey` 无法解析为有效的 `Icon` 枚举值,则回退到 `Icon.Apps` + +#### Scenario: 特殊分类处理 +- **GIVEN** 分类 ID 为 "all" +- **WHEN** 需要获取该分类的图标 +- **THEN** 系统应返回 `Icon.Apps` + +#### Scenario: 三处映射一致性 +- **GIVEN** 任意一个组件分类 +- **WHEN** 在阑山桌面主窗口、融合桌面组件库、独立组件库窗口中显示该分类 +- **THEN** 三处应显示完全相同的图标 + +### Requirement: ViewModel 使用 Icon 枚举 + +`ComponentLibraryCategoryViewModel.Icon` 属性 SHALL 使用 `FluentIcons.Common.Icon` 枚举类型(而非 `FluentIcons.Common.Symbol`),以与 `fi:FluentIcon` 控件的 `Icon` 属性兼容。 + +#### Scenario: XAML 绑定兼容 +- **GIVEN** `ComponentLibraryCategoryViewModel.Icon` 属性类型为 `Icon` +- **WHEN** 在 XAML 中通过 `{Binding Icon}` 绑定到 `fi:FluentIcon` 控件 +- **THEN** 图标应正确渲染,无需额外转换 + +## MODIFIED Requirements + +### Requirement: 分类图标解析 + +原实现使用硬编码的 `if/switch` 语句将分类 ID 映射到 `Symbol` 枚举,新实现改为: + +- 使用 `DesktopComponentDefinition.IconKey` 字段作为图标来源 +- 通过 `Enum.TryParse(iconKey, ignoreCase: true, out var icon)` 解析 +- 解析失败时回退到 `Icon.Apps` +- 移除所有三处硬编码映射方法 + +### Requirement: ComponentLibraryCategoryViewModel.Icon 类型 + +原类型为 `Symbol`,修改为 `Icon`,与 `fi:FluentIcon` 控件的 `Icon` 依赖属性类型一致。 + +## REMOVED Requirements + +无移除的需求。 diff --git a/.trae/specs/fused-desktop-category-icon-unification/tasks.md b/.trae/specs/fused-desktop-category-icon-unification/tasks.md new file mode 100644 index 0000000..272f0b2 --- /dev/null +++ b/.trae/specs/fused-desktop-category-icon-unification/tasks.md @@ -0,0 +1,38 @@ +# Tasks + +- [x] Task 1: 创建共享分类图标映射工具 + - [x] SubTask 1.1: 在 `LanMountainDesktop.ComponentSystem` 命名空间下创建 `ComponentCategoryIconResolver` 静态类 + - [x] SubTask 1.2: 实现 `ResolveCategoryIcon(string categoryId, IEnumerable categoryComponents)` 方法,基于 IconKey 解析为 `FluentIcons.Common.Icon` + - [x] SubTask 1.3: 添加单元测试验证图标解析逻辑(TDD:先写失败测试,再实现) + +- [x] Task 2: 修改 ViewModel 的 Icon 属性类型 + - [x] SubTask 2.1: 将 `ComponentLibraryCategoryViewModel.Icon` 属性类型从 `Symbol` 改为 `Icon` + - [x] SubTask 2.2: 更新构造函数参数类型 + +- [x] Task 3: 更新 FusedDesktopComponentLibraryControl.axaml.cs + - [x] SubTask 3.1: 移除 `ResolveCategoryIcon` 硬编码方法 + - [x] SubTask 3.2: 在 `LoadCategories` 中使用 `ComponentCategoryIconResolver.ResolveCategoryIcon` + - [x] SubTask 3.3: 更新 "all" 分类图标从 `Symbol.Apps` 改为 `Icon.Apps` + +- [x] Task 4: 更新 ComponentLibraryWindow.axaml.cs + - [x] SubTask 4.1: 移除 `ResolveCategoryIcon` 硬编码方法 + - [x] SubTask 4.2: 使用 `ComponentCategoryIconResolver.ResolveCategoryIcon` + +- [x] Task 5: 更新 MainWindow.ComponentSystem.cs + - [x] SubTask 5.1: 移除 `ResolveComponentLibraryCategoryIcon` 硬编码方法 + - [x] SubTask 5.2: 使用 `ComponentCategoryIconResolver.ResolveCategoryIcon` + - [x] SubTask 5.3: 更新 `ComponentLibraryCategory` 记录的 `Icon` 字段类型从 `Symbol` 改为 `Icon` + - [x] SubTask 5.4: 更新 `GetComponentLibraryCategories` 方法中的图标解析调用 + +- [x] Task 6: 更新 XAML 绑定 + - [x] SubTask 6.1: 验证 `FusedDesktopComponentLibraryControl.axaml` 中 `fi:FluentIcon Icon="{Binding Icon}"` 绑定在新类型下正常工作 + +- [x] Task 7: 构建验证 + - [x] SubTask 7.1: 运行 `dotnet build` 确保无编译错误 + - [x] SubTask 7.2: 运行 `dotnet test` 确保所有测试通过 + +# Task Dependencies +- Task 2 依赖于 Task 1(共享映射工具) +- Task 3、4、5 依赖于 Task 1 和 Task 2 +- Task 6 依赖于 Task 2(类型变更后验证绑定) +- Task 7 依赖于所有前置任务 diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md b/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md new file mode 100644 index 0000000..58242db --- /dev/null +++ b/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md @@ -0,0 +1,10 @@ +# Checklist + +- [x] `LanMountainDesktop.Shared.IPC` builds in Debug. +- [x] `LanMountainDesktop.Launcher` builds in Debug. +- [x] `LanMountainDesktop` builds in Debug. +- [x] `LanMountainDesktop.AirAppHost` builds in Debug. +- [x] `LanMountainDesktop.Tests` builds in Debug. +- [x] Air APP launcher and lifecycle unit tests pass. +- [x] Direct-host fallback starts Launcher in `air-app-broker` mode instead of debug/normal launch mode. +- [ ] Manual process-lifetime verification with the running desktop. diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/spec.md b/.trae/specs/launcher-managed-air-app-lifecycle/spec.md new file mode 100644 index 0000000..a7cf1bf --- /dev/null +++ b/.trae/specs/launcher-managed-air-app-lifecycle/spec.md @@ -0,0 +1,22 @@ +# Launcher Managed Air APP Lifecycle + +## Goal + +Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes. + +## Behavior + +- Launcher exposes `IAirAppLifecycleService` on the dedicated `LanMountainDesktop.Launcher.AirApp.v1` pipe. +- Desktop host calls Launcher IPC for `world-clock` and `whiteboard`; it does not directly start `LanMountainDesktop.AirAppHost`. +- If the dedicated pipe is unavailable, the desktop host starts Launcher with the hidden `air-app-broker --requester-pid ` command and retries the Air APP request. +- `air-app-broker` starts only the Air APP lifecycle IPC broker. It bypasses OOBE, Splash, debug preview windows, and normal desktop launch orchestration. +- Launcher keeps one Air APP process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key. +- AirAppHost receives Launcher pipe and instance key at startup, registers after the window opens, and unregisters on close. +- Launcher remains alive while the main desktop process or any Air APP process is alive. +- Broker mode remains alive while the requester desktop process or any Air APP process is alive; after both are gone, it exits. + +## Out of Scope + +- Third-party plugin-declared Air APP metadata. +- Cross-machine IPC. +- Persisting the Air APP instance table across OS reboot. diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md b/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md new file mode 100644 index 0000000..d9461b7 --- /dev/null +++ b/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md @@ -0,0 +1,11 @@ +# Tasks + +- [x] Add shared Air APP lifecycle IPC contracts. +- [x] Add Launcher Air APP lifecycle service and dedicated IPC host. +- [x] Make Launcher remain alive while desktop or Air APP processes exist. +- [x] Route desktop Air APP launch requests through Launcher IPC. +- [x] Add hidden `air-app-broker` Launcher command for direct-host development fallback. +- [x] Make desktop fallback start `air-app-broker --requester-pid ` instead of normal `launch`. +- [x] Add broker lifetime and command recognition tests. +- [x] Add AirAppHost registration and unregister best-effort calls. +- [x] Add lifecycle service and request-building tests. diff --git a/.trae/specs/window-layer-isolation/checklist.md b/.trae/specs/window-layer-isolation/checklist.md new file mode 100644 index 0000000..480d190 --- /dev/null +++ b/.trae/specs/window-layer-isolation/checklist.md @@ -0,0 +1,7 @@ +# Checklist + +- [x] Air APP window code does not call fused desktop bottom-most APIs. +- [x] Air APP window code does not set `Topmost = true`. +- [x] Fused desktop overlay and widget windows still use bottom-most APIs. +- [x] Fused desktop widget reload path refreshes desktop layer after showing. +- [ ] Manual Windows z-order verification with fused desktop and Air APP windows. diff --git a/.trae/specs/window-layer-isolation/spec.md b/.trae/specs/window-layer-isolation/spec.md new file mode 100644 index 0000000..ae6cf9a --- /dev/null +++ b/.trae/specs/window-layer-isolation/spec.md @@ -0,0 +1,18 @@ +# Window Layer Isolation + +## Goal + +Keep fused desktop component windows and Air APP windows in separate z-order roles. + +## Behavior + +- Fused desktop windows are desktop-surface windows. They may use `IWindowBottomMostService` and region passthrough, must stay attached to the Windows desktop icon host when supported, and must not cover ordinary apps. +- Air APP windows are ordinary application windows. They must not use the fused desktop bottom-most service, must not attach to the desktop icon host, and must not use global `Topmost` promotion. +- Re-showing or reloading fused desktop widgets refreshes their desktop layer after the window is visible. +- Air APP activation uses normal window activation; repeated-open foreground recovery remains owned by Launcher lifecycle activation. + +## Out of Scope + +- Changing Air APP lifecycle IPC. +- Changing whiteboard note sharing. +- Implementing third-party Air APP SDK behavior. diff --git a/.trae/specs/window-layer-isolation/tasks.md b/.trae/specs/window-layer-isolation/tasks.md new file mode 100644 index 0000000..bf393e5 --- /dev/null +++ b/.trae/specs/window-layer-isolation/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Remove Air APP `Topmost` promotion from `AirAppWindow`. +- [x] Add explicit desktop-layer refresh for fused desktop widget windows. +- [x] Refresh fused desktop widget windows after show/reload. +- [x] Add window-role diagnostics for desktop-surface and Air APP windows. +- [x] Add static regression tests for window-layer isolation. diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml b/LanMountainDesktop.AirAppHost/AirApp.axaml new file mode 100644 index 0000000..57f2ac7 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirApp.axaml @@ -0,0 +1,32 @@ + + + + + + + MiSans VF, avares://LanMountainDesktop.AirAppHost/Assets/Fonts#MiSans + #FFF7F9FC + #22000000 + #FF171A20 + #FF657080 + #FF2D73E5 + + + + + + + + + + + + + 18 + 10 + 8 + + diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml.cs b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs new file mode 100644 index 0000000..fb98789 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class AirApp : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var options = AirAppLaunchOptions.Parse(desktop.Args ?? []); + desktop.MainWindow = new AirAppWindow(options); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs new file mode 100644 index 0000000..fe1a8fc --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs @@ -0,0 +1,64 @@ +namespace LanMountainDesktop.AirAppHost; + +public sealed record AirAppLaunchOptions( + string AppId, + string SessionId, + string? SourceComponentId, + string? SourcePlacementId, + string? LauncherPipeName, + string? InstanceKey) +{ + public const string WorldClockAppId = "world-clock"; + public const string WhiteboardAppId = "whiteboard"; + + public static AirAppLaunchOptions Parse(IReadOnlyList args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Count; index++) + { + var arg = args[index]; + if (!arg.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = arg[2..].Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + values[key] = args[index + 1]; + index++; + } + else + { + values[key] = "true"; + } + } + + return new AirAppLaunchOptions( + GetValue(values, "app-id", WorldClockAppId), + GetValue(values, "session-id", Guid.NewGuid().ToString("N")), + GetOptionalValue(values, "source-component-id"), + GetOptionalValue(values, "source-placement-id"), + GetOptionalValue(values, "launcher-pipe"), + GetOptionalValue(values, "instance-key")); + } + + private static string GetValue(IReadOnlyDictionary values, string key, string fallback) + { + return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : fallback; + } + + private static string? GetOptionalValue(IReadOnlyDictionary values, string key) + { + return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : null; + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml new file mode 100644 index 0000000..e352015 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs new file mode 100644 index 0000000..3df352f --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs @@ -0,0 +1,228 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using LanMountainDesktop.Views.Components; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class AirAppWindow : Window +{ + private readonly AirAppLaunchOptions _options; + private readonly AirAppWindowDescriptor _descriptor; + private string _instanceKey = string.Empty; + + public AirAppWindow() + : this(AirAppLaunchOptions.Parse([])) + { + } + + public AirAppWindow(AirAppLaunchOptions options) + { + _options = options; + _descriptor = AirAppWindowDescriptor.Create(options); + InitializeComponent(); + ConfigureWindow(); + } + + private void ConfigureWindow() + { + ApplyWindowDescriptor(_descriptor); + + if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase)) + { + ContentHost.Content = new WorldClockAirAppView(_options); + return; + } + + if (string.Equals(_options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase)) + { + ConfigureWhiteboardWindow(); + return; + } + + ContentHost.Content = new TextBlock + { + Text = $"Unsupported Air APP: {_options.AppId}", + Margin = new Avalonia.Thickness(18) + }; + } + + private void ApplyWindowDescriptor(AirAppWindowDescriptor descriptor) + { + Title = descriptor.Title; + TitleTextBlock.Text = descriptor.TitleText; + SubtitleTextBlock.Text = descriptor.SubtitleText; + Width = descriptor.Width; + Height = descriptor.Height; + MinWidth = descriptor.MinWidth; + MinHeight = descriptor.MinHeight; + ShowInTaskbar = descriptor.ShowInTaskbar; + CanResize = descriptor.CanResize; + WindowDecorations = WindowDecorations.None; + ExtendClientAreaToDecorationsHint = true; + ExtendClientAreaTitleBarHeightHint = -1; + + TitleBar.IsVisible = true; + Grid.SetRow(ContentHost, 1); + Grid.SetRowSpan(ContentHost, 1); + WindowState = WindowState.Normal; + + switch (descriptor.ChromeMode) + { + case AirAppWindowChromeMode.Standard: + break; + + case AirAppWindowChromeMode.Borderless: + HideCustomTitleBar(); + break; + + case AirAppWindowChromeMode.FullScreen: + HideCustomTitleBar(); + WindowShell.CornerRadius = new Avalonia.CornerRadius(0); + WindowShell.BorderThickness = new Avalonia.Thickness(0); + WindowShell.BoxShadow = default; + WindowState = WindowState.FullScreen; + break; + + case AirAppWindowChromeMode.Tool: + ShowInTaskbar = false; + CanResize = false; + break; + + case AirAppWindowChromeMode.BackgroundOnly: + // Reserved for future background-only Air APPs. Keep a normal window for now + // so accidental launches remain visible and debuggable. + break; + } + } + + private void HideCustomTitleBar() + { + TitleBar.IsVisible = false; + Grid.SetRow(ContentHost, 0); + Grid.SetRowSpan(ContentHost, 2); + } + + private void ConfigureWhiteboardWindow() + { + var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId) + ? BuiltInComponentIds.DesktopWhiteboard + : _options.SourceComponentId.Trim(); + var baseWidthCells = string.Equals(componentId, BuiltInComponentIds.DesktopBlackboardLandscape, StringComparison.OrdinalIgnoreCase) + ? 4 + : 2; + var widget = new WhiteboardWidget(baseWidthCells); + widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId); + widget.SetSurfaceMode( + WhiteboardWidgetSurfaceMode.AirApp, + () => + { + widget.ForceSaveNote(); + Close(); + }); + + ContentHost.Content = widget; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + _ = RegisterWithLauncherAsync(); + AppLogger.Info( + "AirAppWindow", + $"Opened. WindowRole=AirApp; AppId='{_options.AppId}'; ForegroundActivationRequested=True."); + Dispatcher.UIThread.Post(() => + { + Activate(); + }, DispatcherPriority.Background); + } + + protected override void OnClosed(EventArgs e) + { + _ = UnregisterWithLauncherAsync(); + base.OnClosed(e); + } + + private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + BeginMoveDrag(e); + } + } + + private void OnCloseClick(object? sender, RoutedEventArgs e) + { + Close(); + } + + private async Task RegisterWithLauncherAsync() + { + if (string.IsNullOrWhiteSpace(_options.LauncherPipeName)) + { + return; + } + + _instanceKey = ResolveInstanceKey(); + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + _ = await proxy.RegisterAsync(new AirAppRegistrationRequest( + _instanceKey, + _options.AppId, + _options.SessionId, + Environment.ProcessId, + Title ?? "Air APP", + _options.SourceComponentId, + _options.SourcePlacementId)).ConfigureAwait(false); + } + catch + { + // Registration is best-effort; Launcher also tracks the process it started. + } + } + + private async Task UnregisterWithLauncherAsync() + { + if (string.IsNullOrWhiteSpace(_options.LauncherPipeName)) + { + return; + } + + var instanceKey = string.IsNullOrWhiteSpace(_instanceKey) ? ResolveInstanceKey() : _instanceKey; + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + _ = await proxy.UnregisterAsync(instanceKey, Environment.ProcessId).ConfigureAwait(false); + } + catch + { + // Unregister is best-effort; Launcher prunes dead processes. + } + } + + private string ResolveInstanceKey() + { + if (!string.IsNullOrWhiteSpace(_options.InstanceKey)) + { + return _options.InstanceKey.Trim(); + } + + var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId) + ? "none" + : _options.SourceComponentId.Trim(); + var placementId = string.IsNullOrWhiteSpace(_options.SourcePlacementId) + ? "none" + : _options.SourcePlacementId.Trim(); + return $"{_options.AppId}:{componentId}:{placementId}"; + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs new file mode 100644 index 0000000..1fa8a19 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs @@ -0,0 +1,10 @@ +namespace LanMountainDesktop.AirAppHost; + +public enum AirAppWindowChromeMode +{ + Standard, + Borderless, + FullScreen, + Tool, + BackgroundOnly +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs new file mode 100644 index 0000000..3ee33e3 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs @@ -0,0 +1,137 @@ +namespace LanMountainDesktop.AirAppHost; + +public sealed record AirAppWindowDescriptor( + string WindowTitle, + string TitleBarTitle, + string TitleBarSubtitle, + AirAppWindowChromeMode ChromeMode, + bool CanResize, + bool ShowInTaskbar, + double Width, + double Height, + double MinWidth, + double MinHeight) +{ + public string Title => WindowTitle; + + public string TitleText => TitleBarTitle; + + public string SubtitleText => TitleBarSubtitle; + + public static AirAppWindowDescriptor Create(AirAppLaunchOptions options) + { + if (string.Equals(options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase)) + { + return Standard( + "World Clock - Air APP", + "World Clock", + "Air APP"); + } + + if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase)) + { + return FullScreen( + "Whiteboard - Air APP", + "Whiteboard", + "Air APP"); + } + + return Standard( + "Air APP", + "Air APP", + options.AppId); + } + + public static AirAppWindowDescriptor Standard( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle, + double width = 520, + double height = 360, + double minWidth = 360, + double minHeight = 260) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.Standard, + CanResize: true, + ShowInTaskbar: true, + width, + height, + minWidth, + minHeight); + } + + public static AirAppWindowDescriptor FullScreen( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.FullScreen, + CanResize: false, + ShowInTaskbar: true, + Width: 1280, + Height: 720, + MinWidth: 360, + MinHeight: 260); + } + + public static AirAppWindowDescriptor Borderless( + string windowTitle, + double width = 520, + double height = 360) + { + return new AirAppWindowDescriptor( + windowTitle, + string.Empty, + string.Empty, + AirAppWindowChromeMode.Borderless, + CanResize: true, + ShowInTaskbar: true, + width, + height, + MinWidth: 240, + MinHeight: 180); + } + + public static AirAppWindowDescriptor Tool( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle, + double width = 360, + double height = 260) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.Tool, + CanResize: false, + ShowInTaskbar: false, + width, + height, + MinWidth: 240, + MinHeight: 180); + } + + public static AirAppWindowDescriptor BackgroundOnly(string appId) + { + return new AirAppWindowDescriptor( + $"{appId} - Air APP", + string.Empty, + string.Empty, + AirAppWindowChromeMode.BackgroundOnly, + CanResize: false, + ShowInTaskbar: false, + Width: 1, + Height: 1, + MinWidth: 1, + MinHeight: 1); + } +} diff --git a/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj b/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj new file mode 100644 index 0000000..463f4d8 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj @@ -0,0 +1,29 @@ + + + WinExe + net10.0 + LatestMajor + enable + enable + true + ..\LanMountainDesktop\Assets\logo_nightly.ico + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.AirAppHost/Program.cs b/LanMountainDesktop.AirAppHost/Program.cs new file mode 100644 index 0000000..e86d13f --- /dev/null +++ b/LanMountainDesktop.AirAppHost/Program.cs @@ -0,0 +1,21 @@ +using Avalonia; + +namespace LanMountainDesktop.AirAppHost; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + + private static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml new file mode 100644 index 0000000..d41d263 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs new file mode 100644 index 0000000..d9f8df1 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class WorldClockAirAppView : UserControl +{ + private readonly DispatcherTimer _timer = new() + { + Interval = TimeSpan.FromSeconds(1) + }; + + private readonly AirAppLaunchOptions _options; + + public WorldClockAirAppView() + : this(AirAppLaunchOptions.Parse([])) + { + } + + public WorldClockAirAppView(AirAppLaunchOptions options) + { + _options = options; + InitializeComponent(); + + SessionTextBlock.Text = string.IsNullOrWhiteSpace(_options.SourcePlacementId) + ? "World Clock" + : $"World Clock / {_options.SourcePlacementId}"; + + _timer.Tick += OnTimerTick; + AttachedToVisualTree += (_, _) => + { + UpdateTime(); + _timer.Start(); + }; + DetachedFromVisualTree += (_, _) => _timer.Stop(); + UpdateTime(); + } + + private void OnTimerTick(object? sender, EventArgs e) + { + UpdateTime(); + } + + private void UpdateTime() + { + var now = DateTime.Now; + TimeTextBlock.Text = now.ToString("HH:mm:ss", CultureInfo.CurrentCulture); + DateTextBlock.Text = now.ToString("yyyy-MM-dd dddd", CultureInfo.CurrentCulture); + TimeZoneTextBlock.Text = TimeZoneInfo.Local.DisplayName; + } +} diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index 306c087..3117fbc 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Services.AirApp; using LanMountainDesktop.Launcher.Services.Ipc; using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; @@ -61,6 +62,13 @@ public partial class App : Application return; } + if (context.IsAirAppBrokerCommand) + { + _ = RunAirAppBrokerAsync(desktop, context); + base.OnFrameworkInitializationCompleted(); + return; + } + // 调试模式:只显示 DevDebugWindow,不走正常启动流程 // 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI if (context.IsDebugMode && !context.IsPreviewCommand && @@ -90,6 +98,45 @@ public partial class App : Application base.OnFrameworkInitializationCompleted(); } + private static async Task RunAirAppBrokerAsync( + IClassicDesktopStyleApplicationLifetime desktop, + CommandContext context) + { + var appRoot = Commands.ResolveAppRoot(context); + var requesterPid = context.GetIntOption("requester-pid", 0); + Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}."); + + using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( + new LauncherAirAppLifecycleService( + new AirAppProcessStarter( + new AirAppHostLocator(), + () => appRoot, + () => null))); + airAppIpcHost.Start(); + + await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); + + Logger.Info("Air APP broker exiting."); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background); + } + + internal static async Task WaitForAirAppBrokerExitAsync( + int requesterPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + while (ShouldKeepAirAppBrokerAlive(requesterPid, airAppLifecycleService)) + { + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + } + + internal static bool ShouldKeepAirAppBrokerAlive( + int requesterPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + return TryGetLiveProcess(requesterPid) || airAppLifecycleService.HasLiveAirApps(); + } + private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop) { switch (context.Command.ToLowerInvariant()) @@ -236,7 +283,6 @@ public partial class App : Application var startupAttemptRegistry = new StartupAttemptRegistry(); var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context); - if (!startupAttemptRegistry.TryReserveCoordinator( context.LaunchSource, successPolicy, @@ -257,6 +303,14 @@ public partial class App : Application return; } + using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( + new LauncherAirAppLifecycleService( + new AirAppProcessStarter( + new AirAppHostLocator(), + () => appRoot, + () => null))); + airAppIpcHost.Start(); + using var coordinatorServer = new LauncherCoordinatorIpcServer( coordinatorPipeName, BuildCoordinatorStatusFromAttempt(reservedAttempt), @@ -334,9 +388,45 @@ public partial class App : Application await WriteLauncherResultAsync(context, result).ConfigureAwait(false); Environment.ExitCode = result.Success ? 0 : 1; + if (result.Success) + { + var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); + await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); + } + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); } + private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid) + { + if (result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var hostPid)) + { + return hostPid; + } + + if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) && + int.TryParse(existingHostPidText, out var existingHostPid)) + { + return existingHostPid; + } + + return fallbackHostPid; + } + + private static async Task WaitForManagedProcessesToExitAsync( + int hostPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}."); + while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps()) + { + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + + Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains."); + } + private static async Task AttachToExistingCoordinatorAsync( CommandContext context, SplashWindow? splashWindow, diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs index fcad276..2203bb1 100644 --- a/LanMountainDesktop.Launcher/CommandContext.cs +++ b/LanMountainDesktop.Launcher/CommandContext.cs @@ -4,11 +4,14 @@ namespace LanMountainDesktop.Launcher; internal sealed class CommandContext { + public const string AirAppBrokerCommand = "air-app-broker"; + private const string LaunchSourceOptionName = "launch-source"; private static readonly string[] GuiCommands = [ "launch", + AirAppBrokerCommand, "apply-update", "preview-splash", "preview-error", @@ -60,6 +63,9 @@ internal sealed class CommandContext public bool IsPreviewCommand => Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase); + public bool IsAirAppBrokerCommand => + string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase); + public bool IsGuiCommand => GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase); diff --git a/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs b/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs new file mode 100644 index 0000000..3967eb4 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs @@ -0,0 +1,88 @@ +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal sealed class AirAppHostLocator +{ + private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe"; + private const string DllName = "LanMountainDesktop.AirAppHost.dll"; + + public string Resolve(string? packageRoot, string? hostPath = null) + { + foreach (var candidate in EnumerateCandidates(packageRoot, hostPath)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + throw new FileNotFoundException("Unable to find LanMountainDesktop.AirAppHost output."); + } + + private static IEnumerable EnumerateCandidates(string? packageRoot, string? hostPath) + { + foreach (var root in EnumerateRoots(packageRoot, hostPath)) + { + yield return Path.Combine(root, "AirAppHost", WindowsExecutableName); + yield return Path.Combine(root, "AirAppHost", DllName); + yield return Path.Combine(root, WindowsExecutableName); + yield return Path.Combine(root, DllName); + + if (Directory.Exists(root)) + { + foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)) + { + yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName); + yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName); + } + } + } + + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent) + { + yield return Path.Combine( + current.FullName, + "LanMountainDesktop.AirAppHost", + "bin", +#if DEBUG + "Debug", +#else + "Release", +#endif + "net10.0", + WindowsExecutableName); + + yield return Path.Combine( + current.FullName, + "LanMountainDesktop.AirAppHost", + "bin", +#if DEBUG + "Debug", +#else + "Release", +#endif + "net10.0", + DllName); + } + } + + private static IEnumerable EnumerateRoots(string? packageRoot, string? hostPath) + { + if (!string.IsNullOrWhiteSpace(packageRoot)) + { + yield return Path.GetFullPath(packageRoot); + } + + if (!string.IsNullOrWhiteSpace(hostPath)) + { + var hostDirectory = Path.GetDirectoryName(Path.GetFullPath(hostPath)); + if (!string.IsNullOrWhiteSpace(hostDirectory)) + { + yield return hostDirectory; + } + } + + yield return AppContext.BaseDirectory; + yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..")); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs b/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs new file mode 100644 index 0000000..bc57e45 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs @@ -0,0 +1,19 @@ +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal static class AirAppInstanceKey +{ + public static string Build(string appId, string? sourceComponentId, string? sourcePlacementId) + { + var normalizedAppId = Normalize(appId, "unknown"); + var normalizedComponentId = Normalize(sourceComponentId, "none"); + var normalizedPlacementId = Normalize(sourcePlacementId, "none"); + return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}"; + } + + private static string Normalize(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) + ? fallback + : value.Trim(); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs new file mode 100644 index 0000000..031e050 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; + +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal interface IAirAppProcessStarter +{ + Process? Start(string appId, string sessionId, string instanceKey, string? sourceComponentId, string? sourcePlacementId); +} + +internal sealed class AirAppProcessStarter : IAirAppProcessStarter +{ + private readonly AirAppHostLocator _locator; + private readonly Func _packageRootProvider; + private readonly Func _hostPathProvider; + + public AirAppProcessStarter( + AirAppHostLocator locator, + Func packageRootProvider, + Func hostPathProvider) + { + _locator = locator; + _packageRootProvider = packageRootProvider; + _hostPathProvider = hostPathProvider; + } + + public Process? Start( + string appId, + string sessionId, + string instanceKey, + string? sourceComponentId, + string? sourcePlacementId) + { + var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider()); + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory + }; + + if (OperatingSystem.IsWindows() && + string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase)) + { + startInfo.FileName = hostPath; + } + else + { + startInfo.FileName = "dotnet"; + startInfo.ArgumentList.Add(hostPath); + } + + AddArgument(startInfo, "--app-id", appId); + AddArgument(startInfo, "--session-id", sessionId); + AddArgument(startInfo, "--instance-key", instanceKey); + AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName); + + if (!string.IsNullOrWhiteSpace(sourceComponentId)) + { + AddArgument(startInfo, "--source-component-id", sourceComponentId.Trim()); + } + + if (!string.IsNullOrWhiteSpace(sourcePlacementId)) + { + AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim()); + } + + return Process.Start(startInfo); + } + + private static void AddArgument(ProcessStartInfo startInfo, string name, string value) + { + startInfo.ArgumentList.Add(name); + startInfo.ArgumentList.Add(value); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs new file mode 100644 index 0000000..ec1b142 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs @@ -0,0 +1,29 @@ +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable +{ + private readonly PublicIpcHostService _host; + + public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService) + { + LifecycleService = lifecycleService; + _host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName); + _host.RegisterPublicService(lifecycleService); + } + + public LauncherAirAppLifecycleService LifecycleService { get; } + + public void Start() + { + _host.Start(); + Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'."); + } + + public void Dispose() + { + _host.Dispose(); + } +} diff --git a/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs new file mode 100644 index 0000000..db45807 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs @@ -0,0 +1,332 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Services.AirApp; + +internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService +{ + private readonly object _gate = new(); + private readonly IAirAppProcessStarter _processStarter; + private readonly Dictionary _instances = new(StringComparer.OrdinalIgnoreCase); + + public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter) + { + _processStarter = processStarter; + } + + public Task OpenAsync(AirAppOpenRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var appId = Normalize(request.AppId, "unknown"); + var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId); + Logger.Info( + $"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}."); + + lock (_gate) + { + CleanupExitedInstances(); + + if (_instances.TryGetValue(instanceKey, out var existing) && IsProcessAlive(existing.ProcessId)) + { + TryActivateProcess(existing.ProcessId); + existing.Touch(); + return Task.FromResult(BuildResult(true, "activated_existing", "Activated existing Air APP instance.", existing)); + } + + var sessionId = Guid.NewGuid().ToString("N"); + try + { + var process = _processStarter.Start( + appId, + sessionId, + instanceKey, + request.SourceComponentId, + request.SourcePlacementId); + if (process is null) + { + return Task.FromResult(BuildResult(false, "start_failed", "AirAppHost process was not created.", null)); + } + + var instance = new ManagedAirAppInstance( + instanceKey, + appId, + sessionId, + process.Id, + $"{appId} - Air APP", + request.SourceComponentId, + request.SourcePlacementId); + _instances[instanceKey] = instance; + Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}."); + return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance)); + } + catch (Exception ex) + { + Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}"); + return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null)); + } + } + } + + public Task ActivateAsync(string instanceKey) + { + lock (_gate) + { + CleanupExitedInstances(); + if (!_instances.TryGetValue(instanceKey, out var instance)) + { + return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null)); + } + + var accepted = TryActivateProcess(instance.ProcessId); + instance.Touch(); + return Task.FromResult(BuildResult( + accepted, + accepted ? "activated" : "activation_failed", + accepted ? "Air APP instance activated." : "Failed to activate Air APP instance.", + instance)); + } + } + + public Task CloseAsync(string instanceKey) + { + lock (_gate) + { + CleanupExitedInstances(); + if (!_instances.TryGetValue(instanceKey, out var instance)) + { + return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null)); + } + + var accepted = TryCloseProcess(instance.ProcessId); + instance.Touch(); + return Task.FromResult(BuildResult( + accepted, + accepted ? "close_requested" : "close_failed", + accepted ? "Air APP close requested." : "Failed to request Air APP close.", + instance)); + } + } + + public Task GetInstancesAsync() + { + lock (_gate) + { + CleanupExitedInstances(); + return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray()); + } + } + + public Task RegisterAsync(AirAppRegistrationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + lock (_gate) + { + var instanceKey = string.IsNullOrWhiteSpace(request.InstanceKey) + ? AirAppInstanceKey.Build(request.AppId, request.SourceComponentId, request.SourcePlacementId) + : request.InstanceKey.Trim(); + var instance = new ManagedAirAppInstance( + instanceKey, + Normalize(request.AppId, "unknown"), + Normalize(request.SessionId, Guid.NewGuid().ToString("N")), + request.ProcessId, + Normalize(request.WindowTitle, $"{request.AppId} - Air APP"), + request.SourceComponentId, + request.SourcePlacementId); + _instances[instanceKey] = instance; + Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}."); + return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance)); + } + } + + public Task UnregisterAsync(string instanceKey, int processId) + { + lock (_gate) + { + if (_instances.TryGetValue(instanceKey, out var instance) && + (processId <= 0 || instance.ProcessId == processId)) + { + _instances.Remove(instanceKey); + Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}."); + return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance)); + } + + return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null)); + } + } + + public bool HasLiveAirApps() + { + lock (_gate) + { + CleanupExitedInstances(); + return _instances.Values.Any(static instance => IsProcessAlive(instance.ProcessId)); + } + } + + private void CleanupExitedInstances() + { + var exitedKeys = _instances + .Where(static pair => !IsProcessAlive(pair.Value.ProcessId)) + .Select(static pair => pair.Key) + .ToList(); + + foreach (var key in exitedKeys) + { + _instances.Remove(key); + Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'."); + } + } + + private static AirAppOperationResult BuildResult( + bool accepted, + string code, + string message, + ManagedAirAppInstance? instance) + { + return new AirAppOperationResult(accepted, code, message, instance?.ToInfo()); + } + + private static bool TryActivateProcess(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + if (process.HasExited) + { + return false; + } + + if (!OperatingSystem.IsWindows()) + { + return true; + } + + process.Refresh(); + var handle = process.MainWindowHandle; + if (handle == IntPtr.Zero) + { + return true; + } + + _ = ShowWindow(handle, SW_SHOWNORMAL); + _ = SetForegroundWindow(handle); + return true; + } + catch + { + return false; + } + } + + private static bool TryCloseProcess(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + if (process.HasExited) + { + return false; + } + + return process.CloseMainWindow(); + } + catch + { + return false; + } + } + + private static bool IsProcessAlive(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + return false; + } + } + + private static string Normalize(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) + ? fallback + : value.Trim(); + } + + private const int SW_SHOWNORMAL = 1; + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + private sealed class ManagedAirAppInstance + { + private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow; + + public ManagedAirAppInstance( + string instanceKey, + string appId, + string sessionId, + int processId, + string windowTitle, + string? sourceComponentId, + string? sourcePlacementId) + { + InstanceKey = instanceKey; + AppId = appId; + SessionId = sessionId; + ProcessId = processId; + WindowTitle = windowTitle; + SourceComponentId = sourceComponentId; + SourcePlacementId = sourcePlacementId; + UpdatedAtUtc = _startedAtUtc; + } + + public string InstanceKey { get; } + + public string AppId { get; } + + public string SessionId { get; } + + public int ProcessId { get; } + + public string WindowTitle { get; } + + public string? SourceComponentId { get; } + + public string? SourcePlacementId { get; } + + public DateTimeOffset UpdatedAtUtc { get; private set; } + + public void Touch() + { + UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + public AirAppInstanceInfo ToInfo() + { + return new AirAppInstanceInfo( + InstanceKey, + AppId, + SessionId, + ProcessId, + WindowTitle, + SourceComponentId, + SourcePlacementId, + IsProcessAlive(ProcessId), + _startedAtUtc, + UpdatedAtUtc); + } + } +} diff --git a/LanMountainDesktop.Shared.IPC/Abstractions/Services/IAirAppLifecycleService.cs b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IAirAppLifecycleService.cs new file mode 100644 index 0000000..68c6474 --- /dev/null +++ b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IAirAppLifecycleService.cs @@ -0,0 +1,52 @@ +using dotnetCampus.Ipc.CompilerServices.Attributes; + +namespace LanMountainDesktop.Shared.IPC.Abstractions.Services; + +[IpcPublic(IgnoresIpcException = true)] +public interface IAirAppLifecycleService +{ + Task OpenAsync(AirAppOpenRequest request); + + Task ActivateAsync(string instanceKey); + + Task CloseAsync(string instanceKey); + + Task GetInstancesAsync(); + + Task RegisterAsync(AirAppRegistrationRequest request); + + Task UnregisterAsync(string instanceKey, int processId); +} + +public sealed record AirAppOpenRequest( + string AppId, + string? SourceComponentId, + string? SourcePlacementId, + int RequesterProcessId); + +public sealed record AirAppRegistrationRequest( + string InstanceKey, + string AppId, + string SessionId, + int ProcessId, + string WindowTitle, + string? SourceComponentId, + string? SourcePlacementId); + +public sealed record AirAppInstanceInfo( + string InstanceKey, + string AppId, + string SessionId, + int ProcessId, + string WindowTitle, + string? SourceComponentId, + string? SourcePlacementId, + bool ProcessAlive, + DateTimeOffset StartedAtUtc, + DateTimeOffset UpdatedAtUtc); + +public sealed record AirAppOperationResult( + bool Accepted, + string Code, + string Message, + AirAppInstanceInfo? Instance); diff --git a/LanMountainDesktop.Shared.IPC/IpcConstants.cs b/LanMountainDesktop.Shared.IPC/IpcConstants.cs index 338cdc0..1acee85 100644 --- a/LanMountainDesktop.Shared.IPC/IpcConstants.cs +++ b/LanMountainDesktop.Shared.IPC/IpcConstants.cs @@ -6,6 +6,10 @@ public static class IpcConstants public const string ProtocolVersion = "external-ipc-public-api.v1"; + public const string AirAppLifecyclePipeName = "LanMountainDesktop.Launcher.AirApp.v1"; + + public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1"; + public static class Routes { public const string SessionGetInfo = "lanmountain.session.get-info"; diff --git a/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs b/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs new file mode 100644 index 0000000..acbefa2 --- /dev/null +++ b/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs @@ -0,0 +1,81 @@ +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class AirAppLauncherServiceTests +{ + [Fact] + public void BuildOpenRequest_IncludesWorldClockSourceContext() + { + var request = AirAppLauncherService.BuildOpenRequest( + AirAppLauncherService.WorldClockAppId, + BuiltInComponentIds.DesktopWorldClock, + "placement-7", + 42); + + Assert.Equal("world-clock", request.AppId); + Assert.Equal(BuiltInComponentIds.DesktopWorldClock, request.SourceComponentId); + Assert.Equal("placement-7", request.SourcePlacementId); + Assert.Equal(42, request.RequesterProcessId); + } + + [Fact] + public void BuildOpenRequest_NormalizesEmptyOptionalContext() + { + var request = AirAppLauncherService.BuildOpenRequest( + AirAppLauncherService.WorldClockAppId, + null, + " ", + 42); + + Assert.Equal("world-clock", request.AppId); + Assert.Null(request.SourceComponentId); + Assert.Null(request.SourcePlacementId); + Assert.Equal(42, request.RequesterProcessId); + } + + [Fact] + public void BuildOpenRequest_IncludesWhiteboardSourceContext() + { + var request = AirAppLauncherService.BuildOpenRequest( + AirAppLauncherService.WhiteboardAppId, + BuiltInComponentIds.DesktopWhiteboard, + "whiteboard-placement", + 99); + + Assert.Equal("whiteboard", request.AppId); + Assert.Equal(BuiltInComponentIds.DesktopWhiteboard, request.SourceComponentId); + Assert.Equal("whiteboard-placement", request.SourcePlacementId); + Assert.Equal(99, request.RequesterProcessId); + } + + [Fact] + public void BuildSingleInstanceKey_UsesWhiteboardComponentAndPlacement() + { + var key = AirAppLauncherService.BuildSingleInstanceKey( + AirAppLauncherService.WhiteboardAppId, + BuiltInComponentIds.DesktopBlackboardLandscape, + "placement-3"); + + Assert.Equal( + $"whiteboard:{BuiltInComponentIds.DesktopBlackboardLandscape}:placement-3", + key); + } + + [Fact] + public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid() + { + var startInfo = AirAppLauncherService.CreateBrokerStartInfo( + @"C:\Apps\LanMountainDesktop.Launcher.exe", + 12345); + + Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName); + Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory); + Assert.False(startInfo.UseShellExecute); + Assert.Equal( + ["air-app-broker", "--requester-pid", "12345"], + startInfo.ArgumentList); + } +} diff --git a/LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs b/LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs new file mode 100644 index 0000000..d6fa02f --- /dev/null +++ b/LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs @@ -0,0 +1,110 @@ +using FluentIcons.Common; +using LanMountainDesktop.ComponentSystem; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class ComponentCategoryIconResolverTests +{ + [Fact] + public void ResolveCategoryIcon_AllCategory_ReturnsApps() + { + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("all", []); + Assert.Equal(Icon.Apps, result); + } + + [Fact] + public void ResolveCategoryIcon_ResolvesFromFirstComponentIconKey() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Clock", "Clock", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Clock", components); + Assert.Equal(Icon.Clock, result); + } + + [Fact] + public void ResolveCategoryIcon_WeatherSunny_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "WeatherSunny", "Weather", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Weather", components); + Assert.Equal(Icon.WeatherSunny, result); + } + + [Fact] + public void ResolveCategoryIcon_News_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "News", "Info", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Info", components); + Assert.Equal(Icon.News, result); + } + + [Fact] + public void ResolveCategoryIcon_Edit_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Edit", "Board", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Board", components); + Assert.Equal(Icon.Edit, result); + } + + [Fact] + public void ResolveCategoryIcon_InvalidIconKey_FallsBackToApps() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "NonExistentIcon", "Other", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Other", components); + Assert.Equal(Icon.Apps, result); + } + + [Fact] + public void ResolveCategoryIcon_EmptyComponents_FallsBackToApps() + { + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Unknown", []); + Assert.Equal(Icon.Apps, result); + } + + [Fact] + public void ResolveCategoryIcon_Play_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Play", "Media", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Media", components); + Assert.Equal(Icon.Play, result); + } + + [Fact] + public void ResolveCategoryIcon_Calculator_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Calculator", "Calculator", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Calculator", components); + Assert.Equal(Icon.Calculator, result); + } + + [Fact] + public void ResolveCategoryIcon_Folder_ResolvesCorrectly() + { + var components = new[] + { + new DesktopComponentDefinition("test1", "Test", "Folder", "File", 2, 2, false, true) + }; + var result = ComponentCategoryIconResolver.ResolveCategoryIcon("File", components); + Assert.Equal(Icon.Folder, result); + } +} diff --git a/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs b/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs index b0e6c11..a08b476 100644 --- a/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs +++ b/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs @@ -117,6 +117,40 @@ public sealed class DesktopComponentRenderModeTests Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 999, "Unknown", isDaylight: true)); } + [Theory] + [InlineData(WeatherVisualStyleId.GoogleWeatherV4, "google")] + [InlineData(WeatherVisualStyleId.Geometric, "geometric")] + [InlineData(WeatherVisualStyleId.Breezy, "breezy")] + [InlineData(WeatherVisualStyleId.LemonFlutter, "lemon")] + public void WeatherSceneProfileResolver_UsesDistinctRendererPerVisualStyle(string styleId, string expectedRenderer) + { + var profile = WeatherSceneProfileResolver.Resolve(styleId, MaterialWeatherCondition.Rain, isNight: false, isLive: true); + + Assert.Equal(expectedRenderer, profile.RendererId); + Assert.Equal("rain", profile.WeatherLayerId); + Assert.True(profile.IsLive); + } + + [Theory] + [InlineData(MaterialWeatherCondition.Clear, "clear")] + [InlineData(MaterialWeatherCondition.PartlyCloudy, "partly-cloudy")] + [InlineData(MaterialWeatherCondition.Cloudy, "cloudy")] + [InlineData(MaterialWeatherCondition.Rain, "rain")] + [InlineData(MaterialWeatherCondition.Storm, "storm")] + [InlineData(MaterialWeatherCondition.Snow, "snow")] + [InlineData(MaterialWeatherCondition.Fog, "fog")] + [InlineData(MaterialWeatherCondition.Haze, "haze")] + [InlineData(MaterialWeatherCondition.Unknown, "ambient")] + public void WeatherSceneProfileResolver_UsesDistinctWeatherLayerPerCondition(MaterialWeatherCondition condition, string expectedLayer) + { + var profile = WeatherSceneProfileResolver.Resolve(WeatherVisualStyleId.Breezy, condition, isNight: true, isLive: false); + + Assert.Equal("breezy", profile.RendererId); + Assert.Equal(expectedLayer, profile.WeatherLayerId); + Assert.True(profile.IsNight); + Assert.False(profile.IsLive); + } + private static DesktopComponentRuntimeDescriptor CreateDescriptor() { Assert.True(CreateRuntimeRegistry().TryGetDescriptor(ComponentId, out var descriptor)); diff --git a/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs b/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs new file mode 100644 index 0000000..d38206e --- /dev/null +++ b/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs @@ -0,0 +1,164 @@ +using System.Diagnostics; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Services.AirApp; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class LauncherAirAppLifecycleServiceTests +{ + [Fact] + public async Task OpenAsync_ReusesExistingInstanceForSameKey() + { + var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess()); + var service = new LauncherAirAppLifecycleService(starter); + var request = new AirAppOpenRequest( + "whiteboard", + BuiltInComponentIds.DesktopWhiteboard, + "placement-1", + Environment.ProcessId); + + var first = await service.OpenAsync(request); + var second = await service.OpenAsync(request); + + Assert.True(first.Accepted); + Assert.True(second.Accepted); + Assert.Equal("started", first.Code); + Assert.Equal("activated_existing", second.Code); + Assert.Equal(1, starter.StartCount); + Assert.Equal(first.Instance!.InstanceKey, second.Instance!.InstanceKey); + } + + [Fact] + public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart() + { + var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess()); + var service = new LauncherAirAppLifecycleService(starter); + var instanceKey = AirAppInstanceKey.Build( + "whiteboard", + BuiltInComponentIds.DesktopWhiteboard, + "placement-2"); + + _ = await service.RegisterAsync(new AirAppRegistrationRequest( + instanceKey, + "whiteboard", + "dead-session", + int.MaxValue, + "Dead Air APP", + BuiltInComponentIds.DesktopWhiteboard, + "placement-2")); + + var result = await service.OpenAsync(new AirAppOpenRequest( + "whiteboard", + BuiltInComponentIds.DesktopWhiteboard, + "placement-2", + Environment.ProcessId)); + + Assert.True(result.Accepted); + Assert.Equal("started", result.Code); + Assert.Equal(1, starter.StartCount); + Assert.Equal(Environment.ProcessId, result.Instance!.ProcessId); + } + + [Fact] + public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess())); + var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1"); + + _ = await service.RegisterAsync(new AirAppRegistrationRequest( + instanceKey, + "world-clock", + "session", + Environment.ProcessId, + "World Clock", + BuiltInComponentIds.DesktopWorldClock, + "clock-1")); + + Assert.True(service.HasLiveAirApps()); + + _ = await service.UnregisterAsync(instanceKey, Environment.ProcessId); + + Assert.False(service.HasLiveAirApps()); + } + + [Fact] + public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); + + Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service)); + } + + [Fact] + public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); + + Assert.False(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); + } + + [Fact] + public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive() + { + var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); + var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2"); + + _ = await service.RegisterAsync(new AirAppRegistrationRequest( + instanceKey, + "world-clock", + "session", + Environment.ProcessId, + "World Clock", + BuiltInComponentIds.DesktopWorldClock, + "clock-2")); + + Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); + } + + [Fact] + public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment() + { + var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); + + var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]); + + Assert.True(context.IsGuiCommand); + Assert.True(context.IsAirAppBrokerCommand); + Assert.True(context.IsDebugMode); + Assert.Equal(42, context.GetIntOption("requester-pid", 0)); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment); + } + } + + private sealed class TestAirAppProcessStarter : IAirAppProcessStarter + { + private readonly Process? _process; + + public TestAirAppProcessStarter(Process? process) + { + _process = process; + } + + public int StartCount { get; private set; } + + public Process? Start( + string appId, + string sessionId, + string instanceKey, + string? sourceComponentId, + string? sourcePlacementId) + { + StartCount++; + return _process; + } + } +} diff --git a/LanMountainDesktop.Tests/MusicControlViewModelTests.cs b/LanMountainDesktop.Tests/MusicControlViewModelTests.cs new file mode 100644 index 0000000..b2e459d --- /dev/null +++ b/LanMountainDesktop.Tests/MusicControlViewModelTests.cs @@ -0,0 +1,42 @@ +using LanMountainDesktop.Services; +using LanMountainDesktop.ViewModels; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class MusicControlViewModelTests : IDisposable +{ + private readonly MusicControlViewModel _viewModel; + + public MusicControlViewModelTests() + { + _viewModel = new MusicControlViewModel(); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + _viewModel.Dispose(); + _viewModel.Dispose(); + } + + [Fact] + public async Task Dispose_StopsRefreshAfterCancellation() + { + var refreshTask = _viewModel.RefreshAsync(); + _viewModel.Dispose(); + + await Task.Delay(100); + } + + [Fact] + public void ViewModel_InitializesWithNoSession() + { + Assert.True(_viewModel.IsNoMedia); + } + + public void Dispose() + { + _viewModel.Dispose(); + } +} diff --git a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs new file mode 100644 index 0000000..2a73f03 --- /dev/null +++ b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs @@ -0,0 +1,88 @@ +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class WindowLayerIsolationTests +{ + [Fact] + public void AirAppWindow_DoesNotUseDesktopBottomMostOrTopmostPromotion() + { + var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs"); + + Assert.DoesNotContain("WindowBottomMostServiceFactory", source); + Assert.DoesNotContain("IWindowBottomMostService", source); + Assert.DoesNotContain("SendToBottom", source); + Assert.DoesNotContain("Topmost = true", source); + Assert.DoesNotContain("Topmost=true", source); + } + + [Fact] + public void AirAppWindowDescriptor_DefinesSupportedChromeModes() + { + var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindowDescriptor.cs"); + + Assert.Contains("AirAppWindowChromeMode", source); + Assert.Contains("Standard", source); + Assert.Contains("Borderless", source); + Assert.Contains("FullScreen", source); + Assert.Contains("Tool", source); + Assert.Contains("BackgroundOnly", source); + } + + [Fact] + public void AirAppWindowDescriptor_MapsBuiltInAppsToExpectedChromeModes() + { + var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindowDescriptor.cs"); + + Assert.Contains("AirAppLaunchOptions.WorldClockAppId", source); + Assert.Contains("AirAppWindowChromeMode.Standard", source); + Assert.Contains("AirAppLaunchOptions.WhiteboardAppId", source); + Assert.Contains("AirAppWindowChromeMode.FullScreen", source); + } + + [Fact] + public void FusedDesktopWindows_KeepDesktopBottomMostBoundary() + { + var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs"); + var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs"); + + Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow); + Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow); + Assert.Contains("SendToBottom", desktopWidgetWindow); + + Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow); + Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow); + Assert.Contains("SendToBottom", transparentOverlayWindow); + } + + [Fact] + public void FusedDesktopManager_RefreshesDesktopLayerAfterShowingWidgets() + { + var source = ReadRepositoryFile("LanMountainDesktop", "Services", "FusedDesktopManagerService.cs"); + + Assert.Contains("existingWindow.RefreshDesktopLayer()", source); + Assert.Contains("window.RefreshDesktopLayer()", source); + } + + private static string ReadRepositoryFile(params string[] segments) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray()); + if (File.Exists(candidate)) + { + return File.ReadAllText(candidate); + } + + if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx"))) + { + break; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'."); + } +} diff --git a/LanMountainDesktop.slnx b/LanMountainDesktop.slnx index d28439a..5a681ea 100644 --- a/LanMountainDesktop.slnx +++ b/LanMountainDesktop.slnx @@ -9,6 +9,7 @@ + diff --git a/LanMountainDesktop/ComponentSystem/ComponentCategoryIconResolver.cs b/LanMountainDesktop/ComponentSystem/ComponentCategoryIconResolver.cs new file mode 100644 index 0000000..1c85e44 --- /dev/null +++ b/LanMountainDesktop/ComponentSystem/ComponentCategoryIconResolver.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using FluentIcons.Common; + +namespace LanMountainDesktop.ComponentSystem; + +public static class ComponentCategoryIconResolver +{ + public static Icon ResolveCategoryIcon( + string categoryId, + IEnumerable categoryComponents) + { + if (string.Equals(categoryId, "all", StringComparison.OrdinalIgnoreCase)) + { + return Icon.Apps; + } + + var firstComponent = categoryComponents.FirstOrDefault(); + if (firstComponent is null || string.IsNullOrWhiteSpace(firstComponent.IconKey)) + { + return Icon.Apps; + } + + if (Enum.TryParse(firstComponent.IconKey, ignoreCase: true, out var icon)) + { + return icon; + } + + return Icon.Apps; + } +} diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 3b157ce..69b9eaa 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -107,4 +107,32 @@ + + + + + + + <_AirAppHostOutput Include="..\LanMountainDesktop.AirAppHost\bin\$(Configuration)\$(TargetFramework)\**\*" /> + + + + + + + + <_AirAppHostPublishOutput Include="..\LanMountainDesktop.AirAppHost\bin\$(Configuration)\$(TargetFramework)\**\*" /> + + + + diff --git a/LanMountainDesktop/Services/AirAppLauncherService.cs b/LanMountainDesktop/Services/AirAppLauncherService.cs new file mode 100644 index 0000000..2778474 --- /dev/null +++ b/LanMountainDesktop/Services/AirAppLauncherService.cs @@ -0,0 +1,160 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Services; + +public interface IAirAppLauncherService +{ + void OpenWorldClock(string? sourcePlacementId); + + void OpenWhiteboard(string componentId, string? sourcePlacementId); +} + +internal sealed class AirAppLauncherService : IAirAppLauncherService +{ + public const string WorldClockAppId = "world-clock"; + public const string WhiteboardAppId = "whiteboard"; + + private const int LauncherIpcRetryCount = 4; + + public void OpenWorldClock(string? sourcePlacementId) + { + _ = OpenAsync(WorldClockAppId, BuiltInComponentIds.DesktopWorldClock, sourcePlacementId); + } + + public void OpenWhiteboard(string componentId, string? sourcePlacementId) + { + _ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId); + } + + internal static AirAppOpenRequest BuildOpenRequest( + string appId, + string? sourceComponentId, + string? sourcePlacementId, + int requesterProcessId) + { + return new AirAppOpenRequest( + appId.Trim(), + string.IsNullOrWhiteSpace(sourceComponentId) ? null : sourceComponentId.Trim(), + string.IsNullOrWhiteSpace(sourcePlacementId) ? null : sourcePlacementId.Trim(), + requesterProcessId); + } + + internal static string BuildSingleInstanceKey(string appId, string? sourceComponentId, string? sourcePlacementId) + { + var normalizedAppId = string.IsNullOrWhiteSpace(appId) ? "unknown" : appId.Trim(); + var normalizedComponentId = string.IsNullOrWhiteSpace(sourceComponentId) ? "none" : sourceComponentId.Trim(); + var normalizedPlacementId = string.IsNullOrWhiteSpace(sourcePlacementId) ? "none" : sourcePlacementId.Trim(); + return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}"; + } + + private static async Task OpenAsync(string appId, string sourceComponentId, string? sourcePlacementId) + { + var request = BuildOpenRequest(appId, sourceComponentId, sourcePlacementId, Environment.ProcessId); + try + { + var result = await SendOpenRequestAsync(request).ConfigureAwait(false); + if (result.Accepted) + { + AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'."); + return; + } + + AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'."); + } + catch (Exception ex) + { + AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex); + } + } + + private static async Task SendOpenRequestAsync(AirAppOpenRequest request) + { + Exception? lastException = null; + for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++) + { + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + return await proxy.OpenAsync(request).ConfigureAwait(false); + } + catch (Exception ex) + { + lastException = ex; + if (attempt == 1) + { + AppLogger.Warn( + "AirAppLauncher", + $"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.", + ex); + TryStartLauncher(); + } + + await Task.Delay(250 * attempt).ConfigureAwait(false); + } + } + + throw new InvalidOperationException( + $"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.", + lastException); + } + + internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId) + { + var startInfo = new ProcessStartInfo + { + FileName = launcherPath, + WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("air-app-broker"); + startInfo.ArgumentList.Add("--requester-pid"); + startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return startInfo; + } + + private static void TryStartLauncher() + { + try + { + var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath(); + if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath)) + { + AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found."); + return; + } + + var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId); + _ = Process.Start(startInfo); + AppLogger.Info( + "AirAppLauncher", + $"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'."); + } + catch (Exception ex) + { + AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex); + } + } +} + +public static class AirAppLauncherServiceProvider +{ + private static readonly object Gate = new(); + private static IAirAppLauncherService? _instance; + + public static IAirAppLauncherService GetOrCreate() + { + lock (Gate) + { + _instance ??= new AirAppLauncherService(); + return _instance; + } + } +} diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs index 789d511..1162004 100644 --- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs +++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs @@ -124,6 +124,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService { existingWindow.Show(); } + + existingWindow.RefreshDesktopLayer(); } else { @@ -136,6 +138,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService _widgetWindows[placement.PlacementId] = window; window.Show(); window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y); + window.RefreshDesktopLayer(); } } catch (Exception ex) diff --git a/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs b/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs index 3ede26d..f2b67ed 100644 --- a/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs +++ b/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs @@ -33,7 +33,7 @@ public sealed class ComponentLibraryCategoryViewModel public ComponentLibraryCategoryViewModel( string id, string title, - Symbol icon, + Icon icon, IReadOnlyList components) { Id = id; @@ -46,7 +46,7 @@ public sealed class ComponentLibraryCategoryViewModel public string Title { get; } - public Symbol Icon { get; } + public Icon Icon { get; } public IReadOnlyList Components { get; } } diff --git a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs index e95741f..5a1716c 100644 --- a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs +++ b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs @@ -58,7 +58,9 @@ public partial class ComponentLibraryWindow : Window _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( category.Id, GetLocalizedCategoryTitle(category.Id), - ResolveCategoryIcon(category.Id), + ComponentCategoryIconResolver.ResolveCategoryIcon( + category.Id, + _componentLibraryService.GetDefinitions().Where(d => string.Equals(d.Category, category.Id, StringComparison.OrdinalIgnoreCase))), itemModels)); } @@ -176,50 +178,6 @@ public partial class ComponentLibraryWindow : Window } } - private Symbol ResolveCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Clock; - } - - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.CalendarDate; - } - - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.WeatherSunny; - } - - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Edit; - } - - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Play; - } - - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Info; - } - - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Calculator; - } - - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Hourglass; - } - - return Symbol.Apps; - } private string GetLocalizedCategoryTitle(string categoryId) { diff --git a/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs b/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs index 9e52c7f..6fbc3d2 100644 --- a/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs +++ b/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs @@ -1,4 +1,4 @@ -using LanMountainDesktop.Services; +using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; @@ -7,6 +7,11 @@ public interface IDesktopComponentWidget void ApplyCellSize(double cellSize); } +public interface IDesktopComponentLifecycleWidget +{ + void OnWidgetDestroyed(); +} + public interface ITimeZoneAwareComponentWidget { void SetTimeZoneService(TimeZoneService timeZoneService); diff --git a/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs b/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs index 7198252..f85bbec 100644 --- a/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs +++ b/LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs @@ -7,6 +7,47 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; +internal readonly record struct WeatherSceneProfile( + string StyleId, + MaterialWeatherCondition Condition, + string RendererId, + string WeatherLayerId, + bool IsNight, + bool IsLive) +{ + public string Signature => $"{RendererId}:{WeatherLayerId}:{(IsNight ? "night" : "day")}:{(IsLive ? "live" : "still")}"; +} + +internal static class WeatherSceneProfileResolver +{ + public static WeatherSceneProfile Resolve(string? styleId, MaterialWeatherCondition condition, bool isNight, bool isLive) + { + var normalized = WeatherVisualStyleCatalog.Normalize(styleId); + var rendererId = normalized switch + { + WeatherVisualStyleId.Geometric => "geometric", + WeatherVisualStyleId.Breezy => "breezy", + WeatherVisualStyleId.LemonFlutter => "lemon", + _ => "google" + }; + + var layerId = condition switch + { + MaterialWeatherCondition.Clear => "clear", + MaterialWeatherCondition.PartlyCloudy => "partly-cloudy", + MaterialWeatherCondition.Cloudy => "cloudy", + MaterialWeatherCondition.Rain => "rain", + MaterialWeatherCondition.Storm => "storm", + MaterialWeatherCondition.Snow => "snow", + MaterialWeatherCondition.Fog => "fog", + MaterialWeatherCondition.Haze => "haze", + _ => "ambient" + }; + + return new WeatherSceneProfile(normalized, condition, rendererId, layerId, isNight, isLive); + } +} + public sealed class MaterialWeatherSceneControl : Control { private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(66) }; @@ -16,32 +57,37 @@ public sealed class MaterialWeatherSceneControl : Control private double _phase; private bool _isLive; private bool _isAttached; - - private static readonly Random _rng = new(42); + private bool _isNight; public MaterialWeatherSceneControl() { IsHitTestVisible = false; _timer.Tick += (_, _) => { - _phase = (_phase + 0.008) % 1d; + _phase = (_phase + 0.0065) % 1d; InvalidateVisual(); }; } - public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive) + public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive, bool isNight) { _styleId = WeatherVisualStyleCatalog.Normalize(styleId); _condition = condition; _palette = palette; _isLive = isLive; + _isNight = isNight; UpdateTimer(); InvalidateVisual(); } + public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive) + { + Apply(styleId, condition, palette, isLive, EstimateNightFromPalette(palette)); + } + public void Apply(MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive) { - Apply(_styleId, condition, palette, isLive); + Apply(_styleId, condition, palette, isLive, EstimateNightFromPalette(palette)); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) @@ -63,26 +109,29 @@ public sealed class MaterialWeatherSceneControl : Control base.Render(context); var rect = new Rect(Bounds.Size); - if (rect.Width <= 1 || rect.Height <= 1) return; + if (rect.Width <= 1 || rect.Height <= 1) + { + return; + } + var profile = WeatherSceneProfileResolver.Resolve(_styleId, _condition, _isNight, _isLive); context.DrawRectangle(CreateLinearBrush(_palette.BackgroundTop, _palette.BackgroundBottom, 0, 0, 1, 1), null, rect); using (context.PushClip(rect)) { - DrawStyleDecoration(context, rect); - - switch (_condition) + switch (profile.RendererId) { - case MaterialWeatherCondition.Rain: - case MaterialWeatherCondition.Storm: - DrawRain(context, rect, _condition == MaterialWeatherCondition.Storm); + case "geometric": + RenderGeometricScene(context, rect, profile); break; - case MaterialWeatherCondition.Snow: - DrawSnow(context, rect); + case "breezy": + RenderBreezyScene(context, rect, profile); break; - case MaterialWeatherCondition.Fog: - case MaterialWeatherCondition.Haze: - DrawFog(context, rect); + case "lemon": + RenderLemonScene(context, rect, profile); + break; + default: + RenderGoogleScene(context, rect, profile); break; } } @@ -90,287 +139,537 @@ public sealed class MaterialWeatherSceneControl : Control private void UpdateTimer() { - if (_isLive && _isAttached) _timer.Start(); - else _timer.Stop(); - } - - private void DrawStyleDecoration(DrawingContext ctx, Rect r) - { - var t = Math.Sin(_phase * Math.PI * 2d); - switch (_styleId) + if (_isLive && _isAttached) { - case WeatherVisualStyleId.Geometric: - DrawGeometricDecoration(ctx, r, t); - break; - case WeatherVisualStyleId.Breezy: - DrawBreezyDecoration(ctx, r, t); - break; - case WeatherVisualStyleId.LemonFlutter: - DrawLemonDecoration(ctx, r, t); - break; + _timer.Start(); + } + else + { + _timer.Stop(); } } - private void DrawGeometricDecoration(DrawingContext ctx, Rect r, double t) + private void RenderGoogleScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { var min = Math.Min(r.Width, r.Height); + var t = Oscillate(0); - 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); + 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); - 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); - - 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); - } - - private void DrawBreezyDecoration(DrawingContext ctx, Rect r, double t) - { - var min = Math.Min(r.Width, r.Height); - - 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); - - for (var i = 0; i < 4; i++) - { - 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); - } - - 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) - { - var min = Math.Min(r.Width, r.Height); - - switch (_condition) + switch (profile.Condition) { case MaterialWeatherCondition.Clear: - case MaterialWeatherCondition.PartlyCloudy: case MaterialWeatherCondition.Unknown: - DrawSunScene(ctx, r, min, t); + 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 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 MaterialWeatherCondition.Cloudy: - DrawCloudScene(ctx, r, min, t); + 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: - DrawRainScene(ctx, r, min, t); + 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: - DrawSnowScene(ctx, r, min, t); + 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; - default: - DrawSunScene(ctx, r, min, t); + 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; } - - 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 DrawSunScene(DrawingContext ctx, Rect r, double min, double t) + private void RenderGeometricScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { - var cx = r.Width * 0.70; - var cy = r.Height * 0.25; + var min = Math.Min(r.Width, r.Height); + var t = Oscillate(0.2); - 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); + 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); - 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++) + switch (profile.Condition) { - 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)); + 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 DrawCloudScene(DrawingContext ctx, Rect r, double min, double t) + private void RenderBreezyScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { - 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 min = Math.Min(r.Width, r.Height); + var t = Oscillate(0.4); - var pen = new Pen(new SolidColorBrush(_palette.PrimaryShape, 0.14), Math.Max(1.5, min * 0.010), lineCap: PenLineCap.Round); - var drift = t * 6; + 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); - 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); - } - - private void DrawRainScene(DrawingContext ctx, Rect r, double min, double t) - { - 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 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++) + switch (profile.Condition) { - 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)); + 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; } } - private void DrawSnowScene(DrawingContext ctx, Rect r, double min, double t) + private void RenderLemonScene(DrawingContext ctx, Rect r, WeatherSceneProfile profile) { - 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 t = Oscillate(0.6); - 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); + 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 (profile.Condition) + { + case MaterialWeatherCondition.Clear: + case MaterialWeatherCondition.Unknown: + 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: + 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: + 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: + 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; + 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; + } + } + + 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 DrawCloudCluster(DrawingContext ctx, Rect r, double nx, double ny, double scale, Color color, double alpha, bool filled) + { + 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; + + 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); + + if (filled) + { + 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 DrawBreezyCloudBands(DrawingContext ctx, Rect r, double yBase, int density, double alpha) + { + 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 DrawRainField(DrawingContext ctx, Rect r, double startY, double densityScale, Color color, double alpha, bool storm) + { + 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)); + } + } + + 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 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 DrawRainBands(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 / 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)); + } + } + + 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 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 DrawFogBands(DrawingContext ctx, Rect r, Color color, double alpha, bool curved) + { + 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)); + } + } + } + + 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); + } + } + + 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()) + { + g.BeginFigure(new Point(startX, baseY), false); + var step = Math.Max(3, width / 48); + for (var x = 0d; x <= width; x += step) + { + 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.DrawGeometry(null, pen, stream); + } + + private void DrawLightning(DrawingContext ctx, Rect r, double nx, double ny, double scale, Color color, double alpha) + { + 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); + } + + ctx.DrawGeometry(new SolidColorBrush(color, alpha), null, bolt); + } + + 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)); + } + } + + 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 + 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 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)); } } - private void DrawRadialGlow(DrawingContext ctx, double cx, double cy, double radius, Color baseColor, double peakAlpha, double centerBoost) + private void DrawTriangle(DrawingContext ctx, double cx, double cy, double radius, Color color, double alpha, double rotate) { - if (radius < 1) return; + var triangle = new StreamGeometry(); + using (var g = triangle.Open()) + { + 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); + } + } - var peak = (byte)Math.Clamp(peakAlpha * 255, 0, 255); - var edge = (byte)0; - var center = (byte)Math.Clamp(centerBoost * 255, 0, 255); + g.EndFigure(true); + } + + ctx.DrawGeometry(new SolidColorBrush(color, alpha), null, triangle); + } + + private void DrawCircle(DrawingContext ctx, double cx, double cy, double radius, Color color, double alpha) + { + if (radius <= 0) + { + return; + } + + 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) + { + return; + } var brush = new RadialGradientBrush { Center = new RelativePoint(0.5, 0.5, RelativeUnit.Relative), GradientStops = { - 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) + 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 DrawArcSegment(DrawingContext ctx, double cx, double cy, double radius, double startDeg, double sweepDeg, Color color, double alpha, double thickness) + private static void DrawEllipse(DrawingContext ctx, IBrush? brush, Pen? pen, double cx, double cy, double rx, double ry) { - if (radius < 2) return; + ctx.DrawEllipse(brush, pen, new Point(cx, cy), Math.Max(0.1, rx), Math.Max(0.1, ry)); + } - var pen = new Pen(new SolidColorBrush(color, (float)alpha), thickness, lineCap: PenLineCap.Round); + private void DrawArc(DrawingContext ctx, double cx, double cy, double radius, double startDeg, double sweepDeg, Color color, double alpha, double thickness) + { + if (radius < 2) + { + return; + } var stream = new StreamGeometry(); - var g = stream.Open(); - - var startRad = startDeg * Math.PI / 180d; - var sweepRad = sweepDeg * Math.PI / 180d; - var steps = Math.Max(8, (int)(sweepDeg / 5)); - - g.BeginFigure(new Point(cx + Math.Cos(startRad) * radius, cy + Math.Sin(startRad) * radius), false); - for (var i = 1; i <= steps; i++) + using (var g = stream.Open()) { - var a = startRad + sweepRad * (i / (double)steps); - g.LineTo(new Point(cx + Math.Cos(a) * radius, cy + Math.Sin(a) * radius)); - } - g.EndFigure(false); + 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)); + } - ctx.DrawGeometry(null, pen, stream); + g.EndFigure(false); + } + + ctx.DrawGeometry(null, new Pen(new SolidColorBrush(color, alpha), Math.Max(1, thickness), lineCap: PenLineCap.Round), stream); } - private void DrawWaveLine(DrawingContext ctx, Rect r, double baseY, double shift, int index, Color color, double alpha) + private double Oscillate(double offset) { - 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; - - var stream = new StreamGeometry(); - var g = stream.Open(); - g.BeginFigure(new Point(startX, baseY), false); - for (var x = startX; x <= endX; x += 3) - { - 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)); - } - g.EndFigure(false); - ctx.DrawGeometry(null, pen, stream); + return Math.Sin((_phase + offset) * Math.PI * 2d); } - private void DrawCloudOutline(DrawingContext ctx, double cx, double cy, double rx, double ry, Pen pen) + private double LightningOpacity() { - 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); + if (!_isLive) + { + 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 void DrawRain(DrawingContext ctx, Rect rect, bool storm) + private static bool EstimateNightFromPalette(MaterialWeatherPalette palette) { - 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++) - { - 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)); - } - - if (storm) - { - 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); - } + 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 void DrawSnow(DrawingContext ctx, Rect rect) + private static Color WithAlpha(Color color, double alpha) { - 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 new Color((byte)Math.Clamp(alpha * 255, 0, 255), color.R, color.G, color.B); } - private void DrawFog(DrawingContext ctx, Rect rect) - { - 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++) - { - 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)); - } - } - - 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 b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml index 6990cd8..431e8b0 100644 --- a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml +++ b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml @@ -135,6 +135,18 @@ + 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 d7d03a3..bfcf875 100644 --- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs +++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs @@ -15,6 +15,7 @@ public partial class DesktopWidgetWindow : Window public DesktopWidgetWindow() { InitializeComponent(); + AppLogger.Info("DesktopWidgetWindow", "Initialized. WindowRole=DesktopSurface."); if (OperatingSystem.IsWindows()) { @@ -44,15 +45,23 @@ public partial class DesktopWidgetWindow : Window } } + public void RefreshDesktopLayer() + { + if (!OperatingSystem.IsWindows() || !IsVisible) + { + return; + } + + _bottomMostService.SendToBottom(this); + Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render); + AppLogger.Info("DesktopWidgetWindow", "Refreshed desktop layer. WindowRole=DesktopSurface."); + } + protected override void OnOpened(EventArgs e) { base.OnOpened(e); - if (OperatingSystem.IsWindows()) - { - _bottomMostService.SendToBottom(this); - Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render); - } + RefreshDesktopLayer(); } protected override void OnSizeChanged(SizeChangedEventArgs e) @@ -72,4 +81,14 @@ public partial class DesktopWidgetWindow : Window new(0, 0, Bounds.Width, Bounds.Height) }); } + + protected override void OnClosing(WindowClosingEventArgs e) + { + if (ComponentContainer.Child is IDisposable disposable) + { + disposable.Dispose(); + } + ComponentContainer.Child = null; + base.OnClosing(e); + } } diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs index 79f757e..2ca6392 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs @@ -81,7 +81,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( "all", L(languageCode, "component_category.all", "All"), - Symbol.Apps, + Icon.Apps, Array.Empty())); var usedCategories = _allDefinitions @@ -97,28 +97,18 @@ public partial class FusedDesktopComponentLibraryControl : UserControl .Select(definition => CreateComponentItem(definition, languageCode)) .ToArray(); + var categoryDefinitions = _allDefinitions + .Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase)) + .ToList(); + _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( category, GetLocalizedCategoryTitle(languageCode, category), - ResolveCategoryIcon(category), + ComponentCategoryIconResolver.ResolveCategoryIcon(category, categoryDefinitions), categoryComponents)); } } - private static Symbol ResolveCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock; - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return Symbol.CalendarDate; - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny; - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit; - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play; - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Info; - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator; - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass; - if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder; - return Symbol.Apps; - } - private string GetLocalizedCategoryTitle(string languageCode, string categoryId) { if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock"); diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index f1575ab..efd4f9b 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -58,7 +58,7 @@ public partial class MainWindow : Window private sealed record ComponentLibraryCategory( string Id, - Symbol Icon, + Icon Icon, string Title, IReadOnlyList Components); @@ -2873,7 +2873,13 @@ public partial class MainWindow : Window private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e) { - if (!_isComponentLibraryOpen || HasActiveDesktopEditSession) + if (!_isComponentLibraryOpen) + { + TryOpenAirAppFromDesktopComponent(sender, e); + return; + } + + if (HasActiveDesktopEditSession) { return; } @@ -2917,6 +2923,29 @@ public partial class MainWindow : Window e.Handled = true; } + private void TryOpenAirAppFromDesktopComponent(object? sender, PointerPressedEventArgs e) + { + if (HasActiveDesktopEditSession || + DesktopPagesViewport is null || + sender is not Border host || + host.Tag is not string placementId || + !e.GetCurrentPoint(host).Properties.IsLeftButtonPressed) + { + return; + } + + var placement = _desktopComponentPlacements.FirstOrDefault(p => + string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + if (placement is null || + !string.Equals(placement.ComponentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _airAppLauncherService.OpenWorldClock(placement.PlacementId); + e.Handled = true; + } + private void SetSelectedDesktopComponent(Border? host) { ClearSelectedLauncherTile(refreshTaskbar: false); @@ -3390,9 +3419,9 @@ public partial class MainWindow : Window var row = new RowDefinition(GridLength.Auto); ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(row); - var icon = new SymbolIcon + var icon = new FluentIcon { - Symbol = category.Icon, + Icon = category.Icon, IconVariant = IconVariant.Regular, FontSize = 18, VerticalAlignment = VerticalAlignment.Center @@ -3461,62 +3490,14 @@ public partial class MainWindow : Window return categories .Select(category => new ComponentLibraryCategory( category.Id, - ResolveComponentLibraryCategoryIcon(category.Id), + ComponentCategoryIconResolver.ResolveCategoryIcon( + category.Id, + _componentRegistry.GetAll().Where(d => string.Equals(d.Category, category.Id, StringComparison.OrdinalIgnoreCase))), GetLocalizedComponentLibraryCategoryTitle(category.Id), category.Components)) .ToList(); } - private Symbol ResolveComponentLibraryCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Clock; - } - - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.CalendarDate; - } - - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.WeatherSunny; - } - - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Edit; - } - - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Play; - } - - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Apps; - } - - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Calculator; - } - - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Hourglass; - } - - if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Folder; - } - - return Symbol.Apps; - } - private string GetLocalizedComponentLibraryCategoryTitle(string categoryId) { if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 43d0549..630f9ef 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -106,6 +106,7 @@ public partial class MainWindow : Window private readonly IComponentLibraryService _componentLibraryService; private readonly IComponentEditorWindowService _componentEditorWindowService; private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService(); + private readonly IAirAppLauncherService _airAppLauncherService = AirAppLauncherServiceProvider.GetOrCreate(); private ComponentLibraryWindow? _detachedComponentLibraryWindow; private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme; private readonly HashSet _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase); diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml index ee3d981..ba4caff 100644 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml @@ -1,5 +1,6 @@ + + + + + + + @@ -43,18 +78,23 @@ HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,20" - Padding="8" + Padding="6" IsHitTestVisible="True"> - - - 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(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 685c8f0..7477dc5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -228,6 +228,30 @@ For the detailed design, migration path, UI strategy, and residual risks, see `d See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration model. +## Air APP Lifecycle + +- Launcher is the lifecycle bridge between the desktop host and Air APP processes. +- The desktop host requests built-in Air APP operations through `IAirAppLifecycleService` on `LanMountainDesktop.Launcher.AirApp.v1`. +- If that pipe is not available because the desktop host was started directly from IDE/dev tooling, the host starts `LanMountainDesktop.Launcher.exe air-app-broker --requester-pid ` and retries the request. +- `air-app-broker` is an internal hidden command that starts only the Air APP lifecycle IPC broker and does not run OOBE, Splash, debug preview windows, or normal desktop launch. +- Launcher owns Air APP process creation, activation, instance-key de-duplication, registration tracking, and exited-process cleanup. +- `LanMountainDesktop.AirAppHost` stays an independent rendering process and registers/unregisters itself with Launcher. +- Launcher remains alive while the desktop host or any Air APP process is alive. +- Air APP windows are ordinary application windows: they do not use fused desktop bottom-most services and do not use global `Topmost` promotion. + +## Fused Desktop Window Layer + +- `TransparentOverlayWindow` and `DesktopWidgetWindow` are desktop-surface windows. +- On Windows, desktop-surface windows may attach to the desktop icon host through `IWindowBottomMostService`, or fall back to `HWND_BOTTOM`. +- Fused desktop windows refresh their bottom-most layer after being opened, shown, or reloaded so they do not cover ordinary apps. + +## Air APP Window Chrome + +- `LanMountainDesktop.AirAppHost` owns Air APP window chrome through `AirAppWindowDescriptor`. +- Supported chrome modes are `Standard`, `Borderless`, `FullScreen`, `Tool`, and reserved `BackgroundOnly`. +- Built-in `world-clock` uses `Standard` chrome with the LanMountain custom title bar. +- Built-in `whiteboard` uses `FullScreen` chrome and supplies its own in-app exit affordance. + ## Launcher OOBE / Elevation Contract - Launcher OOBE state is owned by a per-user JSON file under `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`. diff --git a/docs/LAUNCHER_COORDINATOR.md b/docs/LAUNCHER_COORDINATOR.md index ea79721..ec935b7 100644 --- a/docs/LAUNCHER_COORDINATOR.md +++ b/docs/LAUNCHER_COORDINATOR.md @@ -29,3 +29,14 @@ Launcher and external callers can use: - `EnsureTaskbarEntryAsync()` These APIs report process, shell, tray, taskbar, and activation state separately so callers do not infer health from window visibility alone. + +## Air APP Lifecycle + +- Launcher is also the Air APP lifecycle manager. +- The desktop host requests Air APP operations through `IAirAppLifecycleService` on the dedicated `LanMountainDesktop.Launcher.AirApp.v1` IPC pipe. +- When the dedicated pipe is unavailable, the desktop host starts `LanMountainDesktop.Launcher.exe air-app-broker --requester-pid ` and retries the request. +- `air-app-broker` is a hidden internal command that starts only the Air APP lifecycle IPC host. It bypasses OOBE, Splash, debug preview windows, and normal desktop launch orchestration. +- Launcher creates, activates, tracks, and closes Air APP host processes by instance key: `{appId}:{sourceComponentId}:{sourcePlacementId}`. +- `LanMountainDesktop.AirAppHost` registers itself with Launcher after its window opens and unregisters on close; Launcher also prunes exited processes. +- Launcher remains alive while either the desktop host process or any Air APP process is alive. +- Broker mode remains alive while the requester process or any Air APP process is alive, then exits after both are gone. diff --git a/docs/auto_commit_md/20260513_ada0cd4.md b/docs/auto_commit_md/20260513_ada0cd4.md new file mode 100644 index 0000000..b766e4b --- /dev/null +++ b/docs/auto_commit_md/20260513_ada0cd4.md @@ -0,0 +1,390 @@ +# Git 提交分析报告 + +## 📋 提交基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | `ada0cd4a3a627107f2f80d910f3195a05f11a582` | +| **短哈希** | `ada0cd4` | +| **作者** | lincube | +| **提交时间** | 2026-05-13 07:42:42 +0800 | +| **提交分支** | (当前分支) | + +## 📝 提交信息 + +``` +change.重做天气,为回到系统提供自定义功能。 +``` + +## 📊 变更统计 + +| 指标 | 数值 | +|------|------| +| **修改文件总数** | 242 个 | +| **新增代码行数** | +3,988 行 | +| **删除代码行数** | -30 行 | +| **净增行数** | +3,958 行 | + +### 文件类型分布 + +| 文件类型 | 数量 | 说明 | +|---------|------|------| +| **新增文件** | ~200+ | 主要为天气图标资源文件 | +| **新增 .md 文档** | 5 | 设计文档和规范 | +| **新增 .cs 代码文件** | 15+ | 核心天气组件和服务 | +| **修改 .cs 代码文件** | 8 | 现有代码调整 | +| **修改 .json 本地化文件** | 4 | 多语言支持 | +| **新增 .axaml UI 文件** | 10+ | 天气组件界面 | +| **二进制资源文件** | ~190+ | 各类天气图标 PNG | + +## 🔍 详细变更分析 + +### 1. 设计文档和规范 (新增) + +#### 新增文档文件 + +- `.trae/documents/weather-widget-material-redesign.md` (+559 行) + - 天气组件 Material Design 重新设计规范 + - 包含视觉设计指南和实现细节 + +- `.trae/documents/weather-widget-visual-redesign.md` (+342 行) + - 天气组件视觉重新设计文档 + - 涵盖图标风格和主题系统 + +- `.trae/tasks/dock-back-to-windows-button-display/spec.md` (+29 行) + - "回到系统"按钮显示功能规范 + - 为桌面组件提供自定义返回系统功能 + +- `.trae/tasks/weather-widget-restyle/checklist.md` (+13 行) + - 天气组件样式重构任务清单 + +#### Desktop Component Render Mode Tests + +- `LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs` (+44 行) + - 新增桌面组件渲染模式测试 + - 涵盖 Live、Design、Preview 等模式 + +### 2. 天气图标资源包 (大量新增) + +#### breezy 风格图标集 (约 70 个文件) +包含完整的天气状态图标,包括: +- 晴天 (clear_day/night) +- 多云 (cloudy/partly_cloudy) +- 雨天 (rain/thunderstorm) +- 雪天 (snow/sleet) +- 雾天 (fog/haze) +- 大风 (wind) +- 冰雹 (hail) +- 每种状态提供多种变体和尺寸 (mini_dark/grey/light) + +#### geometric 风格图标集 (约 14 个文件) +几何风格的天气图标 + +#### google-weather-v4 风格图标集 (约 65 个文件) +Google 天气风格第四版图标 + +#### lemon-flutter 风格图标集 (约 18 个文件) +Lemon Flutter 应用风格图标 + +#### 资源元数据 +- `NOTICE.md` - 资源版权声明 +- `SOURCE.md` - 资源来源说明 + +### 3. 核心服务层变更 + +#### WeatherIconAssetResolver.cs (新增 +235 行) + +**功能职责**: +- 天气图标资源解析和加载 +- 支持多种图标风格切换 +- 运行时图标资源动态加载 + +**关键方法**: +- `LoadIcon()` - 根据样式和天气条件加载图标 +- `ResolveIconKey()` - 解析图标键值 +- 支持动态图标包 ID 规范化 + +#### WeatherVisualStyleCatalog.cs (新增 +77 行) + +**功能职责**: +- 天气视觉样式目录管理 +- 样式定义和配置 +- 默认样式和可用样式列表 + +**关键类**: +- `WeatherVisualStyleCatalog` - 样式目录 +- `WeatherVisualStyle` - 样式定义 +- `WeatherVisualStyleId` - 样式 ID 常量 + +#### SettingsDomainServices.cs (修改 -3 行) + +- 集成新的天气图标包设置 + +#### WeatherLocationRefreshService.cs (修改 -3 行) + +- 优化位置刷新逻辑 + +### 4. ViewModel 层变更 + +#### WeatherSettingsPageViewModel.cs (大规模修改 +280 行/-60 行) + +**新增功能**: +- 天气视觉样式选择器 +- 图标包切换功能 +- 实时预览图标更新 + +**关键变更**: +```csharp +// 新增视觉样式相关属性和方法 +VisualStyleHeader/Description +SelectedVisualStyle +VisualStyles 列表 +CreateVisualStyles() 方法 +UpdatePreviewIcon() 方法 +``` + +**设置持久化**: +- 将 `IconPackId` 从硬编码 `"DefaultWeather"` 改为用户可选择的 `SelectedVisualStyle?.Value` +- 支持设置导入/导出的图标包配置 + +### 5. UI 组件层 (大量新增) + +#### WeatherWidgetBase.cs (核心基类 +423 行) + +**功能职责**: +- 所有天气组件的抽象基类 +- 统一的数据流和状态管理 +- 响应式布局支持 + +**核心特性**: +- **状态管理**:`Loading`、`Ready`、`MissingLocation`、`Error`、`Preview` +- **生命周期**:自动订阅设置变更、刷新定时器管理 +- **响应式设计**:支持单元格大小自适应 (`ApplyCellSize`) +- **多模式支持**: + - `DesktopComponentRenderMode.Live` - 实时数据 + - `DesktopComponentRenderMode.Design` - 设计预览 + - `DesktopComponentRenderMode.Preview` - 静态预览 + +**接口实现**: +- `IDesktopComponentWidget` +- `IDesktopPageVisibilityAwareComponentWidget` +- `IWeatherInfoAwareComponentWidget` +- `IComponentRuntimeContextAware` +- `IComponentPlacementContextAware` +- `IComponentChromeContextAware` + +#### MaterialWeatherSceneControl.cs (场景控制 +382 行) + +**功能职责**: +- Material Design 天气场景渲染控制 +- 动态主题应用 +- 动画状态管理 + +#### MaterialWeatherVisualTheme.cs (视觉主题 +248 行) + +**功能职责**: +- 天气主题系统 +- 调色板管理 +- 条件解析 + +**关键枚举**: +- `MaterialWeatherCondition` - 天气状况 +- `MaterialWeatherPalette` - 颜色调色板 + +#### WeatherWidget.axaml + WeatherWidget.axaml.cs (主天气组件 +48/+26 行) + +继承自 `WeatherWidgetBase` 的主天气组件实现 + +#### ExtendedWeatherWidget.axaml + .cs (扩展天气组件 +30/+118 行) + +扩展功能天气组件 + +#### HourlyWeatherWidget.axaml + .cs (小时天气组件 +27/+81 行) + +逐小时天气预报组件 + +#### MultiDayWeatherWidget.axaml + .cs (多日天气组件 +24/+88 行) + +多日天气预报组件 + +#### WeatherClockWidget.axaml + .cs (天气时钟组件 +24/+88 行) + +集成时钟的天气组件 + +#### WeatherIconView.cs (图标视图 +30 行) + +通用天气图标显示组件 + +### 6. 设置页面 + +#### GeneralSettingsPage.axaml (扩展 +115 行) + +新增通用设置页面内容 + +#### WeatherSettingsPage.axaml (扩展 +19 行) + +新增天气设置页面内容 + +### 7. 主窗口和系统集成 + +#### MainWindow.axaml (扩展 +13 行) + +- 新增天气组件引用 + +#### MainWindow.axaml.cs (大规模修改 +175 行) + +- 天气组件初始化和配置 +- 组件注册和管理 + +#### MainWindow.ComponentSystem.cs (扩展 +5 行) + +- 组件系统集成 + +#### MainWindow.SettingsHardCut.Stubs.cs (修改 -36 行) + +- 设置硬切存根调整 + +#### DesktopComponentRuntimeRegistry.cs (扩展 +20 行) + +- 桌面组件运行时注册 + +### 8. 本地化更新 + +#### 多语言文件更新 + +| 语言 | 变更 | +|------|------| +| **zh-CN.json** | -22 行 | +| **en-US.json** | -24 行 | +| **ja-JP.json** | -22 行 | +| **ko-KR.json** | -24 行 | + +主要涉及天气相关的字符串调整 + +### 9. 模型层变更 + +#### AppSettingsSnapshot.cs (修改 +10 行) + +- 新增 `IconPackId` 属性 +- 支持天气图标包配置持久化 + +### 10. 开发工具 + +#### mocks/weather-widget-mock.html (新增 +209 行) + +- 天气组件 HTML 模拟/原型 +- 用于开发和测试预览 + +## ⚠️ 代码审查要点 + +### ✅ 优点和亮点 + +1. **模块化设计优秀** + - `WeatherWidgetBase` 作为抽象基类,提供统一的架构 + - 清晰的职责分离:Resolver、Catalog、SceneControl、VisualTheme 各司其职 + +2. **多风格图标系统** + - 支持 breezy、geometric、google-weather-v4、lemon-flutter 等多种图标风格 + - 完整的图标变体支持 (day/night、mini variants) + +3. **响应式设计** + - 支持单元格大小自适应 + - 多种渲染模式支持 (Live/Design/Preview) + +4. **设置系统完善** + - 用户可选择天气视觉样式 + - 设置持久化和导入/导出支持 + +5. **测试覆盖** + - 新增 `DesktopComponentRenderModeTests.cs` + +### 🔍 需要关注的问题 + +1. **提交粒度过大** + - 242 个文件、4000+ 行代码的单次提交 + - 建议拆分为多个更小、更聚焦的提交: + - 文档提交 + - 图标资源提交 + - 核心服务提交 + - UI 组件提交 + - 本地化提交 + +2. **二进制资源管理** + - 190+ 个 PNG 图标文件 + - 建议考虑使用 Git LFS 优化仓库大小 + +3. **潜在的依赖问题** + - 新增大量组件需要确保构建系统正确处理 + - 建议运行完整构建验证 + +4. **测试覆盖** + - 仅新增渲染模式测试 + - 建议补充天气服务、图标解析、设置持久化的单元测试 + +5. **文档一致性** + - 新增的 `spec.md` 需要确保与实现代码同步更新 + +### 💡 建议改进 + +1. **提交信息优化** + - 当前:`change.重做天气,为回到系统提供自定义功能。` + - 建议:`feat(weather): 重做天气组件,支持多视觉风格和自定义图标包` + - 包含更多技术细节和影响范围 + +2. **CHANGELOG 更新** + - 如此大的功能变更应记录在 CHANGELOG 中 + +3. **性能考虑** + - 大量图标资源需要懒加载 + - 确保运行时内存使用可控 + +4. **可访问性** + - 检查天气图标是否有适当的替代文本描述 + - 确保高对比度模式下的可读性 + +## 📈 影响范围评估 + +### 功能模块影响 + +| 模块 | 影响程度 | 说明 | +|------|---------|------| +| **天气组件系统** | 🔴 高 | 核心重做,影响所有天气功能 | +| **设置系统** | 🟡 中 | 新增样式选择,需兼容性考虑 | +| **主题系统** | 🟡 中 | 新的 Material 主题集成 | +| **国际化** | 🟢 低 | 多语言字符串更新 | + +### 向后兼容性 + +- ⚠️ **设置格式变更**:`AppSettingsSnapshot` 新增 `IconPackId` 字段 +- ⚠️ **组件注册变更**:新增组件类型需要注册 +- ✅ **API 兼容性**:新增类和方法,不修改现有公共 API + +## 🎯 后续建议 + +1. **立即执行** + - 运行完整构建验证 + - 运行相关单元测试 + - 更新 CHANGELOG + +2. **短期计划** + - 添加天气图标解析和设置的单元测试 + - 更新相关文档 + - 考虑启用 Git LFS 管理图标资源 + +3. **长期考虑** + - 建立图标资源自动化压缩流程 + - 建立设计系统文档站点 + +## 📄 附件 + +- 提交-diff 详情:需使用 `git show ada0cd4a3a627107f2f80d910f3195a05f11a582` 查看 +- 设计文档: + - `.trae/documents/weather-widget-material-redesign.md` + - `.trae/documents/weather-widget-visual-redesign.md` + - `.trae/tasks/dock-back-to-windows-button-display/spec.md` + +--- + +**报告生成时间**:2026-05-13 +**分析工具**:Git + 自定义分析脚本 +**建议审查者**:技术负责人、UI/UX 负责人