feat.airapp剥离启动器

This commit is contained in:
lincube
2026-05-31 19:41:10 +08:00
parent 21e970c5b6
commit c351a8e7f3
78 changed files with 1957 additions and 1250 deletions

View File

@@ -0,0 +1,95 @@
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppHostLocator
{
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
private static string ExecutableName => OperatingSystem.IsWindows()
? WindowsExecutableName
: UnixExecutableName;
public string Resolve(string? packageRoot, string? hostPath = null)
{
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
{
if (File.Exists(candidate))
{
return candidate;
}
}
throw new FileNotFoundException("Unable to find LanMountainDesktop.AirAppHost output.");
}
private static IEnumerable<string> EnumerateCandidates(string? packageRoot, string? hostPath)
{
foreach (var root in EnumerateRoots(packageRoot, hostPath))
{
yield return Path.Combine(root, "AirAppHost", ExecutableName);
yield return Path.Combine(root, "AirAppHost", DllName);
yield return Path.Combine(root, ExecutableName);
yield return Path.Combine(root, DllName);
if (Directory.Exists(root))
{
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
{
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
yield return Path.Combine(deploymentDirectory, ExecutableName);
yield return Path.Combine(deploymentDirectory, DllName);
}
}
}
var current = new DirectoryInfo(AppContext.BaseDirectory);
for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent)
{
yield return Path.Combine(
current.FullName,
"LanMountainDesktop.AirAppHost",
"bin",
#if DEBUG
"Debug",
#else
"Release",
#endif
"net10.0",
ExecutableName);
yield return Path.Combine(
current.FullName,
"LanMountainDesktop.AirAppHost",
"bin",
#if DEBUG
"Debug",
#else
"Release",
#endif
"net10.0",
DllName);
}
}
private static IEnumerable<string> EnumerateRoots(string? packageRoot, string? hostPath)
{
if (!string.IsNullOrWhiteSpace(packageRoot))
{
yield return Path.GetFullPath(packageRoot);
}
if (!string.IsNullOrWhiteSpace(hostPath))
{
var hostDirectory = Path.GetDirectoryName(Path.GetFullPath(hostPath));
if (!string.IsNullOrWhiteSpace(hostDirectory))
{
yield return hostDirectory;
}
}
yield return AppContext.BaseDirectory;
yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
}
}

View File

@@ -0,0 +1,22 @@
namespace LanMountainDesktop.AirAppRuntime;
internal static class AirAppInstanceKey
{
public static string Build(string appId, string? sourceComponentId, string? sourcePlacementId)
{
var normalizedAppId = Normalize(appId, "unknown");
if (string.Equals(normalizedAppId, "world-clock", StringComparison.OrdinalIgnoreCase))
{
return $"{normalizedAppId}:clock-suite:global";
}
var normalizedComponentId = Normalize(sourceComponentId, "none");
var normalizedPlacementId = Normalize(sourcePlacementId, "none");
return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}";
}
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
}

View File

