feat.airapp sdk

This commit is contained in:
lincube
2026-06-08 02:39:44 +08:00
parent 1a6f129e78
commit 7db72fbcd0
36 changed files with 2617 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
using System.Diagnostics;
using Microsoft.Build.Locator;
using Microsoft.Build.Execution;
namespace LanMountainDesktop.AirAppDevServer;
/// <summary>
/// AirApp 开发服务器
/// 提供文件监视、自动编译、热重载功能
/// </summary>
public sealed class AirAppDevServer
{
private readonly string _projectPath;
private readonly int _port;
private readonly bool _verbose;
private FileSystemWatcher? _watcher;
private DateTime _lastBuildTime = DateTime.MinValue;
private readonly object _buildLock = new();
private bool _isBuilding;
public AirAppDevServer(string projectPath, int port, bool verbose)
{
_projectPath = Path.GetFullPath(projectPath);
_port = port;
_verbose = verbose;
}
public Task StartAsync()
{
// 初始构建
Console.WriteLine("🔨 初始构建中...");
if (!BuildProject())
{
Console.WriteLine("❌ 初始构建失败");
return Task.CompletedTask;
}
Console.WriteLine("✅ 初始构建成功");
Console.WriteLine();
// 启动文件监视
StartFileWatcher();
return Task.CompletedTask;
}
public Task StopAsync()
{
_watcher?.Dispose();
return Task.CompletedTask;
}
private void StartFileWatcher()
{
_watcher = new FileSystemWatcher(_projectPath)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
Filter = "*.*",
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
_watcher.Changed += OnFileChanged;
_watcher.Created += OnFileChanged;
_watcher.Deleted += OnFileChanged;
_watcher.Renamed += OnFileRenamed;
Console.WriteLine("👁️ 文件监视已启动,等待更改...");
Console.WriteLine();
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
// 忽略 bin、obj、.vs 等目录
if (e.FullPath.Contains("\\bin\\") ||
e.FullPath.Contains("\\obj\\") ||
e.FullPath.Contains("\\.vs\\") ||
e.FullPath.Contains("\\.git\\"))
{
return;
}
// 只处理源代码文件
var ext = Path.GetExtension(e.FullPath).ToLowerInvariant();
if (ext != ".cs" && ext != ".axaml" && ext != ".json" && ext != ".csproj")
{
return;
}
// 防止重复触发(文件保存时可能触发多次)
var now = DateTime.Now;
if ((now - _lastBuildTime).TotalMilliseconds < 500)
{
return;
}
LogVerbose($"📝 检测到文件更改: {Path.GetFileName(e.FullPath)}");
TriggerRebuild();
}
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
LogVerbose($"📝 检测到文件重命名: {Path.GetFileName(e.OldFullPath)} -> {Path.GetFileName(e.FullPath)}");
TriggerRebuild();
}
private void TriggerRebuild()
{
lock (_buildLock)
{
if (_isBuilding)
{
LogVerbose("⏳ 构建进行中,跳过此次触发");
return;
}
_isBuilding = true;
}
Task.Run(() =>
{
try
{
// 短暂延迟,让文件写入完成
Thread.Sleep(300);
Console.WriteLine("🔄 重新构建中...");
var success = BuildProject();
_lastBuildTime = DateTime.Now;
if (success)
{
Console.WriteLine($"✅ 重新构建成功 [{DateTime.Now:HH:mm:ss}]");
Console.WriteLine("♻️ 热重载已生效");
}
else
{
Console.WriteLine($"❌ 重新构建失败 [{DateTime.Now:HH:mm:ss}]");
}
Console.WriteLine();
}
finally
{
lock (_buildLock)
{
_isBuilding = false;
}
}
});
}
private bool BuildProject()
{
try
{
// 查找项目文件
var projectFile = FindProjectFile();
if (projectFile == null)
{
Console.WriteLine("❌ 未找到项目文件 (.csproj)");
return false;
}
LogVerbose($"📄 项目文件: {Path.GetFileName(projectFile)}");
// 使用 dotnet build
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"build \"{projectFile}\" -c Debug --nologo",
WorkingDirectory = Path.GetDirectoryName(projectFile),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
{
Console.WriteLine("❌ 无法启动 dotnet build");
return false;
}
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (_verbose)
{
if (!string.IsNullOrWhiteSpace(output))
{
Console.WriteLine(output);
}
}
if (process.ExitCode != 0)
{
Console.WriteLine("❌ 构建错误:");
Console.WriteLine(error);
return false;
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"❌ 构建异常: {ex.Message}");
if (_verbose)
{
Console.WriteLine(ex.StackTrace);
}
return false;
}
}
private string? FindProjectFile()
{
var files = Directory.GetFiles(_projectPath, "*.csproj", SearchOption.TopDirectoryOnly);
return files.Length > 0 ? files[0] : null;
}
private void LogVerbose(string message)
{
if (_verbose)
{
Console.WriteLine($"[VERBOSE] {message}");
}
}
}

View File

@@ -0,0 +1,119 @@
using System.Diagnostics;
using System.IO.Compression;
namespace LanMountainDesktop.AirAppDevServer;
/// <summary>
/// AirApp 打包工具
/// 将 AirApp 项目打包为 .laapp 文件
/// </summary>
public sealed class AirAppPackager
{
private readonly string _projectPath;
public AirAppPackager(string projectPath)
{
_projectPath = Path.GetFullPath(projectPath);
}
public async Task<string> PackageAsync(string? outputPath)
{
Console.WriteLine("🔨 构建项目...");
if (!await BuildProjectAsync())
{
throw new InvalidOperationException("构建失败");
}
var binPath = Path.Combine(_projectPath, "bin", "Release", "net10.0");
if (!Directory.Exists(binPath))
{
binPath = Path.Combine(_projectPath, "bin", "Debug", "net10.0");
if (!Directory.Exists(binPath))
{
throw new InvalidOperationException("未找到构建输出");
}
}
Console.WriteLine($"📁 输出目录: {binPath}");
// 确定输出文件名
var projectName = Path.GetFileNameWithoutExtension(
Directory.GetFiles(_projectPath, "*.csproj").FirstOrDefault() ?? "AirApp");
if (string.IsNullOrEmpty(outputPath))
{
outputPath = Path.Combine(binPath, $"{projectName}.laapp");
}
else
{
outputPath = Path.GetFullPath(outputPath);
if (Directory.Exists(outputPath))
{
outputPath = Path.Combine(outputPath, $"{projectName}.laapp");
}
}
// 删除旧的包
if (File.Exists(outputPath))
{
File.Delete(outputPath);
}
Console.WriteLine($"📦 打包到: {outputPath}");
// 创建 ZIP 包
using (var archive = ZipFile.Open(outputPath, ZipArchiveMode.Create))
{
var filesToPackage = Directory.GetFiles(binPath, "*.*", SearchOption.AllDirectories)
.Where(f => !f.Contains(".pdb") && !f.EndsWith(".laapp"))
.ToList();
Console.WriteLine($"📄 打包 {filesToPackage.Count} 个文件...");
foreach (var file in filesToPackage)
{
var relativePath = Path.GetRelativePath(binPath, file);
archive.CreateEntryFromFile(file, relativePath);
}
}
Console.WriteLine($"✅ 包大小: {new FileInfo(outputPath).Length / 1024} KB");
return outputPath;
}
private async Task<bool> BuildProjectAsync()
{
var projectFile = Directory.GetFiles(_projectPath, "*.csproj").FirstOrDefault();
if (projectFile == null)
{
Console.WriteLine("❌ 未找到项目文件");
return false;
}
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"build \"{projectFile}\" -c Release --nologo",
WorkingDirectory = _projectPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync();
Console.WriteLine($"❌ 构建错误:\n{error}");
return false;
}
return true;
}
}

