mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.airapp剥离启动器
This commit is contained in:
95
LanMountainDesktop.AirAppRuntime/AirAppHostLocator.cs
Normal file
95
LanMountainDesktop.AirAppRuntime/AirAppHostLocator.cs
Normal 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, ".."));
|
||||
}
|
||||
}
|
||||
22
LanMountainDesktop.AirAppRuntime/AirAppInstanceKey.cs
Normal file
22
LanMountainDesktop.AirAppRuntime/AirAppInstanceKey.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
330
LanMountainDesktop.AirAppRuntime/AirAppLifecycleService.cs
Normal file
330
LanMountainDesktop.AirAppRuntime/AirAppLifecycleService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal 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}");
|
||||
}
|
||||
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
93
LanMountainDesktop.AirAppRuntime/IAirAppProcessStarter.cs
Normal file
93
LanMountainDesktop.AirAppRuntime/IAirAppProcessStarter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
Reference in New Issue
Block a user