@@ -0,0 +1,330 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
{
private readonly object _gate = new();
private readonly IAirAppProcessStarter _processStarter;
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
{
_processStarter = processStarter;
}
public Task<AirAppOperationResult> OpenAsync(AirAppOpenRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var appId = Normalize(request.AppId, "unknown");
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
AirAppRuntimeLogger.Info(
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
lock (_gate)
{
CleanupExitedInstances();
if (_instances.TryGetValue(instanceKey, out var existing) && IsProcessAlive(existing.ProcessId))
{
TryActivateProcess(existing.ProcessId);
existing.Touch();
return Task.FromResult(BuildResult(true, "activated_existing", "Activated existing Air APP instance.", existing));
}
var sessionId = Guid.NewGuid().ToString("N");
try
{
var process = _processStarter.Start(
appId,
sessionId,
instanceKey,
request.SourceComponentId,
request.SourcePlacementId);
if (process is null)
{
return Task.FromResult(BuildResult(false, "start_failed", "AirAppHost process was not created.", null));
}
var instance = new ManagedAirAppInstance(
instanceKey,
appId,
sessionId,
process.Id,
$"{appId} - Air APP",
request.SourceComponentId,
request.SourcePlacementId);
_instances[instanceKey] = instance;
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
}
catch (Exception ex)
{
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
}
}
}
public Task<AirAppOperationResult> ActivateAsync(string instanceKey)
{
lock (_gate)
{
CleanupExitedInstances();
if (!_instances.TryGetValue(instanceKey, out var instance))
{
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
}
var accepted = TryActivateProcess(instance.ProcessId);
instance.Touch();
return Task.FromResult(BuildResult(
accepted,
accepted ? "activated" : "activation_failed",
accepted ? "Air APP instance activated." : "Failed to activate Air APP instance.",
instance));
}
}
public Task<AirAppOperationResult> CloseAsync(string instanceKey)
{
lock (_gate)
{
CleanupExitedInstances();
if (!_instances.TryGetValue(instanceKey, out var instance))
{
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
}
var accepted = TryCloseProcess(instance.ProcessId);
instance.Touch();
return Task.FromResult(BuildResult(
accepted,
accepted ? "close_requested" : "close_failed",
accepted ? "Air APP close requested." : "Failed to request Air APP close.",
instance));
}
}
public Task<AirAppInstanceInfo[]> GetInstancesAsync()
{
lock (_gate)
{
CleanupExitedInstances();
return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray());
}
}
public Task<AirAppOperationResult> RegisterAsync(AirAppRegistrationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
lock (_gate)
{
var instanceKey = string.IsNullOrWhiteSpace(request.InstanceKey)
? AirAppInstanceKey.Build(request.AppId, request.SourceComponentId, request.SourcePlacementId)
: request.InstanceKey.Trim();
var instance = new ManagedAirAppInstance(
instanceKey,
Normalize(request.AppId, "unknown"),
Normalize(request.SessionId, Guid.NewGuid().ToString("N")),
request.ProcessId,
Normalize(request.WindowTitle, $"{request.AppId} - Air APP"),
request.SourceComponentId,
request.SourcePlacementId);
_instances[instanceKey] = instance;
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
}
}
public Task<AirAppOperationResult> UnregisterAsync(string instanceKey, int processId)
{
lock (_gate)
{
if (_instances.TryGetValue(instanceKey, out var instance) &&
(processId <= 0 || instance.ProcessId == processId))
{
_instances.Remove(instanceKey);
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
}
return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
}
}
public bool HasLiveAirApps()
{
lock (_gate)
{
CleanupExitedInstances();
return _instances.Values.Any(static instance => IsProcessAlive(instance.ProcessId));
}
}
private void CleanupExitedInstances()
{
var exitedKeys = _instances
.Where(static pair => !IsProcessAlive(pair.Value.ProcessId))
.Select(static pair => pair.Key)
.ToList();
foreach (var key in exitedKeys)
{
_instances.Remove(key);
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
}
}
private static AirAppOperationResult BuildResult(
bool accepted,
string code,
string message,
ManagedAirAppInstance? instance)
{
return new AirAppOperationResult(accepted, code, message, instance?.ToInfo());
}
private static bool TryActivateProcess(int processId)
{
try
{
using var process = Process.GetProcessById(processId);
if (process.HasExited)
{
return false;
}
if (!OperatingSystem.IsWindows())
{
return true;
}
process.Refresh();
var handle = process.MainWindowHandle;
if (handle == IntPtr.Zero)
{
return true;
}
_ = ShowWindow(handle, SW_SHOWNORMAL);
_ = SetForegroundWindow(handle);
return true;
}
catch
{
return false;
}
}
private static bool TryCloseProcess(int processId)
{
try
{
using var process = Process.GetProcessById(processId);
if (process.HasExited)
{
return false;
}
return process.CloseMainWindow();
}
catch
{
return false;
}
}
internal static bool IsProcessAlive(int processId)
{
if (processId <= 0)
{
return false;
}
try
{
using var process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
return false;
}
}
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private const int SW_SHOWNORMAL = 1;
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
private sealed class ManagedAirAppInstance
{
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
public ManagedAirAppInstance(
string instanceKey,
string appId,
string sessionId,
int processId,
string windowTitle,
string? sourceComponentId,
string? sourcePlacementId)
{
InstanceKey = instanceKey;
AppId = appId;
SessionId = sessionId;
ProcessId = processId;
WindowTitle = windowTitle;
SourceComponentId = sourceComponentId;
SourcePlacementId = sourcePlacementId;
UpdatedAtUtc = _startedAtUtc;
}
public string InstanceKey { get; }
public string AppId { get; }
public string SessionId { get; }
public int ProcessId { get; }
public string WindowTitle { get; }
public string? SourceComponentId { get; }
public string? SourcePlacementId { get; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
public void Touch()
{
UpdatedAtUtc = DateTimeOffset.UtcNow;
}
public AirAppInstanceInfo ToInfo()
{
return new AirAppInstanceInfo(
InstanceKey,
AppId,
SessionId,
ProcessId,
WindowTitle,
SourceComponentId,
SourcePlacementId,
IsProcessAlive(ProcessId),
_startedAtUtc,
UpdatedAtUtc);
}
}
}

View File

@@ -0,0 +1,29 @@
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeControlService : IAirAppRuntimeControlService
{
private readonly AirAppRuntimeLifetime _lifetime;
public AirAppRuntimeControlService(AirAppRuntimeLifetime lifetime)
{
_lifetime = lifetime;
}
public Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
{
_lifetime.AttachHost(hostProcessId);
var status = _lifetime.GetStatus();
return Task.FromResult(new AirAppRuntimeControlResult(
hostProcessId > 0,
hostProcessId > 0 ? "host_attached" : "invalid_host_pid",
hostProcessId > 0 ? "AirApp runtime host process attached." : "Host process id must be positive.",
status));
}
public Task<AirAppRuntimeStatus> GetStatusAsync()
{
return Task.FromResult(_lifetime.GetStatus());
}
}

View File

@@ -0,0 +1,29 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeIpcHost : IDisposable
{
private readonly PublicIpcHostService _host;
public AirAppRuntimeIpcHost(
AirAppLifecycleService lifecycleService,
AirAppRuntimeControlService controlService)
{
_host = new PublicIpcHostService(IpcConstants.AirAppRuntimePipeName);
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
_host.RegisterPublicService<IAirAppRuntimeControlService>(controlService);
}
public void Start()
{
_host.Start();
AirAppRuntimeLogger.Info($"Air APP runtime IPC started. Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
}
public void Dispose()
{
_host.Dispose();
}
}

View File

@@ -0,0 +1,77 @@
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeLifetime
{
private readonly object _gate = new();
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
private readonly AirAppLifecycleService _lifecycleService;
private readonly int _launcherProcessId;
private readonly int _requesterProcessId;
private int _hostProcessId;
private DateTimeOffset _updatedAtUtc;
public AirAppRuntimeLifetime(AirAppRuntimeOptions options, AirAppLifecycleService lifecycleService)
{
_lifecycleService = lifecycleService;
_launcherProcessId = options.LauncherProcessId;
_requesterProcessId = options.RequesterProcessId;
_hostProcessId = options.RequesterProcessId;
_updatedAtUtc = _startedAtUtc;
}
public void AttachHost(int hostProcessId)
{
if (hostProcessId <= 0)
{
return;
}
lock (_gate)
{
_hostProcessId = hostProcessId;
_updatedAtUtc = DateTimeOffset.UtcNow;
}
AirAppRuntimeLogger.Info($"Attached host process. HostPid={hostProcessId}.");
}
public bool ShouldKeepAlive()
{
var status = GetStatus();
return status.LauncherProcessAlive ||
status.HostProcessAlive ||
IsProcessAlive(_requesterProcessId) ||
status.HasLiveAirApps;
}
public AirAppRuntimeStatus GetStatus()
{
int hostPid;
DateTimeOffset updatedAt;
lock (_gate)
{
hostPid = _hostProcessId;
updatedAt = _updatedAtUtc;
}
var launcherAlive = IsProcessAlive(_launcherProcessId);
var hostAlive = IsProcessAlive(hostPid);
var hasLiveAirApps = _lifecycleService.HasLiveAirApps();
return new AirAppRuntimeStatus(
Environment.ProcessId,
_launcherProcessId,
hostPid,
launcherAlive,
hostAlive,
hasLiveAirApps,
_startedAtUtc,
updatedAt);
}
internal static bool IsProcessAlive(int processId)
{
return AirAppLifecycleService.IsProcessAlive(processId);
}
}

View File

@@ -0,0 +1,16 @@
using System.Diagnostics;
namespace LanMountainDesktop.AirAppRuntime;
internal static class AirAppRuntimeLogger
{
public static void Info(string message) => Trace.WriteLine($"[AirAppRuntime] INFO {message}");
public static void Warn(string message) => Trace.WriteLine($"[AirAppRuntime] WARN {message}");
public static void Warn(string message, Exception ex) =>
Trace.WriteLine($"[AirAppRuntime] WARN {message} {ex}");
public static void Error(string message, Exception ex) =>
Trace.WriteLine($"[AirAppRuntime] ERROR {message} {ex}");
}

View File

@@ -0,0 +1,66 @@
using System.Globalization;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed record AirAppRuntimeOptions(
string? AppRoot,
string? DataRoot,
int LauncherProcessId,
int RequesterProcessId)
{
public static AirAppRuntimeOptions Parse(IReadOnlyList<string> args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < args.Count; index++)
{
var current = args[index];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
continue;
}
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++index];
}
else
{
values[key] = "true";
}
}
return new AirAppRuntimeOptions(
GetOptionalPath(values, "app-root"),
GetOptionalPath(values, "data-root"),
GetInt(values, "launcher-pid"),
GetInt(values, "requester-pid"));
}
private static string? GetOptionalPath(IReadOnlyDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? Path.GetFullPath(value)
: null;
}
private static int GetInt(IReadOnlyDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) &&
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
}
}