View File

@@ -0,0 +1,129 @@
using System.Diagnostics;
using System.Text.Json;
namespace LanMountainDesktop.AirAppDevServer;
/// <summary>
/// AirApp 预览工具
/// 在独立窗口中预览组件或窗口,无需安装到宿主
/// </summary>
public sealed class AirAppPreviewer
{
private readonly string _projectPath;
public AirAppPreviewer(string projectPath)
{
_projectPath = Path.GetFullPath(projectPath);
}
public async Task PreviewComponentAsync(string componentId)
{
Console.WriteLine($"🎨 预览组件: {componentId}");
await LaunchPreviewAsync("component", componentId);
}
public async Task PreviewWindowAsync(string windowId)
{
Console.WriteLine($"🪟 预览窗口: {windowId}");
await LaunchPreviewAsync("window", windowId);
}
public async Task PreviewAllAsync()
{
Console.WriteLine("📋 加载 AirApp 清单...");
var manifest = await LoadManifestAsync();
if (manifest == null)
{
Console.WriteLine("❌ 未找到 airapp.json");
return;
}
Console.WriteLine($"✅ AirApp: {manifest.Name}");
Console.WriteLine();
// 显示可用的组件和窗口
if (manifest.Components?.Count > 0)
{
Console.WriteLine("📦 可用组件:");
foreach (var comp in manifest.Components)
{
Console.WriteLine($" - {comp.Id}: {comp.Name}");
}
Console.WriteLine();
}
if (manifest.Windows?.Count > 0)
{
Console.WriteLine("🪟 可用窗口:");
foreach (var win in manifest.Windows)
{
Console.WriteLine($" - {win.Id}: {win.Name}");
}
Console.WriteLine();
}
Console.WriteLine("使用以下命令预览:");
Console.WriteLine(" airapp-dev preview --component <component-id>");
Console.WriteLine(" airapp-dev preview --window <window-id>");
}
private async Task LaunchPreviewAsync(string type, string id)
{
// 确保项目已构建
var binPath = Path.Combine(_projectPath, "bin", "Debug", "net10.0");
if (!Directory.Exists(binPath))
{
Console.WriteLine("❌ 未找到构建输出,请先运行: dotnet build");
return;
}
Console.WriteLine($"📁 输出路径: {binPath}");
Console.WriteLine("🚀 启动预览窗口...");
Console.WriteLine();
Console.WriteLine("💡 提示: 关闭预览窗口以退出");
Console.WriteLine();
// TODO: 这里需要启动一个预览宿主应用
// 预览宿主会加载 AirApp 并显示指定的组件或窗口
Console.WriteLine("⚠️ 预览功能需要配合 LanMountainDesktop 宿主运行");
Console.WriteLine(" 暂时请使用: dotnet run --project LanMountainDesktop.csproj -- --debug-airapp <path>");
await Task.CompletedTask;
}
private async Task<ManifestModel?> LoadManifestAsync()
{
var manifestPath = Path.Combine(_projectPath, "airapp.json");
if (!File.Exists(manifestPath))
{
return null;
}
var json = await File.ReadAllTextAsync(manifestPath);
return JsonSerializer.Deserialize<ManifestModel>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
private sealed class ManifestModel
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public List<ComponentModel>? Components { get; set; }
public List<WindowModel>? Windows { get; set; }
}
private sealed class ComponentModel
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
}
private sealed class WindowModel
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="Microsoft.Build.Locator" Version="1.7.8" />
<PackageReference Include="Microsoft.Build" Version="17.11.4" />
<PackageReference Include="Microsoft.Build.Framework" Version="17.11.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.AirAppSdk\LanMountainDesktop.AirAppSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,149 @@
using System.CommandLine;
using System.Diagnostics;
namespace LanMountainDesktop.AirAppDevServer;
/// <summary>
/// AirApp 开发服务器主程序
/// 提供热重载、实时预览等开发功能
/// </summary>
class Program
{
static async Task<int> Main(string[] args)
{
var rootCommand = new RootCommand("LanMountainDesktop AirApp 开发服务器");
// 开发模式命令
var devCommand = new Command("dev", "启动开发服务器(支持热重载)");
var projectPathOption = new Option<string>(
aliases: new[] { "--project", "-p" },
description: "AirApp 项目路径",
getDefaultValue: () => Directory.GetCurrentDirectory());
var portOption = new Option<int>(
aliases: new[] { "--port" },
description: "开发服务器端口",
getDefaultValue: () => 5000);
var verboseOption = new Option<bool>(
aliases: new[] { "--verbose", "-v" },
description: "显示详细日志");
devCommand.AddOption(projectPathOption);
devCommand.AddOption(portOption);
devCommand.AddOption(verboseOption);
devCommand.SetHandler(async (projectPath, port, verbose) =>
{
await RunDevServerAsync(projectPath, port, verbose);
}, projectPathOption, portOption, verboseOption);
// 预览命令
var previewCommand = new Command("preview", "预览 AirApp无需安装到宿主");
var componentOption = new Option<string?>(
aliases: new[] { "--component", "-c" },
description: "要预览的组件 ID");
var windowOption = new Option<string?>(
aliases: new[] { "--window", "-w" },
description: "要预览的窗口 ID");
previewCommand.AddOption(projectPathOption);
previewCommand.AddOption(componentOption);
previewCommand.AddOption(windowOption);
previewCommand.SetHandler(async (projectPath, component, window) =>
{
await RunPreviewAsync(projectPath, component, window);
}, projectPathOption, componentOption, windowOption);
// 打包命令
var packageCommand = new Command("package", "打包 AirApp 为 .laapp 文件");
var outputOption = new Option<string?>(
aliases: new[] { "--output", "-o" },
description: "输出路径");
packageCommand.AddOption(projectPathOption);
packageCommand.AddOption(outputOption);
packageCommand.SetHandler(async (projectPath, output) =>
{
await PackageAirAppAsync(projectPath, output);
}, projectPathOption, outputOption);
rootCommand.AddCommand(devCommand);
rootCommand.AddCommand(previewCommand);
rootCommand.AddCommand(packageCommand);
return await rootCommand.InvokeAsync(args);
}
static async Task RunDevServerAsync(string projectPath, int port, bool verbose)
{
Console.WriteLine("🚀 启动 AirApp 开发服务器...");
Console.WriteLine($"📁 项目路径: {projectPath}");
Console.WriteLine($"🔌 端口: {port}");
Console.WriteLine();
var server = new AirAppDevServer(projectPath, port, verbose);
await server.StartAsync();
Console.WriteLine();
Console.WriteLine("✅ 开发服务器已启动");
Console.WriteLine($"🌐 预览地址: http://localhost:{port}");
Console.WriteLine();
Console.WriteLine("按 Ctrl+C 停止服务器...");
Console.WriteLine();
// 等待取消信号
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true;
cts.Cancel();
};
try
{
await Task.Delay(Timeout.Infinite, cts.Token);
}
catch (TaskCanceledException)
{
Console.WriteLine();
Console.WriteLine("🛑 正在停止服务器...");
}
await server.StopAsync();
Console.WriteLine("✅ 服务器已停止");
}
static async Task RunPreviewAsync(string projectPath, string? component, string? window)
{
Console.WriteLine("👁️ 启动 AirApp 预览...");
Console.WriteLine($"📁 项目路径: {projectPath}");
var previewer = new AirAppPreviewer(projectPath);
if (!string.IsNullOrEmpty(component))
{
await previewer.PreviewComponentAsync(component);
}
else if (!string.IsNullOrEmpty(window))
{
await previewer.PreviewWindowAsync(window);
}
else
{
await previewer.PreviewAllAsync();
}
}
static async Task PackageAirAppAsync(string projectPath, string? output)
{
Console.WriteLine("📦 打包 AirApp...");
Console.WriteLine($"📁 项目路径: {projectPath}");
var packager = new AirAppPackager(projectPath);
var outputPath = await packager.PackageAsync(output);
Console.WriteLine();
Console.WriteLine($"✅ 打包完成: {outputPath}");
}
}

