feat.airapp与融合桌面

This commit is contained in:
lincube
2026-05-14 19:44:01 +08:00
parent ada0cd4a3a
commit a5abda62dc
64 changed files with 3617 additions and 362 deletions

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -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 全部通过

View 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
无移除的需求。

View 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 依赖于所有前置任务

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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>

View 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();
}
}

View 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;
}
}

View 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>

View 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}";
}
}

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.AirAppHost;
public enum AirAppWindowChromeMode
{
Standard,
Borderless,
FullScreen,
Tool,
BackgroundOnly
}

View 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);
}
}

View File

@@ -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>

View 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();
}
}

View 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>

View 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;
}
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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, ".."));
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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";

View 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);
}
}

View 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);
}
}

View File

@@ -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));

View 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;
}
}
}

View 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();
}
}

View 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)}'.");
}
}

View File

@@ -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" />

View File

@@ -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;
}
}

View File

@@ -107,4 +107,32 @@
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' != 'Windows_NT'" />
</Target>
<Target Name="BuildAirAppHostOutput"
AfterTargets="Build"
Condition="'$(BuildingAirAppHost)' != 'true' and '$(SkipAirAppHostBuild)' != 'true'">
<Exec Command="dotnet build &quot;$(MSBuildProjectDirectory)\..\LanMountainDesktop.AirAppHost\LanMountainDesktop.AirAppHost.csproj&quot; -c &quot;$(Configuration)&quot; --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>

View 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;
}
}
}

View File

@@ -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)

View File

