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