diff --git a/LanMountainDesktop.AirAppDevServer/AirAppDevServer.cs b/LanMountainDesktop.AirAppDevServer/AirAppDevServer.cs new file mode 100644 index 0000000..743cda3 --- /dev/null +++ b/LanMountainDesktop.AirAppDevServer/AirAppDevServer.cs @@ -0,0 +1,230 @@ +using System.Diagnostics; +using Microsoft.Build.Locator; +using Microsoft.Build.Execution; + +namespace LanMountainDesktop.AirAppDevServer; + +/// +/// AirApp 开发服务器 +/// 提供文件监视、自动编译、热重载功能 +/// +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}"); + } + } +} diff --git a/LanMountainDesktop.AirAppDevServer/AirAppPackager.cs b/LanMountainDesktop.AirAppDevServer/AirAppPackager.cs new file mode 100644 index 0000000..5bc44af --- /dev/null +++ b/LanMountainDesktop.AirAppDevServer/AirAppPackager.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using System.IO.Compression; + +namespace LanMountainDesktop.AirAppDevServer; + +/// +/// AirApp 打包工具 +/// 将 AirApp 项目打包为 .laapp 文件 +/// +public sealed class AirAppPackager +{ + private readonly string _projectPath; + + public AirAppPackager(string projectPath) + { + _projectPath = Path.GetFullPath(projectPath); + } + + public async Task 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 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; + } +} diff --git a/LanMountainDesktop.AirAppDevServer/AirAppPreviewer.cs b/LanMountainDesktop.AirAppDevServer/AirAppPreviewer.cs new file mode 100644 index 0000000..dfeb706 --- /dev/null +++ b/LanMountainDesktop.AirAppDevServer/AirAppPreviewer.cs @@ -0,0 +1,129 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace LanMountainDesktop.AirAppDevServer; + +/// +/// AirApp 预览工具 +/// 在独立窗口中预览组件或窗口,无需安装到宿主 +/// +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 "); + Console.WriteLine(" airapp-dev preview --window "); + } + + 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 "); + + await Task.CompletedTask; + } + + private async Task LoadManifestAsync() + { + var manifestPath = Path.Combine(_projectPath, "airapp.json"); + if (!File.Exists(manifestPath)) + { + return null; + } + + var json = await File.ReadAllTextAsync(manifestPath); + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + + private sealed class ManifestModel + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public List? Components { get; set; } + public List? 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; } = ""; + } +} diff --git a/LanMountainDesktop.AirAppDevServer/LanMountainDesktop.AirAppDevServer.csproj b/LanMountainDesktop.AirAppDevServer/LanMountainDesktop.AirAppDevServer.csproj new file mode 100644 index 0000000..768461a --- /dev/null +++ b/LanMountainDesktop.AirAppDevServer/LanMountainDesktop.AirAppDevServer.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + enable + enable + preview + + + + + + + + + + + + + diff --git a/LanMountainDesktop.AirAppDevServer/Program.cs b/LanMountainDesktop.AirAppDevServer/Program.cs new file mode 100644 index 0000000..c301e0d --- /dev/null +++ b/LanMountainDesktop.AirAppDevServer/Program.cs @@ -0,0 +1,149 @@ +using System.CommandLine; +using System.Diagnostics; + +namespace LanMountainDesktop.AirAppDevServer; + +/// +/// AirApp 开发服务器主程序 +/// 提供热重载、实时预览等开发功能 +/// +class Program +{ + static async Task Main(string[] args) + { + var rootCommand = new RootCommand("LanMountainDesktop AirApp 开发服务器"); + + // 开发模式命令 + var devCommand = new Command("dev", "启动开发服务器(支持热重载)"); + var projectPathOption = new Option( + aliases: new[] { "--project", "-p" }, + description: "AirApp 项目路径", + getDefaultValue: () => Directory.GetCurrentDirectory()); + var portOption = new Option( + aliases: new[] { "--port" }, + description: "开发服务器端口", + getDefaultValue: () => 5000); + var verboseOption = new Option( + 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( + aliases: new[] { "--component", "-c" }, + description: "要预览的组件 ID"); + var windowOption = new Option( + 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( + 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}"); + } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppAppearanceSnapshot.cs b/LanMountainDesktop.AirAppSdk/AirAppAppearanceSnapshot.cs new file mode 100644 index 0000000..5522802 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppAppearanceSnapshot.cs @@ -0,0 +1,49 @@ +using Avalonia.Media; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Snapshot of the current appearance settings. +/// +public sealed class AirAppAppearanceSnapshot +{ + /// + /// Gets whether dark mode is enabled. + /// + public bool IsDarkMode { get; init; } + + /// + /// Gets the primary accent color. + /// + public Color AccentColor { get; init; } + + /// + /// Gets the glass effect opacity (0.0 - 1.0). + /// + public double GlassOpacity { get; init; } + + /// + /// Gets the corner radius preset. + /// + public AirAppCornerRadiusPreset CornerRadiusPreset { get; init; } + + /// + /// Gets the background color. + /// + public Color BackgroundColor { get; init; } + + /// + /// Gets the foreground (text) color. + /// + public Color ForegroundColor { get; init; } + + /// + /// Gets the border color. + /// + public Color BorderColor { get; init; } + + /// + /// Gets additional custom properties. + /// + public IReadOnlyDictionary? CustomProperties { get; init; } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppBase.cs b/LanMountainDesktop.AirAppSdk/AirAppBase.cs new file mode 100644 index 0000000..482db30 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppBase.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Base class for AirApp implementations. +/// Inherit from this class and apply the [AirAppEntrance] attribute. +/// +public abstract class AirAppBase : IAirApp +{ + /// + /// Gets the runtime context after the AirApp has started. + /// Available after OnStartedAsync is called. + /// + protected IAirAppRuntimeContext? RuntimeContext { get; private set; } + + /// + /// Initialize the AirApp and register services. + /// Override this method to register your components, windows, and services. + /// + /// Host builder context + /// Service collection + public virtual void Initialize(HostBuilderContext context, IServiceCollection services) + { + // Default implementation: do nothing + // Derived classes can override to register services + } + + /// + /// Called after the host application has started. + /// Override this for runtime initialization. + /// + /// AirApp runtime context + public virtual Task OnStartedAsync(IAirAppRuntimeContext context) + { + RuntimeContext = context; + return Task.CompletedTask; + } + + /// + /// Called when the host application is stopping. + /// Override this for cleanup logic. + /// + public virtual Task OnStoppingAsync() + { + return Task.CompletedTask; + } + + /// + /// Register a desktop component widget. + /// + /// Widget implementation type + /// Unique component identifier + /// Display name + /// Optional configuration + protected void RegisterComponent( + string id, + string name, + Action? 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); + } + + /// + /// Register a window. + /// + /// Window implementation type + /// Unique window identifier + /// Display name + protected void RegisterWindow(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)); + } + + /// + /// Register a service in the DI container. + /// + /// Service interface + /// Implementation type + protected void RegisterService() + 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(); + } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppComponentOptions.cs b/LanMountainDesktop.AirAppSdk/AirAppComponentOptions.cs new file mode 100644 index 0000000..8fd3560 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppComponentOptions.cs @@ -0,0 +1,61 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Options for registering an AirApp desktop component. +/// +public sealed class AirAppComponentOptions +{ + /// + /// Gets or sets the unique component identifier. + /// + public required string Id { get; set; } + + /// + /// Gets or sets the display name. + /// + public required string Name { get; set; } + + /// + /// Gets or sets the widget implementation type. + /// Must implement IAirAppWidget. + /// + public required Type WidgetType { get; set; } + + /// + /// Gets or sets the optional description. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the default width in grid cells. + /// Default is 2. + /// + public int DefaultWidth { get; set; } = 2; + + /// + /// Gets or sets the default height in grid cells. + /// Default is 2. + /// + public int DefaultHeight { get; set; } = 2; + + /// + /// Gets or sets the resize mode. + /// + public AirAppComponentResizeMode ResizeMode { get; set; } = AirAppComponentResizeMode.Both; + + /// + /// Gets or sets whether this component can be added multiple times. + /// Default is true. + /// + public bool AllowMultipleInstances { get; set; } = true; + + /// + /// Gets or sets the category for grouping in the component library. + /// + public string? Category { get; set; } + + /// + /// Gets or sets the icon identifier. + /// + public string? IconKey { get; set; } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppComponentResizeMode.cs b/LanMountainDesktop.AirAppSdk/AirAppComponentResizeMode.cs new file mode 100644 index 0000000..c8feec4 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppComponentResizeMode.cs @@ -0,0 +1,27 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Resize mode for AirApp desktop components. +/// +public enum AirAppComponentResizeMode +{ + /// + /// Cannot be resized. + /// + None = 0, + + /// + /// Can be resized horizontally only. + /// + Horizontal = 1, + + /// + /// Can be resized vertically only. + /// + Vertical = 2, + + /// + /// Can be resized in both directions. + /// + Both = 3 +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppCornerRadiusPreset.cs b/LanMountainDesktop.AirAppSdk/AirAppCornerRadiusPreset.cs new file mode 100644 index 0000000..828ae49 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppCornerRadiusPreset.cs @@ -0,0 +1,32 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Corner radius presets. +/// +public enum AirAppCornerRadiusPreset +{ + /// + /// No rounded corners. + /// + None = 0, + + /// + /// Small corner radius (4px). + /// + Small = 1, + + /// + /// Medium corner radius (8px). + /// + Medium = 2, + + /// + /// Large corner radius (12px). + /// + Large = 3, + + /// + /// Extra large corner radius (16px). + /// + ExtraLarge = 4 +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppEntranceAttribute.cs b/LanMountainDesktop.AirAppSdk/AirAppEntranceAttribute.cs new file mode 100644 index 0000000..05fea07 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppEntranceAttribute.cs @@ -0,0 +1,10 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Marks a class as the entry point for an AirApp. +/// The marked class must inherit from AirAppBase or implement IAirApp. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class AirAppEntranceAttribute : Attribute +{ +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppManifest.cs b/LanMountainDesktop.AirAppSdk/AirAppManifest.cs new file mode 100644 index 0000000..662fa25 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppManifest.cs @@ -0,0 +1,188 @@ +using System.Text.Json; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// AirApp manifest (airapp.json). +/// +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? Components = null, + IReadOnlyList? Windows = null, + IReadOnlyList? Permissions = null, + IReadOnlyList? SharedContracts = null) +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + /// + /// Load manifest from file. + /// + public static AirAppManifest Load(string manifestPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); + + using var stream = File.OpenRead(manifestPath); + return Load(stream, manifestPath); + } + + /// + /// Load manifest from stream. + /// + public static AirAppManifest Load(Stream stream, string sourceName) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceName); + + var manifest = JsonSerializer.Deserialize(stream, SerializerOptions); + if (manifest is null) + { + throw new InvalidOperationException($"Failed to deserialize AirApp manifest '{sourceName}'."); + } + + return manifest.NormalizeAndValidate(sourceName); + } + + /// + /// Resolve entrance assembly path. + /// + 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)); + } + + /// + /// Get runtime mode. + /// + 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(), + Windows = Windows ?? Array.Empty(), + Permissions = Permissions ?? Array.Empty(), + SharedContracts = SharedContracts ?? Array.Empty() + }; + + // 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(); + } +} + +/// +/// Component declaration in manifest. +/// +public sealed record AirAppComponentManifest( + string Id, + string Name, + int DefaultWidth = 2, + int DefaultHeight = 2, + string? Description = null, + string? Category = null, + string? IconKey = null); + +/// +/// Window declaration in manifest. +/// +public sealed record AirAppWindowManifest( + string Id, + string Name, + double DefaultWidth = 800, + double DefaultHeight = 600, + string? Description = null); + +/// +/// Shared contract reference. +/// +public sealed record AirAppSharedContractReference( + string Id, + string Version); + +/// +/// Runtime configuration. +/// +public sealed record AirAppRuntimeConfiguration +{ + public string? Mode { get; init; } + public IReadOnlyList? Capabilities { get; init; } + + internal AirAppRuntimeConfiguration NormalizeAndValidate(string manifestPath) + { + return this with + { + Mode = string.IsNullOrWhiteSpace(Mode) ? "in-process" : Mode.Trim().ToLowerInvariant(), + Capabilities = Capabilities ?? Array.Empty() + }; + } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppRuntimeMode.cs b/LanMountainDesktop.AirAppSdk/AirAppRuntimeMode.cs new file mode 100644 index 0000000..dfba50f --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppRuntimeMode.cs @@ -0,0 +1,53 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Runtime mode for AirApps. +/// +public enum AirAppRuntimeMode +{ + /// + /// Run in the host process (best performance, shared memory). + /// + InProcess = 0, + + /// + /// Run in an isolated background process (safer, separate memory). + /// + IsolatedBackground = 1, + + /// + /// Run in an isolated window process (full isolation). + /// + IsolatedWindow = 2 +} + +/// +/// Helper for parsing runtime modes. +/// +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; + } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppSdkInfo.cs b/LanMountainDesktop.AirAppSdk/AirAppSdkInfo.cs new file mode 100644 index 0000000..cbbacdb --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppSdkInfo.cs @@ -0,0 +1,33 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// AirApp SDK information. +/// +public static class AirAppSdkInfo +{ + /// + /// Current SDK version. + /// + public const string SdkVersion = "6.0.0"; + + /// + /// Current API version. + /// AirApps must target this major version to be compatible. + /// + public const string ApiVersion = "6.0.0"; + + /// + /// Gets the SDK display name. + /// + public static string DisplayName => "LanMountainDesktop AirApp SDK"; + + /// + /// Gets the default manifest file name. + /// + public const string ManifestFileName = "airapp.json"; + + /// + /// Gets the package file extension. + /// + public const string PackageExtension = ".laapp"; +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppServiceCollectionExtensions.cs b/LanMountainDesktop.AirAppSdk/AirAppServiceCollectionExtensions.cs new file mode 100644 index 0000000..827d7b9 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppServiceCollectionExtensions.cs @@ -0,0 +1,158 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Extension methods for registering AirApp services. +/// +public static class AirAppServiceCollectionExtensions +{ + /// + /// Register a desktop component. + /// + public static IServiceCollection AddAirAppComponent( + this IServiceCollection services, + string id, + string name, + Action? 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(); + + // Register the component options (will be picked up by the host) + services.AddSingleton(options); + + return services; + } + + /// + /// Register a window. + /// + public static IServiceCollection AddAirAppWindow( + this IServiceCollection services, + string id, + string name) + where TWindow : class, IAirAppWindow + { + // Register the window as transient (new instance per open) + services.AddTransient(); + + // TODO: Register window metadata + return services; + } + + /// + /// Register a settings section (declarative). + /// + public static IServiceCollection AddAirAppSettings( + this IServiceCollection services, + string id, + string name, + Action? configure = null) + { + var builder = new AirAppSettingsSectionBuilder(id, name); + configure?.Invoke(builder); + + // Register the settings section + services.AddSingleton(builder.Build()); + + return services; + } +} + +/// +/// Builder for settings sections. +/// +public sealed class AirAppSettingsSectionBuilder +{ + private readonly string _id; + private readonly string _name; + private readonly List _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 + }; + } +} + +/// +/// Settings section metadata. +/// +public sealed class AirAppSettingsSection +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required List Options { get; init; } +} + +/// +/// Individual setting option. +/// +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; } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppWidgetBase.cs b/LanMountainDesktop.AirAppSdk/AirAppWidgetBase.cs new file mode 100644 index 0000000..da7a000 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppWidgetBase.cs @@ -0,0 +1,80 @@ +using Avalonia.Controls; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Base class for AirApp desktop component widgets. +/// Inherit from this to create custom desktop components. +/// +public abstract class AirAppWidgetBase : UserControl, IAirAppWidget +{ + private IAirAppComponentContext? _context; + + /// + /// Gets or sets the component context. + /// + public IAirAppComponentContext Context + { + get => _context ?? throw new InvalidOperationException("Context has not been set yet."); + set + { + _context = value; + OnContextSet(); + } + } + + /// + /// Called when the context is first set. + /// Override this to initialize based on context. + /// + protected virtual void OnContextSet() + { + } + + /// + /// Called when the widget is attached to the desktop. + /// + public void OnAttached() + { + OnAttachedCore(); + } + + /// + /// Called when the widget is detached from the desktop. + /// + public void OnDetached() + { + OnDetachedCore(); + } + + /// + /// Called when the appearance has changed. + /// + /// New appearance snapshot + public void OnAppearanceChanged(AirAppAppearanceSnapshot snapshot) + { + OnAppearanceChangedCore(snapshot); + } + + /// + /// Override this to handle widget attachment. + /// + protected virtual void OnAttachedCore() + { + } + + /// + /// Override this to handle widget detachment. + /// + protected virtual void OnDetachedCore() + { + } + + /// + /// Override this to handle appearance changes. + /// + /// New appearance snapshot + protected virtual void OnAppearanceChangedCore(AirAppAppearanceSnapshot snapshot) + { + } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppWindowBase.cs b/LanMountainDesktop.AirAppSdk/AirAppWindowBase.cs new file mode 100644 index 0000000..a5ec528 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppWindowBase.cs @@ -0,0 +1,96 @@ +using Avalonia; +using Avalonia.Controls; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Base class for AirApp windows. +/// +public abstract class AirAppWindowBase : Window, IAirAppWindow +{ + /// + /// Gets the window descriptor. + /// Override this to customize window configuration. + /// + public virtual AirAppWindowDescriptor Descriptor => new() + { + Width = 800, + Height = 600, + MinWidth = 400, + MinHeight = 300, + ChromeMode = AirAppWindowChromeMode.Standard, + CanResize = true, + ShowInTaskbar = true, + ShowAsDialog = false + }; + + /// + /// Initializes a new instance of AirAppWindowBase. + /// + protected AirAppWindowBase() + { + ApplyDescriptor(Descriptor); + } + + /// + /// Called before the window is opened. + /// + public virtual Task OnWindowOpeningAsync() + { + return Task.CompletedTask; + } + + /// + /// Called after the window has been opened. + /// + public virtual void OnWindowOpened() + { + } + + /// + /// Called when the window is closing. + /// + public virtual void OnWindowClosing(WindowClosingEventArgs e) + { + } + + /// + /// Called after the window has been closed. + /// + public virtual void OnWindowClosed() + { + } + + /// + /// Apply the window descriptor configuration. + /// + 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; + } + } +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppWindowChromeMode.cs b/LanMountainDesktop.AirAppSdk/AirAppWindowChromeMode.cs new file mode 100644 index 0000000..505dceb --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppWindowChromeMode.cs @@ -0,0 +1,32 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Window chrome mode for AirApp windows. +/// +public enum AirAppWindowChromeMode +{ + /// + /// Standard window with title bar and borders. + /// + Standard = 0, + + /// + /// Borderless window with custom chrome. + /// + Borderless = 1, + + /// + /// Full-screen window with no decorations. + /// + FullScreen = 2, + + /// + /// Tool window (no taskbar icon, small title bar). + /// + Tool = 3, + + /// + /// Background-only (no UI, reserved for future use). + /// + BackgroundOnly = 4 +} diff --git a/LanMountainDesktop.AirAppSdk/AirAppWindowDescriptor.cs b/LanMountainDesktop.AirAppSdk/AirAppWindowDescriptor.cs new file mode 100644 index 0000000..433970c --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/AirAppWindowDescriptor.cs @@ -0,0 +1,52 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Window configuration descriptor. +/// +public sealed class AirAppWindowDescriptor +{ + /// + /// Gets or sets the window title. + /// + public string Title { get; set; } = "AirApp Window"; + + /// + /// Gets or sets the initial width. + /// + public double Width { get; set; } = 800; + + /// + /// Gets or sets the initial height. + /// + public double Height { get; set; } = 600; + + /// + /// Gets or sets the minimum width. + /// + public double MinWidth { get; set; } = 400; + + /// + /// Gets or sets the minimum height. + /// + public double MinHeight { get; set; } = 300; + + /// + /// Gets or sets the chrome mode. + /// + public AirAppWindowChromeMode ChromeMode { get; set; } = AirAppWindowChromeMode.Standard; + + /// + /// Gets or sets whether the window can be resized. + /// + public bool CanResize { get; set; } = true; + + /// + /// Gets or sets whether the window shows in the taskbar. + /// + public bool ShowInTaskbar { get; set; } = true; + + /// + /// Gets or sets whether the window is modal. + /// + public bool ShowAsDialog { get; set; } = false; +} diff --git a/LanMountainDesktop.AirAppSdk/IAirApp.cs b/LanMountainDesktop.AirAppSdk/IAirApp.cs new file mode 100644 index 0000000..6d05f33 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirApp.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Core interface for AirApp entry point. +/// +public interface IAirApp +{ + /// + /// Initialize the AirApp and register services. + /// Called during host startup before the application is fully running. + /// + /// Host builder context + /// Service collection for dependency injection + void Initialize(HostBuilderContext context, IServiceCollection services); + + /// + /// Called after the host application has started. + /// Use this for initialization that requires runtime services. + /// + /// AirApp runtime context + Task OnStartedAsync(IAirAppRuntimeContext context); + + /// + /// Called when the host application is stopping. + /// Use this for cleanup and resource disposal. + /// + Task OnStoppingAsync(); +} diff --git a/LanMountainDesktop.AirAppSdk/IAirAppAppearanceContext.cs b/LanMountainDesktop.AirAppSdk/IAirAppAppearanceContext.cs new file mode 100644 index 0000000..e156a34 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirAppAppearanceContext.cs @@ -0,0 +1,19 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Provides appearance and theme context. +/// +public interface IAirAppAppearanceContext +{ + /// + /// Gets the current appearance snapshot. + /// + AirAppAppearanceSnapshot CurrentSnapshot { get; } + + /// + /// Subscribe to appearance changes. + /// + /// Change handler + /// Subscription token + IDisposable SubscribeToChanges(Action handler); +} diff --git a/LanMountainDesktop.AirAppSdk/IAirAppComponentContext.cs b/LanMountainDesktop.AirAppSdk/IAirAppComponentContext.cs new file mode 100644 index 0000000..1a1492f --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirAppComponentContext.cs @@ -0,0 +1,58 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Context provided to an AirApp desktop component instance. +/// +public interface IAirAppComponentContext +{ + /// + /// Gets the component identifier. + /// + string ComponentId { get; } + + /// + /// Gets the unique placement identifier for this component instance. + /// + string PlacementId { get; } + + /// + /// Gets the current width in grid cells. + /// + int Width { get; } + + /// + /// Gets the current height in grid cells. + /// + int Height { get; } + + /// + /// Gets the service provider for this component. + /// + IServiceProvider Services { get; } + + /// + /// Gets the appearance context. + /// + IAirAppAppearanceContext Appearance { get; } + + /// + /// Request a window to be opened. + /// + /// Window identifier + Task OpenWindowAsync(string windowId); + + /// + /// Send a message to other components or AirApps. + /// + /// Message topic + /// Message payload + void SendMessage(string topic, object? payload = null); + + /// + /// Subscribe to messages. + /// + /// Message topic + /// Message handler + /// Subscription token for unsubscribing + IDisposable Subscribe(string topic, Action handler); +} diff --git a/LanMountainDesktop.AirAppSdk/IAirAppLogger.cs b/LanMountainDesktop.AirAppSdk/IAirAppLogger.cs new file mode 100644 index 0000000..6528119 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirAppLogger.cs @@ -0,0 +1,37 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Logger interface for AirApps. +/// +public interface IAirAppLogger +{ + /// + /// Log a debug message. + /// + void Debug(string message); + + /// + /// Log an informational message. + /// + void Info(string message); + + /// + /// Log a warning message. + /// + void Warn(string message); + + /// + /// Log a warning with exception. + /// + void Warn(string message, Exception exception); + + /// + /// Log an error message. + /// + void Error(string message); + + /// + /// Log an error with exception. + /// + void Error(string message, Exception exception); +} diff --git a/LanMountainDesktop.AirAppSdk/IAirAppMessageBus.cs b/LanMountainDesktop.AirAppSdk/IAirAppMessageBus.cs new file mode 100644 index 0000000..317d3fb --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirAppMessageBus.cs @@ -0,0 +1,31 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Message bus for inter-AirApp communication. +/// +public interface IAirAppMessageBus +{ + /// + /// Publish a message to a topic. + /// + /// Message topic + /// Message payload + void Publish(string topic, object? payload = null); + + /// + /// Subscribe to a topic. + /// + /// Message topic + /// Message handler + /// Subscription token + IDisposable Subscribe(string topic, Action handler); + + /// + /// Subscribe to a topic with typed payload. + /// + /// Payload type + /// Message topic + /// Typed message handler + /// Subscription token + IDisposable Subscribe(string topic, Action handler); +} diff --git a/LanMountainDesktop.AirAppSdk/IAirAppRuntimeContext.cs b/LanMountainDesktop.AirAppSdk/IAirAppRuntimeContext.cs new file mode 100644 index 0000000..66e785e --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirAppRuntimeContext.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Hosting; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Provides runtime context and services for an AirApp. +/// +public interface IAirAppRuntimeContext +{ + /// + /// Gets the unique identifier of this AirApp. + /// + string AirAppId { get; } + + /// + /// Gets the display name of this AirApp. + /// + string AirAppName { get; } + + /// + /// Gets the AirApp version. + /// + string AirAppVersion { get; } + + /// + /// Gets the data directory for this AirApp. + /// Use this directory to store persistent user data. + /// + string DataDirectory { get; } + + /// + /// Gets the cache directory for this AirApp. + /// Use this directory to store temporary cached data. + /// + string CacheDirectory { get; } + + /// + /// Gets the service provider for dependency injection. + /// + IServiceProvider Services { get; } + + /// + /// Gets the host application lifetime manager. + /// + IHostApplicationLifetime Lifetime { get; } + + /// + /// Gets the message bus for inter-AirApp communication. + /// + IAirAppMessageBus MessageBus { get; } + + /// + /// Gets the appearance context for theme and styling. + /// + IAirAppAppearanceContext Appearance { get; } + + /// + /// Gets the logger for this AirApp. + /// + IAirAppLogger Logger { get; } + + /// + /// Opens a window defined by this AirApp. + /// + /// Window identifier + /// The opened window instance + Task OpenWindowAsync(string windowId); + + /// + /// Closes a window by its identifier. + /// + /// Window identifier + void CloseWindow(string windowId); + + /// + /// Register a desktop component (internal use by AirAppBase). + /// + void RegisterComponent(AirAppComponentOptions options); + + /// + /// Register a window (internal use by AirAppBase). + /// + void RegisterWindow(string id, string name, Type windowType); + + /// + /// Register a service (internal use by AirAppBase). + /// + void RegisterService() + where TService : class + where TImplementation : class, TService; +} diff --git a/LanMountainDesktop.AirAppSdk/IAirAppWidget.cs b/LanMountainDesktop.AirAppSdk/IAirAppWidget.cs new file mode 100644 index 0000000..d7d1210 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirAppWidget.cs @@ -0,0 +1,29 @@ +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Interface for AirApp desktop component widgets. +/// +public interface IAirAppWidget +{ + /// + /// Gets or sets the component context. + /// Set by the host when the widget is created. + /// + IAirAppComponentContext Context { get; set; } + + /// + /// Called when the widget is attached to the desktop. + /// + void OnAttached(); + + /// + /// Called when the widget is detached from the desktop. + /// + void OnDetached(); + + /// + /// Called when the appearance (theme) has changed. + /// + /// New appearance snapshot + void OnAppearanceChanged(AirAppAppearanceSnapshot snapshot); +} diff --git a/LanMountainDesktop.AirAppSdk/IAirAppWindow.cs b/LanMountainDesktop.AirAppSdk/IAirAppWindow.cs new file mode 100644 index 0000000..56e1e51 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/IAirAppWindow.cs @@ -0,0 +1,36 @@ +using Avalonia.Controls; + +namespace LanMountainDesktop.AirAppSdk; + +/// +/// Interface for AirApp windows. +/// +public interface IAirAppWindow +{ + /// + /// Gets the window descriptor (configuration). + /// + AirAppWindowDescriptor Descriptor { get; } + + /// + /// Called before the window is opened. + /// Use this for async initialization. + /// + Task OnWindowOpeningAsync(); + + /// + /// Called after the window has been opened. + /// + void OnWindowOpened(); + + /// + /// Called when the window is closing. + /// Set e.Cancel = true to prevent closing. + /// + void OnWindowClosing(WindowClosingEventArgs e); + + /// + /// Called after the window has been closed. + /// + void OnWindowClosed(); +} diff --git a/LanMountainDesktop.AirAppSdk/LanMountainDesktop.AirAppSdk.csproj b/LanMountainDesktop.AirAppSdk/LanMountainDesktop.AirAppSdk.csproj new file mode 100644 index 0000000..0476e32 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/LanMountainDesktop.AirAppSdk.csproj @@ -0,0 +1,34 @@ + + + net10.0 + enable + enable + preview + true + + + LanMountainDesktop.AirAppSdk + 6.0.0 + LanMountainDesktop Team + Official SDK for developing AirApps (Lightweight Applications) for LanMountainDesktop + lanmountaindesktop;airapp;sdk;plugin;avalonia + MIT + https://github.com/LanMountain/LanMountainDesktop + + + true + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.AirAppSdk/README.md b/LanMountainDesktop.AirAppSdk/README.md new file mode 100644 index 0000000..6763488 --- /dev/null +++ b/LanMountainDesktop.AirAppSdk/README.md @@ -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("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("widget-id", "Widget Name"); + + // Register windows + services.AddAirAppWindow("window-id", "Window Name"); + + // Register your services + services.AddSingleton(); + } + + 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(); + + // 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 + + + + + + ``` + +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 diff --git a/LanMountainDesktop.AirAppTemplate/LanMountainDesktop.AirAppTemplate.csproj b/LanMountainDesktop.AirAppTemplate/LanMountainDesktop.AirAppTemplate.csproj new file mode 100644 index 0000000..9bd0e04 --- /dev/null +++ b/LanMountainDesktop.AirAppTemplate/LanMountainDesktop.AirAppTemplate.csproj @@ -0,0 +1,22 @@ + + + Template + 6.0.0 + LanMountainDesktop.AirAppTemplate + LanMountainDesktop AirApp Templates + LanMountainDesktop Team + Project templates for creating AirApps for LanMountainDesktop + templates;lanmountaindesktop;airapp;dotnet-new + net10.0 + true + false + content + $(NoWarn);NU5128 + true + + + + + + + diff --git a/LanMountainDesktop.AirAppTemplate/templates/component/.template.config/template.json b/LanMountainDesktop.AirAppTemplate/templates/component/.template.config/template.json new file mode 100644 index 0000000..45f9e94 --- /dev/null +++ b/LanMountainDesktop.AirAppTemplate/templates/component/.template.config/template.json @@ -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" + } + } +} diff --git a/LanMountainDesktop.AirAppTemplate/templates/component/LanMountainDesktop.AirApp.ComponentTemplate.csproj b/LanMountainDesktop.AirAppTemplate/templates/component/LanMountainDesktop.AirApp.ComponentTemplate.csproj new file mode 100644 index 0000000..18b968e --- /dev/null +++ b/LanMountainDesktop.AirAppTemplate/templates/component/LanMountainDesktop.AirApp.ComponentTemplate.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + preview + + + + + + + + + + PreserveNewest + + + diff --git a/LanMountainDesktop.AirAppTemplate/templates/component/MyAirApp.cs b/LanMountainDesktop.AirAppTemplate/templates/component/MyAirApp.cs new file mode 100644 index 0000000..2af06dd --- /dev/null +++ b/LanMountainDesktop.AirAppTemplate/templates/component/MyAirApp.cs @@ -0,0 +1,40 @@ +using LanMountainDesktop.AirAppSdk; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LanMountainDesktop.AirApp.ComponentTemplate; + +/// +/// AirApp entry point. +/// +[AirAppEntrance] +public sealed class MyAirApp : AirAppBase +{ + public override void Initialize(HostBuilderContext context, IServiceCollection services) + { + // Register the desktop component + services.AddAirAppComponent( + "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; + } +} diff --git a/LanMountainDesktop.AirAppTemplate/templates/component/MyWidget.cs b/LanMountainDesktop.AirAppTemplate/templates/component/MyWidget.cs new file mode 100644 index 0000000..54e4ef5 --- /dev/null +++ b/LanMountainDesktop.AirAppTemplate/templates/component/MyWidget.cs @@ -0,0 +1,81 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.AirAppSdk; + +namespace LanMountainDesktop.AirApp.ComponentTemplate; + +/// +/// Desktop component widget implementation. +/// +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"); + } +} diff --git a/LanMountainDesktop.AirAppTemplate/templates/component/README.md b/LanMountainDesktop.AirAppTemplate/templates/component/README.md new file mode 100644 index 0000000..7a627ea --- /dev/null +++ b/LanMountainDesktop.AirAppTemplate/templates/component/README.md @@ -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` diff --git a/LanMountainDesktop.AirAppTemplate/templates/component/airapp.json b/LanMountainDesktop.AirAppTemplate/templates/component/airapp.json new file mode 100644 index 0000000..b98f1aa --- /dev/null +++ b/LanMountainDesktop.AirAppTemplate/templates/component/airapp.json @@ -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 + } + ] +}