mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.airapp与融合桌面
This commit is contained in:
7
.trae/specs/air-app-whiteboard/checklist.md
Normal file
7
.trae/specs/air-app-whiteboard/checklist.md
Normal file
@@ -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.
|
||||
26
.trae/specs/air-app-whiteboard/spec.md
Normal file
26
.trae/specs/air-app-whiteboard/spec.md
Normal file
@@ -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.
|
||||
8
.trae/specs/air-app-whiteboard/tasks.md
Normal file
8
.trae/specs/air-app-whiteboard/tasks.md
Normal file
@@ -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.
|
||||
8
.trae/specs/air-app-window-chrome/checklist.md
Normal file
8
.trae/specs/air-app-window-chrome/checklist.md
Normal file
@@ -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.
|
||||
22
.trae/specs/air-app-window-chrome/spec.md
Normal file
22
.trae/specs/air-app-window-chrome/spec.md
Normal file
@@ -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.
|
||||
7
.trae/specs/air-app-window-chrome/tasks.md
Normal file
7
.trae/specs/air-app-window-chrome/tasks.md
Normal file
@@ -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.
|
||||
@@ -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 全部通过
|
||||
73
.trae/specs/fused-desktop-category-icon-unification/spec.md
Normal file
73
.trae/specs/fused-desktop-category-icon-unification/spec.md
Normal file
@@ -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<Icon>(iconKey, ignoreCase: true, out var icon)` 解析
|
||||
- 解析失败时回退到 `Icon.Apps`
|
||||
- 移除所有三处硬编码映射方法
|
||||
|
||||
### Requirement: ComponentLibraryCategoryViewModel.Icon 类型
|
||||
|
||||
原类型为 `Symbol`,修改为 `Icon`,与 `fi:FluentIcon` 控件的 `Icon` 依赖属性类型一致。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
无移除的需求。
|
||||
38
.trae/specs/fused-desktop-category-icon-unification/tasks.md
Normal file
38
.trae/specs/fused-desktop-category-icon-unification/tasks.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Task 1: 创建共享分类图标映射工具
|
||||
- [x] SubTask 1.1: 在 `LanMountainDesktop.ComponentSystem` 命名空间下创建 `ComponentCategoryIconResolver` 静态类
|
||||
- [x] SubTask 1.2: 实现 `ResolveCategoryIcon(string categoryId, IEnumerable<DesktopComponentDefinition> 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 依赖于所有前置任务
|
||||
10
.trae/specs/launcher-managed-air-app-lifecycle/checklist.md
Normal file
10
.trae/specs/launcher-managed-air-app-lifecycle/checklist.md
Normal file
@@ -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.
|
||||
22
.trae/specs/launcher-managed-air-app-lifecycle/spec.md
Normal file
22
.trae/specs/launcher-managed-air-app-lifecycle/spec.md
Normal file
@@ -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 <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.
|
||||
11
.trae/specs/launcher-managed-air-app-lifecycle/tasks.md
Normal file
11
.trae/specs/launcher-managed-air-app-lifecycle/tasks.md
Normal file
@@ -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 <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.
|
||||
7
.trae/specs/window-layer-isolation/checklist.md
Normal file
7
.trae/specs/window-layer-isolation/checklist.md
Normal file
@@ -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.
|
||||
18
.trae/specs/window-layer-isolation/spec.md
Normal file
18
.trae/specs/window-layer-isolation/spec.md
Normal file
@@ -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.
|
||||
7
.trae/specs/window-layer-isolation/tasks.md
Normal file
7
.trae/specs/window-layer-isolation/tasks.md
Normal file
@@ -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.
|
||||
32
LanMountainDesktop.AirAppHost/AirApp.axaml
Normal file
32
LanMountainDesktop.AirAppHost/AirApp.axaml
Normal file
@@ -0,0 +1,32 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.AirAppHost.AirApp"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
|
||||
<Application.Resources>
|
||||
<FontFamily x:Key="AppFontFamily">MiSans VF, avares://LanMountainDesktop.AirAppHost/Assets/Fonts#MiSans</FontFamily>
|
||||
<Color x:Key="AirAppWindowBackgroundColor">#FFF7F9FC</Color>
|
||||
<Color x:Key="AirAppWindowBorderColor">#22000000</Color>
|
||||
<Color x:Key="AirAppTitleTextColor">#FF171A20</Color>
|
||||
<Color x:Key="AirAppSecondaryTextColor">#FF657080</Color>
|
||||
<Color x:Key="AirAppAccentColor">#FF2D73E5</Color>
|
||||
<SolidColorBrush x:Key="AirAppWindowBackgroundBrush" Color="{StaticResource AirAppWindowBackgroundColor}" />
|
||||
<SolidColorBrush x:Key="AirAppWindowBorderBrush" Color="{StaticResource AirAppWindowBorderColor}" />
|
||||
<SolidColorBrush x:Key="AirAppTitleTextBrush" Color="{StaticResource AirAppTitleTextColor}" />
|
||||
<SolidColorBrush x:Key="AirAppSecondaryTextBrush" Color="{StaticResource AirAppSecondaryTextColor}" />
|
||||
<SolidColorBrush x:Key="AirAppAccentBrush" Color="{StaticResource AirAppAccentColor}" />
|
||||
<SolidColorBrush x:Key="AdaptiveSurfaceRaisedBrush" Color="#FFF1F4F9" />
|
||||
<SolidColorBrush x:Key="AdaptiveButtonBorderBrush" Color="#16000000" />
|
||||
<SolidColorBrush x:Key="AdaptiveSurfaceBaseBrush" Color="#FFFFFFFF" />
|
||||
<SolidColorBrush x:Key="SystemControlForegroundBaseMediumLowBrush" Color="#55000000" />
|
||||
<SolidColorBrush x:Key="AdaptiveAccentBrush" Color="#FF2D73E5" />
|
||||
<SolidColorBrush x:Key="AdaptiveOnAccentBrush" Color="#FFFFFFFF" />
|
||||
<SolidColorBrush x:Key="AdaptiveTextPrimaryBrush" Color="#FF0F172A" />
|
||||
<CornerRadius x:Key="DesignCornerRadiusComponent">18</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusSm">10</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXs">8</CornerRadius>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
24
LanMountainDesktop.AirAppHost/AirApp.axaml.cs
Normal file
24
LanMountainDesktop.AirAppHost/AirApp.axaml.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
64
LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
Normal file
64
LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed record AirAppLaunchOptions(
|
||||
string AppId,
|
||||
string SessionId,
|
||||
string? SourceComponentId,
|
||||
string? SourcePlacementId,
|
||||
string? LauncherPipeName,
|
||||
string? InstanceKey)
|
||||
{
|
||||
public const string WorldClockAppId = "world-clock";
|
||||
public const string WhiteboardAppId = "whiteboard";
|
||||
|
||||
public static AirAppLaunchOptions Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < args.Count; index++)
|
||||
{
|
||||
var arg = args[index];
|
||||
if (!arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = arg[2..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[index + 1];
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
values[key] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
return new AirAppLaunchOptions(
|
||||
GetValue(values, "app-id", WorldClockAppId),
|
||||
GetValue(values, "session-id", Guid.NewGuid().ToString("N")),
|
||||
GetOptionalValue(values, "source-component-id"),
|
||||
GetOptionalValue(values, "source-placement-id"),
|
||||
GetOptionalValue(values, "launcher-pipe"),
|
||||
GetOptionalValue(values, "instance-key"));
|
||||
}
|
||||
|
||||
private static string GetValue(IReadOnlyDictionary<string, string> values, string key, string fallback)
|
||||
{
|
||||
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value.Trim()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static string? GetOptionalValue(IReadOnlyDictionary<string, string> values, string key)
|
||||
{
|
||||
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value.Trim()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
64
LanMountainDesktop.AirAppHost/AirAppWindow.axaml
Normal file
64
LanMountainDesktop.AirAppHost/AirAppWindow.axaml
Normal file
@@ -0,0 +1,64 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.AirAppHost.AirAppWindow"
|
||||
Width="520"
|
||||
Height="360"
|
||||
MinWidth="360"
|
||||
MinHeight="260"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
TransparencyLevelHint="Transparent"
|
||||
Background="Transparent"
|
||||
FontFamily="{DynamicResource AppFontFamily}"
|
||||
Title="Air APP">
|
||||
<Border x:Name="WindowShell"
|
||||
Background="{DynamicResource AirAppWindowBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AirAppWindowBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="18"
|
||||
ClipToBounds="True"
|
||||
BoxShadow="0 18 44 #22000000">
|
||||
<Grid RowDefinitions="52,*">
|
||||
<Grid x:Name="TitleBar"
|
||||
ColumnDefinitions="*,Auto"
|
||||
Background="Transparent"
|
||||
PointerPressed="OnTitleBarPointerPressed">
|
||||
<StackPanel Margin="18,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Air APP"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AirAppTitleTextBrush}" />
|
||||
<TextBlock x:Name="SubtitleTextBlock"
|
||||
Text="LanMountainDesktop"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource AirAppSecondaryTextBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Width="36"
|
||||
Height="36"
|
||||
Margin="0,8,10,8"
|
||||
Padding="0"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Click="OnCloseClick">
|
||||
<TextBlock Text="X"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AirAppTitleTextBrush}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<ContentControl x:Name="ContentHost"
|
||||
Grid.Row="1" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
228
LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
Normal file
228
LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed partial class AirAppWindow : Window
|
||||
{
|
||||
private readonly AirAppLaunchOptions _options;
|
||||
private readonly AirAppWindowDescriptor _descriptor;
|
||||
private string _instanceKey = string.Empty;
|
||||
|
||||
public AirAppWindow()
|
||||
: this(AirAppLaunchOptions.Parse([]))
|
||||
{
|
||||
}
|
||||
|
||||
public AirAppWindow(AirAppLaunchOptions options)
|
||||
{
|
||||
_options = options;
|
||||
_descriptor = AirAppWindowDescriptor.Create(options);
|
||||
InitializeComponent();
|
||||
ConfigureWindow();
|
||||
}
|
||||
|
||||
private void ConfigureWindow()
|
||||
{
|
||||
ApplyWindowDescriptor(_descriptor);
|
||||
|
||||
if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ContentHost.Content = new WorldClockAirAppView(_options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(_options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ConfigureWhiteboardWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
ContentHost.Content = new TextBlock
|
||||
{
|
||||
Text = $"Unsupported Air APP: {_options.AppId}",
|
||||
Margin = new Avalonia.Thickness(18)
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyWindowDescriptor(AirAppWindowDescriptor descriptor)
|
||||
{
|
||||
Title = descriptor.Title;
|
||||
TitleTextBlock.Text = descriptor.TitleText;
|
||||
SubtitleTextBlock.Text = descriptor.SubtitleText;
|
||||
Width = descriptor.Width;
|
||||
Height = descriptor.Height;
|
||||
MinWidth = descriptor.MinWidth;
|
||||
MinHeight = descriptor.MinHeight;
|
||||
ShowInTaskbar = descriptor.ShowInTaskbar;
|
||||
CanResize = descriptor.CanResize;
|
||||
WindowDecorations = WindowDecorations.None;
|
||||
ExtendClientAreaToDecorationsHint = true;
|
||||
ExtendClientAreaTitleBarHeightHint = -1;
|
||||
|
||||
TitleBar.IsVisible = true;
|
||||
Grid.SetRow(ContentHost, 1);
|
||||
Grid.SetRowSpan(ContentHost, 1);
|
||||
WindowState = WindowState.Normal;
|
||||
|
||||
switch (descriptor.ChromeMode)
|
||||
{
|
||||
case AirAppWindowChromeMode.Standard:
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.Borderless:
|
||||
HideCustomTitleBar();
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.FullScreen:
|
||||
HideCustomTitleBar();
|
||||
WindowShell.CornerRadius = new Avalonia.CornerRadius(0);
|
||||
WindowShell.BorderThickness = new Avalonia.Thickness(0);
|
||||
WindowShell.BoxShadow = default;
|
||||
WindowState = WindowState.FullScreen;
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.Tool:
|
||||
ShowInTaskbar = false;
|
||||
CanResize = false;
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.BackgroundOnly:
|
||||
// Reserved for future background-only Air APPs. Keep a normal window for now
|
||||
// so accidental launches remain visible and debuggable.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HideCustomTitleBar()
|
||||
{
|
||||
TitleBar.IsVisible = false;
|
||||
Grid.SetRow(ContentHost, 0);
|
||||
Grid.SetRowSpan(ContentHost, 2);
|
||||
}
|
||||
|
||||
private void ConfigureWhiteboardWindow()
|
||||
{
|
||||
var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId)
|
||||
? BuiltInComponentIds.DesktopWhiteboard
|
||||
: _options.SourceComponentId.Trim();
|
||||
var baseWidthCells = string.Equals(componentId, BuiltInComponentIds.DesktopBlackboardLandscape, StringComparison.OrdinalIgnoreCase)
|
||||
? 4
|
||||
: 2;
|
||||
var widget = new WhiteboardWidget(baseWidthCells);
|
||||
widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId);
|
||||
widget.SetSurfaceMode(
|
||||
WhiteboardWidgetSurfaceMode.AirApp,
|
||||
() =>
|
||||
{
|
||||
widget.ForceSaveNote();
|
||||
Close();
|
||||
});
|
||||
|
||||
ContentHost.Content = widget;
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
_ = RegisterWithLauncherAsync();
|
||||
AppLogger.Info(
|
||||
"AirAppWindow",
|
||||
$"Opened. WindowRole=AirApp; AppId='{_options.AppId}'; ForegroundActivationRequested=True.");
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Activate();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
_ = UnregisterWithLauncherAsync();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
BeginMoveDrag(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
private async Task RegisterWithLauncherAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.LauncherPipeName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_instanceKey = ResolveInstanceKey();
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<IAirAppLifecycleService>();
|
||||
_ = await proxy.RegisterAsync(new AirAppRegistrationRequest(
|
||||
_instanceKey,
|
||||
_options.AppId,
|
||||
_options.SessionId,
|
||||
Environment.ProcessId,
|
||||
Title ?? "Air APP",
|
||||
_options.SourceComponentId,
|
||||
_options.SourcePlacementId)).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Registration is best-effort; Launcher also tracks the process it started.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UnregisterWithLauncherAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.LauncherPipeName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var instanceKey = string.IsNullOrWhiteSpace(_instanceKey) ? ResolveInstanceKey() : _instanceKey;
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<IAirAppLifecycleService>();
|
||||
_ = await proxy.UnregisterAsync(instanceKey, Environment.ProcessId).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Unregister is best-effort; Launcher prunes dead processes.
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveInstanceKey()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.InstanceKey))
|
||||
{
|
||||
return _options.InstanceKey.Trim();
|
||||
}
|
||||
|
||||
var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId)
|
||||
? "none"
|
||||
: _options.SourceComponentId.Trim();
|
||||
var placementId = string.IsNullOrWhiteSpace(_options.SourcePlacementId)
|
||||
? "none"
|
||||
: _options.SourcePlacementId.Trim();
|
||||
return $"{_options.AppId}:{componentId}:{placementId}";
|
||||
}
|
||||
}
|
||||
10
LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs
Normal file
10
LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public enum AirAppWindowChromeMode
|
||||
{
|
||||
Standard,
|
||||
Borderless,
|
||||
FullScreen,
|
||||
Tool,
|
||||
BackgroundOnly
|
||||
}
|
||||
137
LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
Normal file
137
LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="..\LanMountainDesktop\Assets\Fonts\**" Link="Assets\Fonts\%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj"
|
||||
AdditionalProperties="SkipAirAppHostBuild=true" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="FluentAvaloniaUI" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
21
LanMountainDesktop.AirAppHost/Program.cs
Normal file
21
LanMountainDesktop.AirAppHost/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<AirApp>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
39
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
Normal file
39
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
Normal file
@@ -0,0 +1,39 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.AirAppHost.WorldClockAirAppView">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
Margin="24,8,24,24">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="10">
|
||||
<TextBlock x:Name="TimeTextBlock"
|
||||
Text="00:00:00"
|
||||
FontSize="58"
|
||||
FontWeight="SemiBold"
|
||||
LetterSpacing="0"
|
||||
Foreground="{DynamicResource AirAppTitleTextBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Text="0000-00-00"
|
||||
FontSize="17"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource AirAppSecondaryTextBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="TimeZoneTextBlock"
|
||||
Text="Local Time"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AirAppSecondaryTextBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
Padding="12,7"
|
||||
CornerRadius="999"
|
||||
Background="#112D73E5">
|
||||
<TextBlock x:Name="SessionTextBlock"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource AirAppAccentBrush}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
52
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs
Normal file
52
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Launcher.Services.AirApp;
|
||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
@@ -61,6 +62,13 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.IsAirAppBrokerCommand)
|
||||
{
|
||||
_ = RunAirAppBrokerAsync(desktop, context);
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// 调试模式:只显示 DevDebugWindow,不走正常启动流程
|
||||
// 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI
|
||||
if (context.IsDebugMode && !context.IsPreviewCommand &&
|
||||
@@ -90,6 +98,45 @@ public partial class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static async Task RunAirAppBrokerAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context)
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var requesterPid = context.GetIntOption("requester-pid", 0);
|
||||
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null)));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
|
||||
Logger.Info("Air APP broker exiting.");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
internal static async Task WaitForAirAppBrokerExitAsync(
|
||||
int requesterPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
{
|
||||
while (ShouldKeepAirAppBrokerAlive(requesterPid, airAppLifecycleService))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool ShouldKeepAirAppBrokerAlive(
|
||||
int requesterPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
{
|
||||
return TryGetLiveProcess(requesterPid) || airAppLifecycleService.HasLiveAirApps();
|
||||
}
|
||||
|
||||
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
switch (context.Command.ToLowerInvariant())
|
||||
@@ -236,7 +283,6 @@ public partial class App : Application
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
|
||||
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
@@ -257,6 +303,14 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null)));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
@@ -334,9 +388,45 @@ public partial class App : Application
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
|
||||
{
|
||||
if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var hostPid))
|
||||
{
|
||||
return hostPid;
|
||||
}
|
||||
|
||||
if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
|
||||
int.TryParse(existingHostPidText, out var existingHostPid))
|
||||
{
|
||||
return existingHostPid;
|
||||
}
|
||||
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForManagedProcessesToExitAsync(
|
||||
int hostPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
{
|
||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
CommandContext context,
|
||||
SplashWindow? splashWindow,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
||||
|
||||
internal sealed class AirAppHostLocator
|
||||
{
|
||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
||||
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
||||
|
||||
public string Resolve(string? packageRoot, string? hostPath = null)
|
||||
{
|
||||
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Unable to find LanMountainDesktop.AirAppHost output.");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateCandidates(string? packageRoot, string? hostPath)
|
||||
{
|
||||
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
||||
{
|
||||
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(root, "AirAppHost", DllName);
|
||||
yield return Path.Combine(root, WindowsExecutableName);
|
||||
yield return Path.Combine(root, DllName);
|
||||
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent)
|
||||
{
|
||||
yield return Path.Combine(
|
||||
current.FullName,
|
||||
"LanMountainDesktop.AirAppHost",
|
||||
"bin",
|
||||
#if DEBUG
|
||||
"Debug",
|
||||
#else
|
||||
"Release",
|
||||
#endif
|
||||
"net10.0",
|
||||
WindowsExecutableName);
|
||||
|
||||
yield return Path.Combine(
|
||||
current.FullName,
|
||||
"LanMountainDesktop.AirAppHost",
|
||||
"bin",
|
||||
#if DEBUG
|
||||
"Debug",
|
||||
#else
|
||||
"Release",
|
||||
#endif
|
||||
"net10.0",
|
||||
DllName);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateRoots(string? packageRoot, string? hostPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(packageRoot))
|
||||
{
|
||||
yield return Path.GetFullPath(packageRoot);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hostPath))
|
||||
{
|
||||
var hostDirectory = Path.GetDirectoryName(Path.GetFullPath(hostPath));
|
||||
if (!string.IsNullOrWhiteSpace(hostDirectory))
|
||||
{
|
||||
yield return hostDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
yield return AppContext.BaseDirectory;
|
||||
yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
||||
|
||||
internal interface IAirAppProcessStarter
|
||||
{
|
||||
Process? Start(string appId, string sessionId, string instanceKey, string? sourceComponentId, string? sourcePlacementId);
|
||||
}
|
||||
|
||||
internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
{
|
||||
private readonly AirAppHostLocator _locator;
|
||||
private readonly Func<string?> _packageRootProvider;
|
||||
private readonly Func<string?> _hostPathProvider;
|
||||
|
||||
public AirAppProcessStarter(
|
||||
AirAppHostLocator locator,
|
||||
Func<string?> packageRootProvider,
|
||||
Func<string?> hostPathProvider)
|
||||
{
|
||||
_locator = locator;
|
||||
_packageRootProvider = packageRootProvider;
|
||||
_hostPathProvider = hostPathProvider;
|
||||
}
|
||||
|
||||
public Process? Start(
|
||||
string appId,
|
||||
string sessionId,
|
||||
string instanceKey,
|
||||
string? sourceComponentId,
|
||||
string? sourcePlacementId)
|
||||
{
|
||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows() &&
|
||||
string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
startInfo.FileName = hostPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = "dotnet";
|
||||
startInfo.ArgumentList.Add(hostPath);
|
||||
}
|
||||
|
||||
AddArgument(startInfo, "--app-id", appId);
|
||||
AddArgument(startInfo, "--session-id", sessionId);
|
||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceComponentId))
|
||||
{
|
||||
AddArgument(startInfo, "--source-component-id", sourceComponentId.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourcePlacementId))
|
||||
{
|
||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||
}
|
||||
|
||||
return Process.Start(startInfo);
|
||||
}
|
||||
|
||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||
{
|
||||
startInfo.ArgumentList.Add(name);
|
||||
startInfo.ArgumentList.Add(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
||||
|
||||
internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
|
||||
{
|
||||
private readonly PublicIpcHostService _host;
|
||||
|
||||
public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService)
|
||||
{
|
||||
LifecycleService = lifecycleService;
|
||||
_host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName);
|
||||
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
||||
}
|
||||
|
||||
public LauncherAirAppLifecycleService LifecycleService { get; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_host.Start();
|
||||
Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
||||
|
||||
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IAirAppProcessStarter _processStarter;
|
||||
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||
{
|
||||
_processStarter = processStarter;
|
||||
}
|
||||
|
||||
public Task<AirAppOperationResult> OpenAsync(AirAppOpenRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var appId = Normalize(request.AppId, "unknown");
|
||||
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
||||
Logger.Info(
|
||||
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
CleanupExitedInstances();
|
||||
|
||||
if (_instances.TryGetValue(instanceKey, out var existing) && IsProcessAlive(existing.ProcessId))
|
||||
{
|
||||
TryActivateProcess(existing.ProcessId);
|
||||
existing.Touch();
|
||||
return Task.FromResult(BuildResult(true, "activated_existing", "Activated existing Air APP instance.", existing));
|
||||
}
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
var process = _processStarter.Start(
|
||||
appId,
|
||||
sessionId,
|
||||
instanceKey,
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
if (process is null)
|
||||
{
|
||||
return Task.FromResult(BuildResult(false, "start_failed", "AirAppHost process was not created.", null));
|
||||
}
|
||||
|
||||
var instance = new ManagedAirAppInstance(
|
||||
instanceKey,
|
||||
appId,
|
||||
sessionId,
|
||||
process.Id,
|
||||
$"{appId} - Air APP",
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AirAppOperationResult> ActivateAsync(string instanceKey)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
CleanupExitedInstances();
|
||||
if (!_instances.TryGetValue(instanceKey, out var instance))
|
||||
{
|
||||
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
|
||||
}
|
||||
|
||||
var accepted = TryActivateProcess(instance.ProcessId);
|
||||
instance.Touch();
|
||||
return Task.FromResult(BuildResult(
|
||||
accepted,
|
||||
accepted ? "activated" : "activation_failed",
|
||||
accepted ? "Air APP instance activated." : "Failed to activate Air APP instance.",
|
||||
instance));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AirAppOperationResult> CloseAsync(string instanceKey)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
CleanupExitedInstances();
|
||||
if (!_instances.TryGetValue(instanceKey, out var instance))
|
||||
{
|
||||
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
|
||||
}
|
||||
|
||||
var accepted = TryCloseProcess(instance.ProcessId);
|
||||
instance.Touch();
|
||||
return Task.FromResult(BuildResult(
|
||||
accepted,
|
||||
accepted ? "close_requested" : "close_failed",
|
||||
accepted ? "Air APP close requested." : "Failed to request Air APP close.",
|
||||
instance));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AirAppInstanceInfo[]> GetInstancesAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
CleanupExitedInstances();
|
||||
return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AirAppOperationResult> RegisterAsync(AirAppRegistrationRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
lock (_gate)
|
||||
{
|
||||
var instanceKey = string.IsNullOrWhiteSpace(request.InstanceKey)
|
||||
? AirAppInstanceKey.Build(request.AppId, request.SourceComponentId, request.SourcePlacementId)
|
||||
: request.InstanceKey.Trim();
|
||||
var instance = new ManagedAirAppInstance(
|
||||
instanceKey,
|
||||
Normalize(request.AppId, "unknown"),
|
||||
Normalize(request.SessionId, Guid.NewGuid().ToString("N")),
|
||||
request.ProcessId,
|
||||
Normalize(request.WindowTitle, $"{request.AppId} - Air APP"),
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AirAppOperationResult> UnregisterAsync(string instanceKey, int processId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_instances.TryGetValue(instanceKey, out var instance) &&
|
||||
(processId <= 0 || instance.ProcessId == processId))
|
||||
{
|
||||
_instances.Remove(instanceKey);
|
||||
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
||||
}
|
||||
|
||||
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasLiveAirApps()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
CleanupExitedInstances();
|
||||
return _instances.Values.Any(static instance => IsProcessAlive(instance.ProcessId));
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupExitedInstances()
|
||||
{
|
||||
var exitedKeys = _instances
|
||||
.Where(static pair => !IsProcessAlive(pair.Value.ProcessId))
|
||||
.Select(static pair => pair.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in exitedKeys)
|
||||
{
|
||||
_instances.Remove(key);
|
||||
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AirAppOperationResult BuildResult(
|
||||
bool accepted,
|
||||
string code,
|
||||
string message,
|
||||
ManagedAirAppInstance? instance)
|
||||
{
|
||||
return new AirAppOperationResult(accepted, code, message, instance?.ToInfo());
|
||||
}
|
||||
|
||||
private static bool TryActivateProcess(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
if (process.HasExited)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
process.Refresh();
|
||||
var handle = process.MainWindowHandle;
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_ = ShowWindow(handle, SW_SHOWNORMAL);
|
||||
_ = SetForegroundWindow(handle);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCloseProcess(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
if (process.HasExited)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return process.CloseMainWindow();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsProcessAlive(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Trim();
|
||||
}
|
||||
|
||||
private const int SW_SHOWNORMAL = 1;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
private sealed class ManagedAirAppInstance
|
||||
{
|
||||
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
public ManagedAirAppInstance(
|
||||
string instanceKey,
|
||||
string appId,
|
||||
string sessionId,
|
||||
int processId,
|
||||
string windowTitle,
|
||||
string? sourceComponentId,
|
||||
string? sourcePlacementId)
|
||||
{
|
||||
InstanceKey = instanceKey;
|
||||
AppId = appId;
|
||||
SessionId = sessionId;
|
||||
ProcessId = processId;
|
||||
WindowTitle = windowTitle;
|
||||
SourceComponentId = sourceComponentId;
|
||||
SourcePlacementId = sourcePlacementId;
|
||||
UpdatedAtUtc = _startedAtUtc;
|
||||
}
|
||||
|
||||
public string InstanceKey { get; }
|
||||
|
||||
public string AppId { get; }
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public int ProcessId { get; }
|
||||
|
||||
public string WindowTitle { get; }
|
||||
|
||||
public string? SourceComponentId { get; }
|
||||
|
||||
public string? SourcePlacementId { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; private set; }
|
||||
|
||||
public void Touch()
|
||||
{
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public AirAppInstanceInfo ToInfo()
|
||||
{
|
||||
return new AirAppInstanceInfo(
|
||||
InstanceKey,
|
||||
AppId,
|
||||
SessionId,
|
||||
ProcessId,
|
||||
WindowTitle,
|
||||
SourceComponentId,
|
||||
SourcePlacementId,
|
||||
IsProcessAlive(ProcessId),
|
||||
_startedAtUtc,
|
||||
UpdatedAtUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IAirAppLifecycleService
|
||||
{
|
||||
Task<AirAppOperationResult> OpenAsync(AirAppOpenRequest request);
|
||||
|
||||
Task<AirAppOperationResult> ActivateAsync(string instanceKey);
|
||||
|
||||
Task<AirAppOperationResult> CloseAsync(string instanceKey);
|
||||
|
||||
Task<AirAppInstanceInfo[]> GetInstancesAsync();
|
||||
|
||||
Task<AirAppOperationResult> RegisterAsync(AirAppRegistrationRequest request);
|
||||
|
||||
Task<AirAppOperationResult> UnregisterAsync(string instanceKey, int processId);
|
||||
}
|
||||
|
||||
public sealed record AirAppOpenRequest(
|
||||
string AppId,
|
||||
string? SourceComponentId,
|
||||
string? SourcePlacementId,
|
||||
int RequesterProcessId);
|
||||
|
||||
public sealed record AirAppRegistrationRequest(
|
||||
string InstanceKey,
|
||||
string AppId,
|
||||
string SessionId,
|
||||
int ProcessId,
|
||||
string WindowTitle,
|
||||
string? SourceComponentId,
|
||||
string? SourcePlacementId);
|
||||
|
||||
public sealed record AirAppInstanceInfo(
|
||||
string InstanceKey,
|
||||
string AppId,
|
||||
string SessionId,
|
||||
int ProcessId,
|
||||
string WindowTitle,
|
||||
string? SourceComponentId,
|
||||
string? SourcePlacementId,
|
||||
bool ProcessAlive,
|
||||
DateTimeOffset StartedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record AirAppOperationResult(
|
||||
bool Accepted,
|
||||
string Code,
|
||||
string Message,
|
||||
AirAppInstanceInfo? Instance);
|
||||
@@ -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";
|
||||
|
||||
81
LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs
Normal file
81
LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
110
LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs
Normal file
110
LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
164
LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs
Normal file
164
LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
LanMountainDesktop.Tests/MusicControlViewModelTests.cs
Normal file
42
LanMountainDesktop.Tests/MusicControlViewModelTests.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
88
LanMountainDesktop.Tests/WindowLayerIsolationTests.cs
Normal file
88
LanMountainDesktop.Tests/WindowLayerIsolationTests.cs
Normal file
@@ -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)}'.");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<Project Path="LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj" />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using FluentIcons.Common;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public static class ComponentCategoryIconResolver
|
||||
{
|
||||
public static Icon ResolveCategoryIcon(
|
||||
string categoryId,
|
||||
IEnumerable<DesktopComponentDefinition> categoryComponents)
|
||||
{
|
||||
if (string.Equals(categoryId, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Icon.Apps;
|
||||
}
|
||||
|
||||
var firstComponent = categoryComponents.FirstOrDefault();
|
||||
if (firstComponent is null || string.IsNullOrWhiteSpace(firstComponent.IconKey))
|
||||
{
|
||||
return Icon.Apps;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<Icon>(firstComponent.IconKey, ignoreCase: true, out var icon))
|
||||
{
|
||||
return icon;
|
||||
}
|
||||
|
||||
return Icon.Apps;
|
||||
}
|
||||
}
|
||||
@@ -107,4 +107,32 @@
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
</Target>
|
||||
<Target Name="BuildAirAppHostOutput"
|
||||
AfterTargets="Build"
|
||||
Condition="'$(BuildingAirAppHost)' != 'true' and '$(SkipAirAppHostBuild)' != 'true'">
|
||||
<Exec Command="dotnet build "$(MSBuildProjectDirectory)\..\LanMountainDesktop.AirAppHost\LanMountainDesktop.AirAppHost.csproj" -c "$(Configuration)" --no-restore -p:BuildProjectReferences=false -p:BuildingAirAppHost=true" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyAirAppHostOutput"
|
||||
AfterTargets="Build"
|
||||
DependsOnTargets="BuildAirAppHostOutput"
|
||||
Condition="'$(SkipAirAppHostBuild)' != 'true'">
|
||||
<ItemGroup>
|
||||
<_AirAppHostOutput Include="..\LanMountainDesktop.AirAppHost\bin\$(Configuration)\$(TargetFramework)\**\*" />
|
||||
</ItemGroup>
|
||||
<MakeDir Directories="$(OutDir)AirAppHost" />
|
||||
<Copy SourceFiles="@(_AirAppHostOutput)"
|
||||
DestinationFiles="@(_AirAppHostOutput->'$(OutDir)AirAppHost\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyAirAppHostPublishOutput" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||
<ItemGroup>
|
||||
<_AirAppHostPublishOutput Include="..\LanMountainDesktop.AirAppHost\bin\$(Configuration)\$(TargetFramework)\**\*" />
|
||||
</ItemGroup>
|
||||
<MakeDir Directories="$(PublishDir)AirAppHost" />
|
||||
<Copy SourceFiles="@(_AirAppHostPublishOutput)"
|
||||
DestinationFiles="@(_AirAppHostPublishOutput->'$(PublishDir)AirAppHost\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
160
LanMountainDesktop/Services/AirAppLauncherService.cs
Normal file
160
LanMountainDesktop/Services/AirAppLauncherService.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IAirAppLauncherService
|
||||
{
|
||||
void OpenWorldClock(string? sourcePlacementId);
|
||||
|
||||
void OpenWhiteboard(string componentId, string? sourcePlacementId);
|
||||
}
|
||||
|
||||
internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
{
|
||||
public const string WorldClockAppId = "world-clock";
|
||||
public const string WhiteboardAppId = "whiteboard";
|
||||
|
||||
private const int LauncherIpcRetryCount = 4;
|
||||
|
||||
public void OpenWorldClock(string? sourcePlacementId)
|
||||
{
|
||||
_ = OpenAsync(WorldClockAppId, BuiltInComponentIds.DesktopWorldClock, sourcePlacementId);
|
||||
}
|
||||
|
||||
public void OpenWhiteboard(string componentId, string? sourcePlacementId)
|
||||
{
|
||||
_ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId);
|
||||
}
|
||||
|
||||
internal static AirAppOpenRequest BuildOpenRequest(
|
||||
string appId,
|
||||
string? sourceComponentId,
|
||||
string? sourcePlacementId,
|
||||
int requesterProcessId)
|
||||
{
|
||||
return new AirAppOpenRequest(
|
||||
appId.Trim(),
|
||||
string.IsNullOrWhiteSpace(sourceComponentId) ? null : sourceComponentId.Trim(),
|
||||
string.IsNullOrWhiteSpace(sourcePlacementId) ? null : sourcePlacementId.Trim(),
|
||||
requesterProcessId);
|
||||
}
|
||||
|
||||
internal static string BuildSingleInstanceKey(string appId, string? sourceComponentId, string? sourcePlacementId)
|
||||
{
|
||||
var normalizedAppId = string.IsNullOrWhiteSpace(appId) ? "unknown" : appId.Trim();
|
||||
var normalizedComponentId = string.IsNullOrWhiteSpace(sourceComponentId) ? "none" : sourceComponentId.Trim();
|
||||
var normalizedPlacementId = string.IsNullOrWhiteSpace(sourcePlacementId) ? "none" : sourcePlacementId.Trim();
|
||||
return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}";
|
||||
}
|
||||
|
||||
private static async Task OpenAsync(string appId, string sourceComponentId, string? sourcePlacementId)
|
||||
{
|
||||
var request = BuildOpenRequest(appId, sourceComponentId, sourcePlacementId, Environment.ProcessId);
|
||||
try
|
||||
{
|
||||
var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
|
||||
if (result.Accepted)
|
||||
{
|
||||
AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AirAppOperationResult> SendOpenRequestAsync(AirAppOpenRequest request)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<IAirAppLifecycleService>();
|
||||
return await proxy.OpenAsync(request).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt == 1)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"AirAppLauncher",
|
||||
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.",
|
||||
ex);
|
||||
TryStartLauncher();
|
||||
}
|
||||
|
||||
await Task.Delay(250 * attempt).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.",
|
||||
lastException);
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
UseShellExecute = false
|
||||
};
|
||||
startInfo.ArgumentList.Add("air-app-broker");
|
||||
startInfo.ArgumentList.Add("--requester-pid");
|
||||
startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void TryStartLauncher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId);
|
||||
_ = Process.Start(startInfo);
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class AirAppLauncherServiceProvider
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static IAirAppLauncherService? _instance;
|
||||
|
||||
public static IAirAppLauncherService GetOrCreate()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_instance ??= new AirAppLauncherService();
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class ComponentLibraryCategoryViewModel
|
||||
public ComponentLibraryCategoryViewModel(
|
||||
string id,
|
||||
string title,
|
||||
Symbol icon,
|
||||
Icon icon,
|
||||
IReadOnlyList<ComponentLibraryItemViewModel> components)
|
||||
{
|
||||
Id = id;
|
||||
@@ -46,7 +46,7 @@ public sealed class ComponentLibraryCategoryViewModel
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public Symbol Icon { get; }
|
||||
public Icon Icon { get; }
|
||||
|
||||
public IReadOnlyList<ComponentLibraryItemViewModel> Components { get; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,18 @@
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button x:Name="SurfaceModeButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Full screen"
|
||||
Click="OnSurfaceModeButtonClick">
|
||||
<fi:SymbolIcon x:Name="SurfaceModeIcon"
|
||||
Symbol="ArrowExport"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
"all",
|
||||
L(languageCode, "component_category.all", "All"),
|
||||
Symbol.Apps,
|
||||
Icon.Apps,
|
||||
Array.Empty<ComponentLibraryItemViewModel>()));
|
||||
|
||||
var usedCategories = _allDefinitions
|
||||
@@ -97,28 +97,18 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
.Select(definition => CreateComponentItem(definition, languageCode))
|
||||
.ToArray();
|
||||
|
||||
var categoryDefinitions = _allDefinitions
|
||||
.Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
category,
|
||||
GetLocalizedCategoryTitle(languageCode, category),
|
||||
ResolveCategoryIcon(category),
|
||||
ComponentCategoryIconResolver.ResolveCategoryIcon(category, categoryDefinitions),
|
||||
categoryComponents));
|
||||
}
|
||||
}
|
||||
|
||||
private static Symbol ResolveCategoryIcon(string categoryId)
|
||||
{
|
||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock;
|
||||
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return Symbol.CalendarDate;
|
||||
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny;
|
||||
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit;
|
||||
if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play;
|
||||
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Info;
|
||||
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator;
|
||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass;
|
||||
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder;
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
private string GetLocalizedCategoryTitle(string languageCode, string categoryId)
|
||||
{
|
||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock");
|
||||
|
||||
@@ -58,7 +58,7 @@ public partial class MainWindow : Window
|
||||
|
||||
private sealed record ComponentLibraryCategory(
|
||||
string Id,
|
||||
Symbol Icon,
|
||||
Icon Icon,
|
||||
string Title,
|
||||
IReadOnlyList<ComponentLibraryComponentEntry> Components);
|
||||
|
||||
@@ -2873,7 +2873,13 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!_isComponentLibraryOpen || HasActiveDesktopEditSession)
|
||||
if (!_isComponentLibraryOpen)
|
||||
{
|
||||
TryOpenAirAppFromDesktopComponent(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasActiveDesktopEditSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -2917,6 +2923,29 @@ public partial class MainWindow : Window
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void TryOpenAirAppFromDesktopComponent(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (HasActiveDesktopEditSession ||
|
||||
DesktopPagesViewport is null ||
|
||||
sender is not Border host ||
|
||||
host.Tag is not string placementId ||
|
||||
!e.GetCurrentPoint(host).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var placement = _desktopComponentPlacements.FirstOrDefault(p =>
|
||||
string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||
if (placement is null ||
|
||||
!string.Equals(placement.ComponentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_airAppLauncherService.OpenWorldClock(placement.PlacementId);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SetSelectedDesktopComponent(Border? host)
|
||||
{
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
@@ -3390,9 +3419,9 @@ public partial class MainWindow : Window
|
||||
var row = new RowDefinition(GridLength.Auto);
|
||||
ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(row);
|
||||
|
||||
var icon = new SymbolIcon
|
||||
var icon = new FluentIcon
|
||||
{
|
||||
Symbol = category.Icon,
|
||||
Icon = category.Icon,
|
||||
IconVariant = IconVariant.Regular,
|
||||
FontSize = 18,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
@@ -3461,62 +3490,14 @@ public partial class MainWindow : Window
|
||||
return categories
|
||||
.Select(category => new ComponentLibraryCategory(
|
||||
category.Id,
|
||||
ResolveComponentLibraryCategoryIcon(category.Id),
|
||||
ComponentCategoryIconResolver.ResolveCategoryIcon(
|
||||
category.Id,
|
||||
_componentRegistry.GetAll().Where(d => string.Equals(d.Category, category.Id, StringComparison.OrdinalIgnoreCase))),
|
||||
GetLocalizedComponentLibraryCategoryTitle(category.Id),
|
||||
category.Components))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private Symbol ResolveComponentLibraryCategoryIcon(string categoryId)
|
||||
{
|
||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Clock;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.CalendarDate;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.WeatherSunny;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Edit;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Play;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Calculator;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Hourglass;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Folder;
|
||||
}
|
||||
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
private string GetLocalizedComponentLibraryCategoryTitle(string categoryId)
|
||||
{
|
||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -106,6 +106,7 @@ public partial class MainWindow : Window
|
||||
private readonly IComponentLibraryService _componentLibraryService;
|
||||
private readonly IComponentEditorWindowService _componentEditorWindowService;
|
||||
private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService();
|
||||
private readonly IAirAppLauncherService _airAppLauncherService = AirAppLauncherServiceProvider.GetOrCreate();
|
||||
private ComponentLibraryWindow? _detachedComponentLibraryWindow;
|
||||
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
||||
private readonly HashSet<string> _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.TransparentOverlayWindow"
|
||||
WindowDecorations="None"
|
||||
CanResize="False"
|
||||
@@ -30,6 +31,40 @@
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 32 #33000000" />
|
||||
</Style>
|
||||
<Style Selector="Button.edit-toolbar-button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Button.edit-toolbar-button:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}" />
|
||||
<Setter Property="RenderTransform" Value="scale(1.02)" />
|
||||
</Style>
|
||||
<Style Selector="Button.edit-toolbar-button:pressed">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonPressedBackgroundBrush}" />
|
||||
<Setter Property="RenderTransform" Value="scale(0.98)" />
|
||||
</Style>
|
||||
<Style Selector="Button.edit-toolbar-button fi|FluentIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Border.edit-toolbar-separator">
|
||||
<Setter Property="Width" Value="1" />
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="Margin" Value="4,8" />
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
@@ -43,18 +78,23 @@
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,20"
|
||||
Padding="8"
|
||||
Padding="6"
|
||||
IsHitTestVisible="True">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button MinWidth="112"
|
||||
Padding="16,8"
|
||||
<StackPanel Orientation="Horizontal" Spacing="2">
|
||||
<Button Classes="edit-toolbar-button"
|
||||
Click="OnRestoreComponentLibraryClick">
|
||||
<TextBlock Text="找回组件库" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="Apps" IconVariant="Regular" />
|
||||
<TextBlock Text="找回组件库" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button MinWidth="96"
|
||||
Padding="16,8"
|
||||
<Border Classes="edit-toolbar-separator" />
|
||||
<Button Classes="edit-toolbar-button"
|
||||
Click="OnExitEditClick">
|
||||
<TextBlock Text="退出编辑" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="Dismiss" IconVariant="Regular" />
|
||||
<TextBlock Text="退出编辑" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <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`.
|
||||
|
||||
@@ -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 <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.
|
||||
|
||||
390
docs/auto_commit_md/20260513_ada0cd4.md
Normal file
390
docs/auto_commit_md/20260513_ada0cd4.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Git 提交分析报告
|
||||
|
||||
## 📋 提交基本信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **提交哈希** | `ada0cd4a3a627107f2f80d910f3195a05f11a582` |
|
||||
| **短哈希** | `ada0cd4` |
|
||||
| **作者** | lincube <lincube3@hotmail.com> |
|
||||
| **提交时间** | 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 负责人
|
||||
Reference in New Issue
Block a user