@@ -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; }
}

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -7,6 +7,47 @@ using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
internal readonly record struct WeatherSceneProfile(
string StyleId,
MaterialWeatherCondition Condition,
string RendererId,
string WeatherLayerId,
bool IsNight,
bool IsLive)
{
public string Signature => $"{RendererId}:{WeatherLayerId}:{(IsNight ? "night" : "day")}:{(IsLive ? "live" : "still")}";
}
internal static class WeatherSceneProfileResolver
{
public static WeatherSceneProfile Resolve(string? styleId, MaterialWeatherCondition condition, bool isNight, bool isLive)
{
var normalized = WeatherVisualStyleCatalog.Normalize(styleId);
var rendererId = normalized switch
{
WeatherVisualStyleId.Geometric => "geometric",
WeatherVisualStyleId.Breezy => "breezy",
WeatherVisualStyleId.LemonFlutter => "lemon",
_ => "google"
};
var layerId = condition switch
{
MaterialWeatherCondition.Clear => "clear",
MaterialWeatherCondition.PartlyCloudy => "partly-cloudy",
MaterialWeatherCondition.Cloudy => "cloudy",
MaterialWeatherCondition.Rain => "rain",
MaterialWeatherCondition.Storm => "storm",
MaterialWeatherCondition.Snow => "snow",
MaterialWeatherCondition.Fog => "fog",
MaterialWeatherCondition.Haze => "haze",
_ => "ambient"
};
return new WeatherSceneProfile(normalized, condition, rendererId, layerId, isNight, isLive);
}
}
public sealed class MaterialWeatherSceneControl : Control
{
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(66) };
@@ -16,32 +57,37 @@ public sealed class MaterialWeatherSceneControl : Control
private double _phase;
private bool _isLive;
private bool _isAttached;
private static readonly Random _rng = new(42);
private bool _isNight;
public MaterialWeatherSceneControl()
{
IsHitTestVisible = false;
_timer.Tick += (_, _) =>
{
_phase = (_phase + 0.008) % 1d;
_phase = (_phase + 0.0065) % 1d;
InvalidateVisual();
};
}
public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive)
public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive, bool isNight)
{
_styleId = WeatherVisualStyleCatalog.Normalize(styleId);
_condition = condition;
_palette = palette;
_isLive = isLive;
_isNight = isNight;
UpdateTimer();
InvalidateVisual();
}
public void Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive)
{
Apply(styleId, condition, palette, isLive, EstimateNightFromPalette(palette));
}
public void Apply(MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive)
{
Apply(_styleId, condition, palette, isLive);
Apply(_styleId, condition, palette, isLive, EstimateNightFromPalette(palette));
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
@@ -63,26 +109,29 @@ public sealed class MaterialWeatherSceneControl : Control
base.Render(context);
var rect = new Rect(Bounds.Size);
if (rect.Width <= 1 || rect.Height <= 1) return;
if (rect.Width <= 1 || rect.Height <= 1)
{
return;
}
var profile = WeatherSceneProfileResolver.Resolve(_styleId, _condition, _isNight, _isLive);
context.DrawRectangle(CreateLinearBrush(_palette.BackgroundTop, _palette.BackgroundBottom, 0, 0, 1, 1), null, rect);
using (context.PushClip(rect))
{
DrawStyleDecoration(context, rect);
switch (_condition)
switch (profile.RendererId)
{
case MaterialWeatherCondition.Rain:
case MaterialWeatherCondition.Storm:
DrawRain(context, rect, _condition == MaterialWeatherCondition.Storm);
case "geometric":
RenderGeometricScene(context, rect, profile);
break;
case MaterialWeatherCondition.Snow:
DrawSnow(context, rect);
case "breezy":
RenderBreezyScene(context, rect, profile);
break;
case MaterialWeatherCondition.Fog:
case MaterialWeatherCondition.Haze:
DrawFog(context, rect);
case "lemon":
RenderLemonScene(context, rect, profile);
break;
default:
RenderGoogleScene(context, rect, profile);
break;
}
}
@@ -90,287 +139,537 @@ public sealed class MaterialWeatherSceneControl : Control
private void UpdateTimer()
{
if (_isLive && _isAttached) _timer.Start();
else _timer.Stop();
if (_isLive && _isAttached)
{
_timer.Start();
}
private void DrawStyleDecoration(DrawingContext ctx, Rect r)
else
{
var t = Math.Sin(_phase * Math.PI * 2d);
switch (_styleId)
{
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.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)
switch (profile.Condition)
{
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++)
{
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();
using (var g = stream.Open())
{
var startRad = startDeg * Math.PI / 180d;
var sweepRad = sweepDeg * Math.PI / 180d;
var steps = Math.Max(8, (int)(sweepDeg / 5));
var steps = Math.Max(10, (int)(Math.Abs(sweepDeg) / 4));
g.BeginFigure(new Point(cx + Math.Cos(startRad) * radius, cy + Math.Sin(startRad) * radius), false);
for (var i = 1; i <= steps; i++)
{
var a = startRad + sweepRad * (i / (double)steps);
g.LineTo(new Point(cx + Math.Cos(a) * radius, cy + Math.Sin(a) * radius));
}
g.EndFigure(false);
ctx.DrawGeometry(null, pen, stream);
}
private void DrawWaveLine(DrawingContext ctx, Rect r, double baseY, double shift, int index, Color color, double alpha)
{
var pen = new Pen(new SolidColorBrush(color, (float)alpha), Math.Max(1.5, r.Width / 100), lineCap: PenLineCap.Round);
var startX = r.Width * 0.05 + shift;
var endX = r.Width * 0.95 + shift;
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);
ctx.DrawGeometry(null, new Pen(new SolidColorBrush(color, alpha), Math.Max(1, thickness), lineCap: PenLineCap.Round), stream);
}
private void DrawCloudOutline(DrawingContext ctx, double cx, double cy, double rx, double ry, Pen pen)
private double Oscillate(double offset)
{
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);
return Math.Sin((_phase + offset) * Math.PI * 2d);
}
private void DrawRain(DrawingContext ctx, Rect rect, bool storm)
private double LightningOpacity()
{
var drops = Math.Clamp((int)(rect.Width / 22), 8, 22);
var brush = new SolidColorBrush(_palette.AccentShape, storm ? 0.72 : 0.52);
var pen = new Pen(brush, Math.Max(1.4, rect.Width / 150), lineCap: PenLineCap.Round);
for (var i = 0; i < drops; i++)
if (!_isLive)
{
var t = (_phase + i * 0.137) % 1d;
var x = rect.Width * (0.18 + (i % drops) / (double)drops * 0.72);
var y = rect.Height * (0.36 + t * 0.66);
ctx.DrawLine(pen, new Point(x, y), new Point(x - rect.Width * 0.025, y + rect.Height * 0.09));
return 0.58;
}
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);
}
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 DrawSnow(DrawingContext ctx, Rect rect)
private static bool EstimateNightFromPalette(MaterialWeatherPalette palette)
{
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);
}
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 DrawFog(DrawingContext ctx, Rect rect)
private static Color WithAlpha(Color color, double alpha)
{
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));
}
return new Color((byte)Math.Clamp(alpha * 255, 0, 255), color.R, color.G, color.B);
}
private IBrush CreateLinearBrush(Color top, Color bottom, double sx, double sy, double ex, double ey)
private static IBrush CreateLinearBrush(Color top, Color bottom, double sx, double sy, double ex, double ey)
{
return new LinearGradientBrush
{

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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");

View File

@@ -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))

View File

@@ -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);

View File

@@ -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>

View File

@@ -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();

View File

@@ -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`.

View File

@@ -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.

View 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 负责人