View File

@@ -0,0 +1,93 @@
using System.Diagnostics;
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.AirAppRuntime;
internal interface IAirAppProcessStarter
{
Process? Start(string appId, string sessionId, string instanceKey, string? sourceComponentId, string? sourcePlacementId);
}
internal sealed class AirAppProcessStarter : IAirAppProcessStarter
{
private readonly AirAppHostLocator _locator;
private readonly Func<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider;
public AirAppProcessStarter(
AirAppHostLocator locator,
Func<string?> packageRootProvider,
Func<string?> hostPathProvider,
Func<string?> dataRootProvider)
{
_locator = locator;
_packageRootProvider = packageRootProvider;
_hostPathProvider = hostPathProvider;
_dataRootProvider = dataRootProvider;
}
public Process? Start(
string appId,
string sessionId,
string instanceKey,
string? sourceComponentId,
string? sourcePlacementId)
{
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
var startInfo = CreateStartInfo(hostPath);
AddArgument(startInfo, "--app-id", appId);
AddArgument(startInfo, "--session-id", sessionId);
AddArgument(startInfo, "--instance-key", instanceKey);
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
var dataRoot = _dataRootProvider();
if (!string.IsNullOrWhiteSpace(dataRoot))
{
AddArgument(startInfo, "--data-root", Path.GetFullPath(dataRoot));
}
if (!string.IsNullOrWhiteSpace(sourceComponentId))
{
AddArgument(startInfo, "--source-component-id", sourceComponentId.Trim());
}
if (!string.IsNullOrWhiteSpace(sourcePlacementId))
{
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
}
AirAppRuntimeLogger.Info(
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
var process = Process.Start(startInfo);
if (process is not null)
{
process.EnableRaisingEvents = true;
process.Exited += (_, _) =>
{
try
{
AirAppRuntimeLogger.Info(
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
}
catch (Exception ex)
{
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
}
};
}
return process;
}
internal static ProcessStartInfo CreateStartInfo(string hostPath)
{
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
}
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{
startInfo.ArgumentList.Add(name);
startInfo.ArgumentList.Add(value);
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>false</PublishAot>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
namespace LanMountainDesktop.AirAppRuntime;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
var options = AirAppRuntimeOptions.Parse(args);
AirAppRuntimeLogger.Info(
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
try
{
var lifecycleService = new AirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => options.AppRoot,
() => null,
() => options.DataRoot));
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
var controlService = new AirAppRuntimeControlService(lifetime);
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
ipcHost.Start();
while (lifetime.ShouldKeepAlive())
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
return 0;
}
catch (Exception ex)
{
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
return 1;
}
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]