View File

@@ -0,0 +1,49 @@
using Avalonia.Media;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Snapshot of the current appearance settings.
/// </summary>
public sealed class AirAppAppearanceSnapshot
{
/// <summary>
/// Gets whether dark mode is enabled.
/// </summary>
public bool IsDarkMode { get; init; }
/// <summary>
/// Gets the primary accent color.
/// </summary>
public Color AccentColor { get; init; }
/// <summary>
/// Gets the glass effect opacity (0.0 - 1.0).
/// </summary>
public double GlassOpacity { get; init; }
/// <summary>
/// Gets the corner radius preset.
/// </summary>
public AirAppCornerRadiusPreset CornerRadiusPreset { get; init; }
/// <summary>
/// Gets the background color.
/// </summary>
public Color BackgroundColor { get; init; }
/// <summary>
/// Gets the foreground (text) color.
/// </summary>
public Color ForegroundColor { get; init; }
/// <summary>
/// Gets the border color.
/// </summary>
public Color BorderColor { get; init; }
/// <summary>
/// Gets additional custom properties.
/// </summary>
public IReadOnlyDictionary<string, object>? CustomProperties { get; init; }
}

View File

@@ -0,0 +1,119 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Base class for AirApp implementations.
/// Inherit from this class and apply the [AirAppEntrance] attribute.
/// </summary>
public abstract class AirAppBase : IAirApp
{
/// <summary>
/// Gets the runtime context after the AirApp has started.
/// Available after OnStartedAsync is called.
/// </summary>
protected IAirAppRuntimeContext? RuntimeContext { get; private set; }
/// <summary>
/// Initialize the AirApp and register services.
/// Override this method to register your components, windows, and services.
/// </summary>
/// <param name="context">Host builder context</param>
/// <param name="services">Service collection</param>
public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
{
// Default implementation: do nothing
// Derived classes can override to register services
}
/// <summary>
/// Called after the host application has started.
/// Override this for runtime initialization.
/// </summary>
/// <param name="context">AirApp runtime context</param>
public virtual Task OnStartedAsync(IAirAppRuntimeContext context)
{
RuntimeContext = context;
return Task.CompletedTask;
}
/// <summary>
/// Called when the host application is stopping.
/// Override this for cleanup logic.
/// </summary>
public virtual Task OnStoppingAsync()
{
return Task.CompletedTask;
}
/// <summary>
/// Register a desktop component widget.
/// </summary>
/// <typeparam name="TWidget">Widget implementation type</typeparam>
/// <param name="id">Unique component identifier</param>
/// <param name="name">Display name</param>
/// <param name="configure">Optional configuration</param>
protected void RegisterComponent<TWidget>(
string id,
string name,
Action<AirAppComponentOptions>? configure = null)
where TWidget : class, IAirAppWidget
{
if (RuntimeContext == null)
{
throw new InvalidOperationException(
"RegisterComponent can only be called after OnStartedAsync. " +
"Use IServiceCollection extension methods in Initialize() instead.");
}
var options = new AirAppComponentOptions
{
Id = id,
Name = name,
WidgetType = typeof(TWidget)
};
configure?.Invoke(options);
// Delegate to runtime context
RuntimeContext.RegisterComponent(options);
}
/// <summary>
/// Register a window.
/// </summary>
/// <typeparam name="TWindow">Window implementation type</typeparam>
/// <param name="id">Unique window identifier</param>
/// <param name="name">Display name</param>
protected void RegisterWindow<TWindow>(string id, string name)
where TWindow : class, IAirAppWindow
{
if (RuntimeContext == null)
{
throw new InvalidOperationException(
"RegisterWindow can only be called after OnStartedAsync.");
}
RuntimeContext.RegisterWindow(id, name, typeof(TWindow));
}
/// <summary>
/// Register a service in the DI container.
/// </summary>
/// <typeparam name="TService">Service interface</typeparam>
/// <typeparam name="TImplementation">Implementation type</typeparam>
protected void RegisterService<TService, TImplementation>()
where TService : class
where TImplementation : class, TService
{
if (RuntimeContext == null)
{
throw new InvalidOperationException(
"RegisterService can only be called after OnStartedAsync. " +
"Use IServiceCollection in Initialize() instead.");
}
RuntimeContext.RegisterService<TService, TImplementation>();
}
}

View File

