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