@@ -0,0 +1,61 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Options for registering an AirApp desktop component.
/// </summary>
public sealed class AirAppComponentOptions
{
/// <summary>
/// Gets or sets the unique component identifier.
/// </summary>
public required string Id { get; set; }
/// <summary>
/// Gets or sets the display name.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Gets or sets the widget implementation type.
/// Must implement IAirAppWidget.
/// </summary>
public required Type WidgetType { get; set; }
/// <summary>
/// Gets or sets the optional description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the default width in grid cells.
/// Default is 2.
/// </summary>
public int DefaultWidth { get; set; } = 2;
/// <summary>
/// Gets or sets the default height in grid cells.
/// Default is 2.
/// </summary>
public int DefaultHeight { get; set; } = 2;
/// <summary>
/// Gets or sets the resize mode.
/// </summary>
public AirAppComponentResizeMode ResizeMode { get; set; } = AirAppComponentResizeMode.Both;
/// <summary>
/// Gets or sets whether this component can be added multiple times.
/// Default is true.
/// </summary>
public bool AllowMultipleInstances { get; set; } = true;
/// <summary>
/// Gets or sets the category for grouping in the component library.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Gets or sets the icon identifier.
/// </summary>
public string? IconKey { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Resize mode for AirApp desktop components.
/// </summary>
public enum AirAppComponentResizeMode
{
/// <summary>
/// Cannot be resized.
/// </summary>
None = 0,
/// <summary>
/// Can be resized horizontally only.
/// </summary>
Horizontal = 1,
/// <summary>
/// Can be resized vertically only.
/// </summary>
Vertical = 2,
/// <summary>
/// Can be resized in both directions.
/// </summary>
Both = 3
}

View File

@@ -0,0 +1,32 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Corner radius presets.
/// </summary>
public enum AirAppCornerRadiusPreset
{
/// <summary>
/// No rounded corners.
/// </summary>
None = 0,
/// <summary>
/// Small corner radius (4px).
/// </summary>
Small = 1,
/// <summary>
/// Medium corner radius (8px).
/// </summary>
Medium = 2,
/// <summary>
/// Large corner radius (12px).
/// </summary>
Large = 3,
/// <summary>
/// Extra large corner radius (16px).
/// </summary>
ExtraLarge = 4
}

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Marks a class as the entry point for an AirApp.
/// The marked class must inherit from AirAppBase or implement IAirApp.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class AirAppEntranceAttribute : Attribute
{
}

View File

@@ -0,0 +1,188 @@
using System.Text.Json;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// AirApp manifest (airapp.json).
/// </summary>
public sealed record AirAppManifest(
string Id,
string Name,
string EntranceAssembly,
string? Description = null,
string? Author = null,
string? Version = null,
string? ApiVersion = null,
AirAppRuntimeConfiguration? Runtime = null,
IReadOnlyList<AirAppComponentManifest>? Components = null,
IReadOnlyList<AirAppWindowManifest>? Windows = null,
IReadOnlyList<string>? Permissions = null,
IReadOnlyList<AirAppSharedContractReference>? SharedContracts = null)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
/// <summary>
/// Load manifest from file.
/// </summary>
public static AirAppManifest Load(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
using var stream = File.OpenRead(manifestPath);
return Load(stream, manifestPath);
}
/// <summary>
/// Load manifest from stream.
/// </summary>
public static AirAppManifest Load(Stream stream, string sourceName)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var manifest = JsonSerializer.Deserialize<AirAppManifest>(stream, SerializerOptions);
if (manifest is null)
{
throw new InvalidOperationException($"Failed to deserialize AirApp manifest '{sourceName}'.");
}
return manifest.NormalizeAndValidate(sourceName);
}
/// <summary>
/// Resolve entrance assembly path.
/// </summary>
public string ResolveEntranceAssemblyPath(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
if (Path.IsPathRooted(EntranceAssembly))
{
return Path.GetFullPath(EntranceAssembly);
}
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
?? throw new InvalidOperationException($"Failed to determine directory of '{manifestPath}'.");
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
}
/// <summary>
/// Get runtime mode.
/// </summary>
public AirAppRuntimeMode RuntimeMode =>
AirAppRuntimeModes.TryParse(Runtime?.Mode, out var mode) ? mode : AirAppRuntimeMode.InProcess;
private AirAppManifest NormalizeAndValidate(string manifestPath)
{
var normalizedRuntime = (Runtime ?? new AirAppRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
var normalized = this with
{
Id = RequireValue(Id, nameof(Id), manifestPath),
Name = RequireValue(Name, nameof(Name), manifestPath),
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
Description = NormalizeOptionalValue(Description),
Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? AirAppSdkInfo.ApiVersion,
Runtime = normalizedRuntime,
Components = Components ?? Array.Empty<AirAppComponentManifest>(),
Windows = Windows ?? Array.Empty<AirAppWindowManifest>(),
Permissions = Permissions ?? Array.Empty<string>(),
SharedContracts = SharedContracts ?? Array.Empty<AirAppSharedContractReference>()
};
// Validate API version
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
{
throw new InvalidOperationException(
$"AirApp manifest '{manifestPath}' declares invalid API version '{normalized.ApiVersion}'.");
}
if (!System.Version.TryParse(AirAppSdkInfo.ApiVersion, out var currentVersion))
{
throw new InvalidOperationException($"AirApp SDK API version '{AirAppSdkInfo.ApiVersion}' is invalid.");
}
if (requestedVersion.Major != currentVersion.Major)
{
throw new InvalidOperationException(
$"AirApp '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
$"but the host provides '{AirAppSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
$"This host only supports v{currentVersion.Major}.x AirApps and rejects v{requestedVersion.Major}.x packages. " +
$"Migrate the AirApp manifest and code to API {AirAppSdkInfo.ApiVersion}, then rebuild and republish.");
}
return normalized;
}
private static string RequireValue(string? value, string propertyName, string manifestPath)
{
var normalized = NormalizeOptionalValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException(
$"AirApp manifest '{manifestPath}' is missing required property '{propertyName}'.");
}
return normalized;
}
private static string? NormalizeOptionalValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
/// <summary>
/// Component declaration in manifest.
/// </summary>
public sealed record AirAppComponentManifest(
string Id,
string Name,
int DefaultWidth = 2,
int DefaultHeight = 2,
string? Description = null,
string? Category = null,
string? IconKey = null);
/// <summary>
/// Window declaration in manifest.
/// </summary>
public sealed record AirAppWindowManifest(
string Id,
string Name,
double DefaultWidth = 800,
double DefaultHeight = 600,
string? Description = null);
/// <summary>
/// Shared contract reference.
/// </summary>
public sealed record AirAppSharedContractReference(
string Id,
string Version);
/// <summary>
/// Runtime configuration.
/// </summary>
public sealed record AirAppRuntimeConfiguration
{
public string? Mode { get; init; }
public IReadOnlyList<string>? Capabilities { get; init; }
internal AirAppRuntimeConfiguration NormalizeAndValidate(string manifestPath)
{
return this with
{
Mode = string.IsNullOrWhiteSpace(Mode) ? "in-process" : Mode.Trim().ToLowerInvariant(),
Capabilities = Capabilities ?? Array.Empty<string>()
};
}
}

View File

@@ -0,0 +1,53 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Runtime mode for AirApps.
/// </summary>
public enum AirAppRuntimeMode
{
/// <summary>
/// Run in the host process (best performance, shared memory).
/// </summary>
InProcess = 0,
/// <summary>
/// Run in an isolated background process (safer, separate memory).
/// </summary>
IsolatedBackground = 1,
/// <summary>
/// Run in an isolated window process (full isolation).
/// </summary>
IsolatedWindow = 2
}
/// <summary>
/// Helper for parsing runtime modes.
/// </summary>
public static class AirAppRuntimeModes
{
public static bool TryParse(string? mode, out AirAppRuntimeMode result)
{
result = AirAppRuntimeMode.InProcess;
if (string.IsNullOrWhiteSpace(mode))
{
return false;
}
var normalized = mode.Trim().ToLowerInvariant();
return normalized switch
{
"in-process" => SetResult(AirAppRuntimeMode.InProcess, out result),
"isolated-background" => SetResult(AirAppRuntimeMode.IsolatedBackground, out result),
"isolated-window" => SetResult(AirAppRuntimeMode.IsolatedWindow, out result),
_ => false
};
}
private static bool SetResult(AirAppRuntimeMode mode, out AirAppRuntimeMode result)
{
result = mode;
return true;
}
}

View File

@@ -0,0 +1,33 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// AirApp SDK information.
/// </summary>
public static class AirAppSdkInfo
{
/// <summary>
/// Current SDK version.
/// </summary>
public const string SdkVersion = "6.0.0";
/// <summary>
/// Current API version.
/// AirApps must target this major version to be compatible.
/// </summary>
public const string ApiVersion = "6.0.0";
/// <summary>
/// Gets the SDK display name.
/// </summary>
public static string DisplayName => "LanMountainDesktop AirApp SDK";
/// <summary>
/// Gets the default manifest file name.
/// </summary>
public const string ManifestFileName = "airapp.json";
/// <summary>
/// Gets the package file extension.
/// </summary>
public const string PackageExtension = ".laapp";
}

View File

@@ -0,0 +1,158 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Extension methods for registering AirApp services.
/// </summary>
public static class AirAppServiceCollectionExtensions
{
/// <summary>
/// Register a desktop component.
/// </summary>
public static IServiceCollection AddAirAppComponent<TWidget>(
this IServiceCollection services,
string id,
string name,
Action<AirAppComponentOptions>? configure = null)
where TWidget : class, IAirAppWidget
{
var options = new AirAppComponentOptions
{
Id = id,
Name = name,
WidgetType = typeof(TWidget)
};
configure?.Invoke(options);
// Register the widget as transient (new instance per placement)
services.AddTransient<TWidget>();
// Register the component options (will be picked up by the host)
services.AddSingleton(options);
return services;
}
/// <summary>
/// Register a window.
/// </summary>
public static IServiceCollection AddAirAppWindow<TWindow>(
this IServiceCollection services,
string id,
string name)
where TWindow : class, IAirAppWindow
{
// Register the window as transient (new instance per open)
services.AddTransient<TWindow>();
// TODO: Register window metadata
return services;
}
/// <summary>
/// Register a settings section (declarative).
/// </summary>
public static IServiceCollection AddAirAppSettings(
this IServiceCollection services,
string id,
string name,
Action<AirAppSettingsSectionBuilder>? configure = null)
{
var builder = new AirAppSettingsSectionBuilder(id, name);
configure?.Invoke(builder);
// Register the settings section
services.AddSingleton(builder.Build());
return services;
}
}
/// <summary>
/// Builder for settings sections.
/// </summary>
public sealed class AirAppSettingsSectionBuilder
{
private readonly string _id;
private readonly string _name;
private readonly List<AirAppSettingOption> _options = new();
internal AirAppSettingsSectionBuilder(string id, string name)
{
_id = id;
_name = name;
}
public AirAppSettingsSectionBuilder AddToggle(string key, string label, bool defaultValue = false)
{
_options.Add(new AirAppSettingOption
{
Key = key,
Label = label,
Type = "toggle",
DefaultValue = defaultValue
});
return this;
}
public AirAppSettingsSectionBuilder AddText(string key, string label, string? defaultValue = null)
{
_options.Add(new AirAppSettingOption
{
Key = key,
Label = label,
Type = "text",
DefaultValue = defaultValue
});
return this;
}
public AirAppSettingsSectionBuilder AddNumber(string key, string label, double defaultValue = 0, double? minimum = null, double? maximum = null)
{
_options.Add(new AirAppSettingOption
{
Key = key,
Label = label,
Type = "number",
DefaultValue = defaultValue,
Minimum = minimum,
Maximum = maximum
});
return this;
}
internal AirAppSettingsSection Build()
{
return new AirAppSettingsSection
{
Id = _id,
Name = _name,
Options = _options
};
}
}
/// <summary>
/// Settings section metadata.
/// </summary>
public sealed class AirAppSettingsSection
{
public required string Id { get; init; }
public required string Name { get; init; }
public required List<AirAppSettingOption> Options { get; init; }
}
/// <summary>
/// Individual setting option.
/// </summary>
public sealed class AirAppSettingOption
{
public required string Key { get; init; }
public required string Label { get; init; }
public required string Type { get; init; }
public object? DefaultValue { get; init; }
public double? Minimum { get; init; }
public double? Maximum { get; init; }
}

View File

@@ -0,0 +1,80 @@
using Avalonia.Controls;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Base class for AirApp desktop component widgets.
/// Inherit from this to create custom desktop components.
/// </summary>
public abstract class AirAppWidgetBase : UserControl, IAirAppWidget
{
private IAirAppComponentContext? _context;
/// <summary>
/// Gets or sets the component context.
/// </summary>
public IAirAppComponentContext Context
{
get => _context ?? throw new InvalidOperationException("Context has not been set yet.");
set
{
_context = value;
OnContextSet();
}
}
/// <summary>
/// Called when the context is first set.
/// Override this to initialize based on context.
/// </summary>
protected virtual void OnContextSet()
{
}
/// <summary>
/// Called when the widget is attached to the desktop.
/// </summary>
public void OnAttached()
{
OnAttachedCore();
}
/// <summary>
/// Called when the widget is detached from the desktop.
/// </summary>
public void OnDetached()
{
OnDetachedCore();
}
/// <summary>
/// Called when the appearance has changed.
/// </summary>
/// <param name="snapshot">New appearance snapshot</param>
public void OnAppearanceChanged(AirAppAppearanceSnapshot snapshot)
{
OnAppearanceChangedCore(snapshot);
}
/// <summary>
/// Override this to handle widget attachment.
/// </summary>
protected virtual void OnAttachedCore()
{
}
/// <summary>
/// Override this to handle widget detachment.
/// </summary>
protected virtual void OnDetachedCore()
{
}
/// <summary>
/// Override this to handle appearance changes.
/// </summary>
/// <param name="snapshot">New appearance snapshot</param>
protected virtual void OnAppearanceChangedCore(AirAppAppearanceSnapshot snapshot)
{
}
}

View File

@@ -0,0 +1,96 @@
using Avalonia;
using Avalonia.Controls;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Base class for AirApp windows.
/// </summary>
public abstract class AirAppWindowBase : Window, IAirAppWindow
{
/// <summary>
/// Gets the window descriptor.
/// Override this to customize window configuration.
/// </summary>
public virtual AirAppWindowDescriptor Descriptor => new()
{
Width = 800,
Height = 600,
MinWidth = 400,
MinHeight = 300,
ChromeMode = AirAppWindowChromeMode.Standard,
CanResize = true,
ShowInTaskbar = true,
ShowAsDialog = false
};
/// <summary>
/// Initializes a new instance of AirAppWindowBase.
/// </summary>
protected AirAppWindowBase()
{
ApplyDescriptor(Descriptor);
}
/// <summary>
/// Called before the window is opened.
/// </summary>
public virtual Task OnWindowOpeningAsync()
{
return Task.CompletedTask;
}
/// <summary>
/// Called after the window has been opened.
/// </summary>
public virtual void OnWindowOpened()
{
}
/// <summary>
/// Called when the window is closing.
/// </summary>
public virtual void OnWindowClosing(WindowClosingEventArgs e)
{
}
/// <summary>
/// Called after the window has been closed.
/// </summary>
public virtual void OnWindowClosed()
{
}
/// <summary>
/// Apply the window descriptor configuration.
/// </summary>
private void ApplyDescriptor(AirAppWindowDescriptor descriptor)
{
Width = descriptor.Width;
Height = descriptor.Height;
MinWidth = descriptor.MinWidth;
MinHeight = descriptor.MinHeight;
CanResize = descriptor.CanResize;
ShowInTaskbar = descriptor.ShowInTaskbar;
ShowAsDialog = descriptor.ShowAsDialog;
// Apply chrome mode
switch (descriptor.ChromeMode)
{
case AirAppWindowChromeMode.Standard:
SystemDecorations = SystemDecorations.Full;
break;
case AirAppWindowChromeMode.Borderless:
SystemDecorations = SystemDecorations.BorderOnly;
break;
case AirAppWindowChromeMode.FullScreen:
SystemDecorations = SystemDecorations.None;
WindowState = WindowState.FullScreen;
break;
case AirAppWindowChromeMode.Tool:
SystemDecorations = SystemDecorations.Full;
ShowInTaskbar = false;
break;
}
}
}

View File

@@ -0,0 +1,32 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Window chrome mode for AirApp windows.
/// </summary>
public enum AirAppWindowChromeMode
{
/// <summary>
/// Standard window with title bar and borders.
/// </summary>
Standard = 0,
/// <summary>
/// Borderless window with custom chrome.
/// </summary>
Borderless = 1,
/// <summary>
/// Full-screen window with no decorations.
/// </summary>
FullScreen = 2,
/// <summary>
/// Tool window (no taskbar icon, small title bar).
/// </summary>
Tool = 3,
/// <summary>
/// Background-only (no UI, reserved for future use).
/// </summary>
BackgroundOnly = 4
}

View File

@@ -0,0 +1,52 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Window configuration descriptor.
/// </summary>
public sealed class AirAppWindowDescriptor
{
/// <summary>
/// Gets or sets the window title.
/// </summary>
public string Title { get; set; } = "AirApp Window";
/// <summary>
/// Gets or sets the initial width.
/// </summary>
public double Width { get; set; } = 800;
/// <summary>
/// Gets or sets the initial height.
/// </summary>
public double Height { get; set; } = 600;
/// <summary>
/// Gets or sets the minimum width.
/// </summary>
public double MinWidth { get; set; } = 400;
/// <summary>
/// Gets or sets the minimum height.
/// </summary>
public double MinHeight { get; set; } = 300;
/// <summary>
/// Gets or sets the chrome mode.
/// </summary>
public AirAppWindowChromeMode ChromeMode { get; set; } = AirAppWindowChromeMode.Standard;
/// <summary>
/// Gets or sets whether the window can be resized.
/// </summary>
public bool CanResize { get; set; } = true;
/// <summary>
/// Gets or sets whether the window shows in the taskbar.
/// </summary>
public bool ShowInTaskbar { get; set; } = true;
/// <summary>
/// Gets or sets whether the window is modal.
/// </summary>
public bool ShowAsDialog { get; set; } = false;
}

View File

@@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Core interface for AirApp entry point.
/// </summary>
public interface IAirApp
{
/// <summary>
/// Initialize the AirApp and register services.
/// Called during host startup before the application is fully running.
/// </summary>
/// <param name="context">Host builder context</param>
/// <param name="services">Service collection for dependency injection</param>
void Initialize(HostBuilderContext context, IServiceCollection services);
/// <summary>
/// Called after the host application has started.
/// Use this for initialization that requires runtime services.
/// </summary>
/// <param name="context">AirApp runtime context</param>
Task OnStartedAsync(IAirAppRuntimeContext context);
/// <summary>
/// Called when the host application is stopping.
/// Use this for cleanup and resource disposal.
/// </summary>
Task OnStoppingAsync();
}

View File

@@ -0,0 +1,19 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Provides appearance and theme context.
/// </summary>
public interface IAirAppAppearanceContext
{
/// <summary>
/// Gets the current appearance snapshot.
/// </summary>
AirAppAppearanceSnapshot CurrentSnapshot { get; }
/// <summary>
/// Subscribe to appearance changes.
/// </summary>
/// <param name="handler">Change handler</param>
/// <returns>Subscription token</returns>
IDisposable SubscribeToChanges(Action<AirAppAppearanceSnapshot> handler);
}

View File

@@ -0,0 +1,58 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Context provided to an AirApp desktop component instance.
/// </summary>
public interface IAirAppComponentContext
{
/// <summary>
/// Gets the component identifier.
/// </summary>
string ComponentId { get; }
/// <summary>
/// Gets the unique placement identifier for this component instance.
/// </summary>
string PlacementId { get; }
/// <summary>
/// Gets the current width in grid cells.
/// </summary>
int Width { get; }
/// <summary>
/// Gets the current height in grid cells.
/// </summary>
int Height { get; }
/// <summary>
/// Gets the service provider for this component.
/// </summary>
IServiceProvider Services { get; }
/// <summary>
/// Gets the appearance context.
/// </summary>
IAirAppAppearanceContext Appearance { get; }
/// <summary>
/// Request a window to be opened.
/// </summary>
/// <param name="windowId">Window identifier</param>
Task OpenWindowAsync(string windowId);
/// <summary>
/// Send a message to other components or AirApps.
/// </summary>
/// <param name="topic">Message topic</param>
/// <param name="payload">Message payload</param>
void SendMessage(string topic, object? payload = null);
/// <summary>
/// Subscribe to messages.
/// </summary>
/// <param name="topic">Message topic</param>
/// <param name="handler">Message handler</param>
/// <returns>Subscription token for unsubscribing</returns>
IDisposable Subscribe(string topic, Action<object?> handler);
}

View File

@@ -0,0 +1,37 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Logger interface for AirApps.
/// </summary>
public interface IAirAppLogger
{
/// <summary>
/// Log a debug message.
/// </summary>
void Debug(string message);
/// <summary>
/// Log an informational message.
/// </summary>
void Info(string message);
/// <summary>
/// Log a warning message.
/// </summary>
void Warn(string message);
/// <summary>
/// Log a warning with exception.
/// </summary>
void Warn(string message, Exception exception);
/// <summary>
/// Log an error message.
/// </summary>
void Error(string message);
/// <summary>
/// Log an error with exception.
/// </summary>
void Error(string message, Exception exception);
}

View File

@@ -0,0 +1,31 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Message bus for inter-AirApp communication.
/// </summary>
public interface IAirAppMessageBus
{
/// <summary>
/// Publish a message to a topic.
/// </summary>
/// <param name="topic">Message topic</param>
/// <param name="payload">Message payload</param>
void Publish(string topic, object? payload = null);
/// <summary>
/// Subscribe to a topic.
/// </summary>
/// <param name="topic">Message topic</param>
/// <param name="handler">Message handler</param>
/// <returns>Subscription token</returns>
IDisposable Subscribe(string topic, Action<object?> handler);
/// <summary>
/// Subscribe to a topic with typed payload.
/// </summary>
/// <typeparam name="T">Payload type</typeparam>
/// <param name="topic">Message topic</param>
/// <param name="handler">Typed message handler</param>
/// <returns>Subscription token</returns>
IDisposable Subscribe<T>(string topic, Action<T?> handler);
}

View File

@@ -0,0 +1,91 @@
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Provides runtime context and services for an AirApp.
/// </summary>
public interface IAirAppRuntimeContext
{
/// <summary>
/// Gets the unique identifier of this AirApp.
/// </summary>
string AirAppId { get; }
/// <summary>
/// Gets the display name of this AirApp.
/// </summary>
string AirAppName { get; }
/// <summary>
/// Gets the AirApp version.
/// </summary>
string AirAppVersion { get; }
/// <summary>
/// Gets the data directory for this AirApp.
/// Use this directory to store persistent user data.
/// </summary>
string DataDirectory { get; }
/// <summary>
/// Gets the cache directory for this AirApp.
/// Use this directory to store temporary cached data.
/// </summary>
string CacheDirectory { get; }
/// <summary>
/// Gets the service provider for dependency injection.
/// </summary>
IServiceProvider Services { get; }
/// <summary>
/// Gets the host application lifetime manager.
/// </summary>
IHostApplicationLifetime Lifetime { get; }
/// <summary>
/// Gets the message bus for inter-AirApp communication.
/// </summary>
IAirAppMessageBus MessageBus { get; }
/// <summary>
/// Gets the appearance context for theme and styling.
/// </summary>
IAirAppAppearanceContext Appearance { get; }
/// <summary>
/// Gets the logger for this AirApp.
/// </summary>
IAirAppLogger Logger { get; }
/// <summary>
/// Opens a window defined by this AirApp.
/// </summary>
/// <param name="windowId">Window identifier</param>
/// <returns>The opened window instance</returns>
Task<IAirAppWindow> OpenWindowAsync(string windowId);
/// <summary>
/// Closes a window by its identifier.
/// </summary>
/// <param name="windowId">Window identifier</param>
void CloseWindow(string windowId);
/// <summary>
/// Register a desktop component (internal use by AirAppBase).
/// </summary>
void RegisterComponent(AirAppComponentOptions options);
/// <summary>
/// Register a window (internal use by AirAppBase).
/// </summary>
void RegisterWindow(string id, string name, Type windowType);
/// <summary>
/// Register a service (internal use by AirAppBase).
/// </summary>
void RegisterService<TService, TImplementation>()
where TService : class
where TImplementation : class, TService;
}

View File

@@ -0,0 +1,29 @@
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Interface for AirApp desktop component widgets.
/// </summary>
public interface IAirAppWidget
{
/// <summary>
/// Gets or sets the component context.
/// Set by the host when the widget is created.
/// </summary>
IAirAppComponentContext Context { get; set; }
/// <summary>
/// Called when the widget is attached to the desktop.
/// </summary>
void OnAttached();
/// <summary>
/// Called when the widget is detached from the desktop.
/// </summary>
void OnDetached();
/// <summary>
/// Called when the appearance (theme) has changed.
/// </summary>
/// <param name="snapshot">New appearance snapshot</param>
void OnAppearanceChanged(AirAppAppearanceSnapshot snapshot);
}

View File

@@ -0,0 +1,36 @@
using Avalonia.Controls;
namespace LanMountainDesktop.AirAppSdk;
/// <summary>
/// Interface for AirApp windows.
/// </summary>
public interface IAirAppWindow
{
/// <summary>
/// Gets the window descriptor (configuration).
/// </summary>
AirAppWindowDescriptor Descriptor { get; }
/// <summary>
/// Called before the window is opened.
/// Use this for async initialization.
/// </summary>
Task OnWindowOpeningAsync();
/// <summary>
/// Called after the window has been opened.
/// </summary>
void OnWindowOpened();
/// <summary>
/// Called when the window is closing.
/// Set e.Cancel = true to prevent closing.
/// </summary>
void OnWindowClosing(WindowClosingEventArgs e);
/// <summary>
/// Called after the window has been closed.
/// </summary>
void OnWindowClosed();
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Package metadata -->
<PackageId>LanMountainDesktop.AirAppSdk</PackageId>
<Version>6.0.0</Version>
<Authors>LanMountainDesktop Team</Authors>
<Description>Official SDK for developing AirApps (Lightweight Applications) for LanMountainDesktop</Description>
<PackageTags>lanmountaindesktop;airapp;sdk;plugin;avalonia</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/LanMountain/LanMountainDesktop</RepositoryUrl>
<!-- Build transitive: include packaging targets in consuming projects -->
<BuildTransitive>true</BuildTransitive>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
<PackageReference Include="Avalonia" Version="11.2.2" />
<PackageReference Include="Avalonia.Controls" Version="11.2.2" />
</ItemGroup>
<!-- Build targets for .laapp packaging -->
<ItemGroup>
<None Include="build\**" Pack="true" PackagePath="build\" />
<None Include="buildTransitive\**" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,363 @@
# LanMountainDesktop.AirAppSdk
Official SDK for developing AirApps (Lightweight Applications) for LanMountainDesktop.
## What is an AirApp?
AirApp is the next-generation application framework for LanMountainDesktop. It provides a unified development experience for creating:
- **Desktop Components** - Widgets that live on the desktop
- **Window Applications** - Standalone windowed apps
- **Background Services** - Services that run in the background
- **Hybrid Apps** - Apps that combine multiple modes
## Quick Start
### Installation
```bash
# Install the SDK package
dotnet add package LanMountainDesktop.AirAppSdk
```
### Create Your First AirApp
1. **Create a new project**
```bash
dotnet new classlib -n MyFirstAirApp
cd MyFirstAirApp
dotnet add package LanMountainDesktop.AirAppSdk
```
2. **Create the entry point**
```csharp
using LanMountainDesktop.AirAppSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MyFirstAirApp;
[AirAppEntrance]
public class MyAirApp : AirAppBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// Register a desktop component
services.AddAirAppComponent<MyWidget>("my-widget", "My Widget");
}
}
```
3. **Create a widget**
```csharp
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.AirAppSdk;
namespace MyFirstAirApp;
public class MyWidget : AirAppWidgetBase
{
public MyWidget()
{
InitializeComponent();
}
private void InitializeComponent()
{
// Simple widget with a TextBlock
Content = new TextBlock
{
Text = "Hello from AirApp!",
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
}
protected override void OnAttachedCore()
{
// Called when widget is added to desktop
Context.Logger.Info("My widget attached!");
}
}
```
4. **Create manifest file** (`airapp.json`)
```json
{
"id": "com.example.myfirstairapp",
"name": "My First AirApp",
"version": "1.0.0",
"apiVersion": "6.0.0",
"author": "Your Name",
"description": "My first AirApp for LanMountainDesktop",
"entranceAssembly": "MyFirstAirApp.dll",
"runtime": {
"mode": "in-process"
}
}
```
5. **Build the project**
```bash
dotnet build -c Release
```
This will produce a `.laapp` package in `bin/Release/net10.0/MyFirstAirApp.laapp`.
## Core Concepts
### AirAppBase
The entry point for your AirApp. Override `Initialize()` to register components and services:
```csharp
[AirAppEntrance]
public class MyAirApp : AirAppBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// Register components
services.AddAirAppComponent<MyWidget>("widget-id", "Widget Name");
// Register windows
services.AddAirAppWindow<MyWindow>("window-id", "Window Name");
// Register your services
services.AddSingleton<IMyService, MyService>();
}
public override async Task OnStartedAsync(IAirAppRuntimeContext context)
{
// Runtime initialization
context.Logger.Info("AirApp started!");
}
}
```
### Desktop Components
Create widgets that appear on the desktop:
```csharp
public class ClockWidget : AirAppWidgetBase
{
private TextBlock _timeText;
public ClockWidget()
{
_timeText = new TextBlock();
Content = _timeText;
// Update every second
DispatcherTimer.Run(() =>
{
_timeText.Text = DateTime.Now.ToString("HH:mm:ss");
return true;
}, TimeSpan.FromSeconds(1));
}
protected override void OnAppearanceChangedCore(AirAppAppearanceSnapshot snapshot)
{
// Respond to theme changes
_timeText.Foreground = new SolidColorBrush(snapshot.ForegroundColor);
}
}
```
### Windows
Create standalone windows:
```csharp
public class MyWindow : AirAppWindowBase
{
public override AirAppWindowDescriptor Descriptor => new()
{
Title = "My Window",
Width = 800,
Height = 600,
ChromeMode = AirAppWindowChromeMode.Standard,
CanResize = true
};
public MyWindow()
{
Content = new TextBlock { Text = "Hello from window!" };
}
public override async Task OnWindowOpeningAsync()
{
// Async initialization before window opens
await LoadDataAsync();
}
}
```
### Runtime Context
Access runtime services:
```csharp
protected override async Task OnStartedAsync(IAirAppRuntimeContext context)
{
// Get data directories
var dataDir = context.DataDirectory;
var cacheDir = context.CacheDirectory;
// Use services
var myService = context.Services.GetService<IMyService>();
// Log messages
context.Logger.Info("AirApp started!");
// Open a window
await context.OpenWindowAsync("my-window");
// Subscribe to messages
context.MessageBus.Subscribe("theme-changed", payload =>
{
context.Logger.Info("Theme changed!");
});
}
```
## API Reference
### Core Interfaces
- `IAirApp` - AirApp entry point
- `IAirAppWidget` - Desktop component widget
- `IAirAppWindow` - Window application
- `IAirAppRuntimeContext` - Runtime services and context
- `IAirAppComponentContext` - Component instance context
### Base Classes
- `AirAppBase` - Base implementation of IAirApp
- `AirAppWidgetBase` - Base class for widgets
- `AirAppWindowBase` - Base class for windows
### Configuration
- `AirAppManifest` - Manifest file structure
- `AirAppComponentOptions` - Component registration options
- `AirAppWindowDescriptor` - Window configuration
- `AirAppRuntimeMode` - Runtime isolation modes
### Services
- `IAirAppLogger` - Logging service
- `IAirAppMessageBus` - Inter-app messaging
- `IAirAppAppearanceContext` - Theme and appearance
## Runtime Modes
### In-Process (Default)
Best performance, runs in the host process:
```json
{
"runtime": {
"mode": "in-process"
}
}
```
### Isolated Background
Runs in a separate background process:
```json
{
"runtime": {
"mode": "isolated-background"
}
}
```
### Isolated Window
Runs in a completely isolated window process:
```json
{
"runtime": {
"mode": "isolated-window"
}
}
```
## Packaging
Your AirApp is automatically packaged as a `.laapp` file when you build:
```bash
dotnet build -c Release
```
The package includes:
- All assemblies
- The `airapp.json` manifest
- Any additional resources
## Migration from Plugin SDK v5
If you're migrating from the older Plugin SDK:
1. Update package reference:
```xml
<!-- Old -->
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="5.0.0" />
<!-- New -->
<PackageReference Include="LanMountainDesktop.AirAppSdk" Version="6.0.0" />
```
2. Update manifest file: `plugin.json` → `airapp.json`
3. Update namespaces:
```csharp
// Old
using LanMountainDesktop.PluginSdk;
[PluginEntrance]
public class Plugin : PluginBase { }
// New
using LanMountainDesktop.AirAppSdk;
[AirAppEntrance]
public class MyAirApp : AirAppBase { }
```
4. Update API calls (mostly compatible, minor naming changes)
## Examples
See the `samples/` directory for complete examples:
- **SimpleWidget** - Basic desktop component
- **ClockWidget** - Time display with auto-update
- **WindowApp** - Standalone window application
- **HybridApp** - Component + window combination
## Documentation
- [Full API Documentation](https://docs.lanmountain.com/airapp-sdk)
- [Development Guide](https://docs.lanmountain.com/airapp-dev-guide)
- [Best Practices](https://docs.lanmountain.com/airapp-best-practices)
## Support
- GitHub Issues: https://github.com/LanMountain/LanMountainDesktop/issues
- Discord: https://discord.gg/lanmountain
- Documentation: https://docs.lanmountain.com
## License
MIT License - See LICENSE file for details

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageType>Template</PackageType>
<PackageVersion>6.0.0</PackageVersion>
<PackageId>LanMountainDesktop.AirAppTemplate</PackageId>
<Title>LanMountainDesktop AirApp Templates</Title>
<Authors>LanMountainDesktop Team</Authors>
<Description>Project templates for creating AirApps for LanMountainDesktop</Description>
<PackageTags>templates;lanmountaindesktop;airapp;dotnet-new</PackageTags>
<TargetFramework>net10.0</TargetFramework>
<IncludeContentInPack>true</IncludeContentInPack>
<IncludeBuildOutput>false</IncludeBuildOutput>
<ContentTargetFolders>content</ContentTargetFolders>
<NoWarn>$(NoWarn);NU5128</NoWarn>
<NoDefaultExcludes>true</NoDefaultExcludes>
</PropertyGroup>
<ItemGroup>
<Content Include="templates\**\*" Exclude="templates\**\bin\**;templates\**\obj\**" />
<Compile Remove="**\*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,37 @@
{
"$schema": "http://json.schemastore.org/template",
"author": "LanMountainDesktop Team",
"classifications": ["LanMountainDesktop", "AirApp", "Component"],
"identity": "LanMountainDesktop.AirApp.Component",
"name": "LanMountainDesktop AirApp - Desktop Component",
"shortName": "lmd-airapp-component",
"tags": {
"language": "C#",
"type": "project"
},
"sourceName": "LanMountainDesktop.AirApp.ComponentTemplate",
"preferNameDirectory": true,
"symbols": {
"ComponentId": {
"type": "parameter",
"datatype": "string",
"defaultValue": "my-widget",
"replaces": "my-widget",
"description": "The unique identifier for the component"
},
"ComponentName": {
"type": "parameter",
"datatype": "string",
"defaultValue": "My Widget",
"replaces": "My Widget",
"description": "The display name for the component"
},
"AuthorName": {
"type": "parameter",
"datatype": "string",
"defaultValue": "Your Name",
"replaces": "Your Name",
"description": "The author name"
}
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.AirAppSdk" Version="6.0.0" />
</ItemGroup>
<!-- Include airapp.json in output -->
<ItemGroup>
<None Update="airapp.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
using LanMountainDesktop.AirAppSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.AirApp.ComponentTemplate;
/// <summary>
/// AirApp entry point.
/// </summary>
[AirAppEntrance]
public sealed class MyAirApp : AirAppBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// Register the desktop component
services.AddAirAppComponent<MyWidget>(
"my-widget",
"My Widget",
options =>
{
options.Description = "A sample desktop component";
options.DefaultWidth = 2;
options.DefaultHeight = 2;
options.ResizeMode = AirAppComponentResizeMode.Both;
options.Category = "Custom";
options.IconKey = "AppGeneric";
});
}
public override Task OnStartedAsync(IAirAppRuntimeContext context)
{
context.Logger.Info("My AirApp started successfully!");
return Task.CompletedTask;
}
public override Task OnStoppingAsync()
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,81 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.AirAppSdk;
namespace LanMountainDesktop.AirApp.ComponentTemplate;
/// <summary>
/// Desktop component widget implementation.
/// </summary>
public sealed class MyWidget : AirAppWidgetBase
{
private readonly TextBlock _titleText;
private readonly TextBlock _timeText;
private readonly DispatcherTimer _timer;
public MyWidget()
{
// Create UI
_titleText = new TextBlock
{
Text = "My Widget",
FontSize = 16,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center
};
_timeText = new TextBlock
{
FontSize = 24,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
var panel = new StackPanel
{
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
panel.Children.Add(_titleText);
panel.Children.Add(_timeText);
Content = panel;
// Setup timer to update time
_timer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_timer.Tick += (s, e) => UpdateTime();
}
protected override void OnAttachedCore()
{
Context.Logger.Info($"Widget attached: {Context.ComponentId} at {Context.PlacementId}");
UpdateTime();
_timer.Start();
}
protected override void OnDetachedCore()
{
Context.Logger.Info($"Widget detached: {Context.ComponentId}");
_timer.Stop();
}
protected override void OnAppearanceChangedCore(AirAppAppearanceSnapshot snapshot)
{
// Respond to theme changes
_titleText.Foreground = new SolidColorBrush(snapshot.ForegroundColor);
_timeText.Foreground = new SolidColorBrush(snapshot.AccentColor);
Context.Logger.Info($"Appearance changed: DarkMode={snapshot.IsDarkMode}");
}
private void UpdateTime()
{
_timeText.Text = DateTime.Now.ToString("HH:mm:ss");
}
}

View File

@@ -0,0 +1,31 @@
# LanMountainDesktop.AirApp.ComponentTemplate
A desktop component AirApp for LanMountainDesktop.
## Build
```bash
dotnet build -c Release
```
This will produce a `.laapp` package in `bin/Release/net10.0/`.
## Install
Copy the `.laapp` file to LanMountainDesktop's plugins directory or install via the AirApp Market.
## Development
To test your component during development:
1. Build the project
2. Run LanMountainDesktop with debug mode:
```bash
dotnet run --project path/to/LanMountainDesktop.csproj -- --debug-airapp path/to/your/bin/Debug/net10.0
```
## Customize
- Edit `MyWidget.cs` to modify the component UI and behavior
- Edit `airapp.json` to change metadata
- Add more components by creating additional widget classes and registering them in `MyAirApp.cs`

View File

@@ -0,0 +1,21 @@
{
"id": "com.example.LanMountainDesktop.AirApp.ComponentTemplate",
"name": "LanMountainDesktop.AirApp.ComponentTemplate",
"version": "1.0.0",
"apiVersion": "6.0.0",
"author": "Your Name",
"description": "A desktop component AirApp for LanMountainDesktop",
"entranceAssembly": "LanMountainDesktop.AirApp.ComponentTemplate.dll",
"runtime": {
"mode": "in-process",
"capabilities": ["desktop-component"]
},
"components": [
{
"id": "my-widget",
"name": "My Widget",
"defaultWidth": 2,
"defaultHeight": 2
}
]
}