diff --git a/.trae/specs/air-app-runtime-container/checklist.md b/.trae/specs/air-app-runtime-container/checklist.md index e8da7ce..14af1ad 100644 --- a/.trae/specs/air-app-runtime-container/checklist.md +++ b/.trae/specs/air-app-runtime-container/checklist.md @@ -2,7 +2,12 @@ - [x] `LanMountainDesktop.AirAppRuntime` is included in `LanMountainDesktop.slnx`. - [x] Launcher no longer hosts `IAirAppLifecycleService`. +- [x] Launcher performs a bounded Host attach after startup, records the outcome, and exits without waiting for the Host process lifetime. +- [x] Built-in world-clock, whiteboard, and RSS-reader entry components execute in Host and call Host-side `AirAppLauncherService`. +- [x] AirApp Runtime starts AirAppHost, and AirAppHost renders only the compiled-in built-in Air APP views. - [x] Host fallback starts `LanMountainDesktop.AirAppRuntime`, not `LanMountainDesktop.Launcher air-app-broker`. +- [x] Production Host/Runtime/AirAppHost/Launcher do not reference AirAppSdk, AirAppTemplate, or AirAppDevServer and do not scan third-party `airapp.json` packages. +- [x] Documentation does not present the prototype `.laapp`/`airapp.json` output as compatible with the production `plugin.json` package installer. - [x] AirApp Runtime is explicitly non-AOT and framework-dependent. - [x] `dotnet build LanMountainDesktop.slnx -c Debug` passes. - [x] Related AirApp Runtime tests pass. diff --git a/.trae/specs/air-app-runtime-container/spec.md b/.trae/specs/air-app-runtime-container/spec.md index 8f84d25..6fe10b6 100644 --- a/.trae/specs/air-app-runtime-container/spec.md +++ b/.trae/specs/air-app-runtime-container/spec.md @@ -2,20 +2,29 @@ ## Goal -Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`. +Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`, while keeping the built-in desktop entry components in the main Host and the visible Air APP windows in separate `LanMountainDesktop.AirAppHost` processes. ## Behavior -- Launcher remains the user-facing entry point and pre-starts AirApp Runtime during normal `launch`. +- The built-in world-clock, whiteboard, and RSS-reader desktop entry components run in the main `LanMountainDesktop` Host. Their click handlers call the Host-side `AirAppLauncherService`; they do not run inside Launcher. +- `AirAppLauncherService` sends an `AirAppOpenRequest` to `IAirAppLifecycleService` through the `LanMountainDesktop.AirAppRuntime.v1` IPC pipe. +- AirApp Runtime resolves the built-in instance key and starts or activates a separate `LanMountainDesktop.AirAppHost` process. `AirAppHost` owns and renders the visible built-in Air APP window. +- Launcher is a short-lived startup coordinator. During normal `launch` it pre-starts AirApp Runtime, starts Host, performs a bounded live-Host-PID handoff through `IAirAppRuntimeControlService`, then shuts down instead of remaining alive for the Host lifetime. A failed handoff is diagnosed and left to Host's on-demand Runtime fallback. - AirApp Runtime exposes `IAirAppLifecycleService` and `IAirAppRuntimeControlService` on `LanMountainDesktop.AirAppRuntime.v1`. -- Desktop host requests Air APP operations through AirApp Runtime IPC. - If the runtime pipe is unavailable, the desktop host starts `LanMountainDesktop.AirAppRuntime` directly and retries. -- AirApp Runtime keeps one AirAppHost process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key, with `world-clock` sharing `world-clock:clock-suite:global`. -- AirApp Runtime remains alive while Launcher, Host, requester, or any AirAppHost process is alive. +- AirApp Runtime keeps one AirAppHost process per resolved instance key. `world-clock` shares `world-clock:clock-suite:global`, `rss-reader` shares `rss-reader:global`, and other built-ins use `{appId}:{sourceComponentId}:{sourcePlacementId}`. +- AirApp Runtime remains alive while the startup Launcher, attached Host, requester, or any AirAppHost process is alive. After a confirmed Host attachment, Host becomes the normal runtime owner; Launcher exits after its bounded handoff work rather than extending Host lifetime. - AirApp Runtime exits after Launcher/Host/requester are gone and no Air APP windows remain. +## Production Boundary + +- `LanMountainDesktop.AirAppSdk`, `LanMountainDesktop.AirAppTemplate`, and `LanMountainDesktop.AirAppDevServer` are preview/prototype projects. The production Host, Runtime, AirAppHost, and Launcher do not reference them or load third-party `airapp.json` assemblies. +- The production AirAppHost currently selects compiled-in views for `world-clock`, `whiteboard`, and `rss-reader`; it is not a general SDK package loader. +- `.laapp` is currently routed through the plugin packaging/install path, which requires `plugin.json`. The DevServer prototype's `.laapp` output based on `airapp.json` is therefore not a production-installable Air APP package. + ## Out of Scope - Moving Air APP windows into the runtime process. - Third-party plugin-declared Air APP metadata. +- Integrating the AirAppSdk/Template/DevServer prototype or implementing a third-party Air APP manifest/package loader. - Persisting the Air APP instance table across OS reboot. diff --git a/.trae/specs/air-app-runtime-container/tasks.md b/.trae/specs/air-app-runtime-container/tasks.md index aa8e97d..4f7668c 100644 --- a/.trae/specs/air-app-runtime-container/tasks.md +++ b/.trae/specs/air-app-runtime-container/tasks.md @@ -4,8 +4,11 @@ - [x] Add shared AirApp Runtime path resolver and process starter. - [x] Add `LanMountainDesktop.AirAppRuntime` as a framework-dependent JIT process. - [x] Move Air APP lifecycle service out of Launcher. -- [x] Make Launcher pre-start AirApp Runtime and attach Host PID after launch. +- [x] Keep built-in Air APP entry components in Host and route their clicks to AirApp Runtime over IPC. +- [x] Make Launcher pre-start AirApp Runtime, perform a bounded Host PID handoff after launch, and exit instead of tracking Host lifetime. +- [x] Keep visible built-in Air APP windows in separate AirAppHost processes managed by AirApp Runtime. - [x] Make Host fallback start AirApp Runtime instead of Launcher broker. - [x] Remove Launcher `air-app-broker` command handling. - [x] Update packaging scripts and release workflow to include AirApp Runtime. +- [x] Document that AirAppSdk, AirAppTemplate, and AirAppDevServer are not yet connected to the production loading path. - [x] Update unit tests and architecture/package assertions. diff --git a/LanMountainDesktop.AirAppTemplate/templates/component/README.md b/LanMountainDesktop.AirAppTemplate/templates/component/README.md index 7a627ea..5c3e9fa 100644 --- a/LanMountainDesktop.AirAppTemplate/templates/component/README.md +++ b/LanMountainDesktop.AirAppTemplate/templates/component/README.md @@ -1,6 +1,9 @@ -# LanMountainDesktop.AirApp.ComponentTemplate +# LanMountainDesktop.AirApp.ComponentTemplate (Preview) -A desktop component AirApp for LanMountainDesktop. +> [!IMPORTANT] +> This template is an API prototype. The production LanMountainDesktop Host, AirAppRuntime, and AirAppHost do not discover `airapp.json` or load assemblies produced by this template. + +A prototype desktop-component AirApp project for exploring `LanMountainDesktop.AirAppSdk`. ## Build @@ -8,21 +11,15 @@ A desktop component AirApp for LanMountainDesktop. dotnet build -c Release ``` -This will produce a `.laapp` package in `bin/Release/net10.0/`. +This compiles the prototype project. The template project itself does not create a production-installable `.laapp` package. ## Install -Copy the `.laapp` file to LanMountainDesktop's plugins directory or install via the AirApp Market. +There is currently no supported production install path for this output. Do not copy it to the plugins directory or submit it to the AirApp Market: production `.laapp` handling is the plugin package path and expects `plugin.json`, while this template uses `airapp.json`. ## 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 - ``` +You can build the project to validate prototype API usage. An integrated preview/production loader is not implemented yet; `LanMountainDesktop.AirAppDevServer` also does not currently launch a real preview host. ## Customize diff --git a/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs b/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs index 5141c00..f980aff 100644 --- a/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs +++ b/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using LanMountainDesktop.Shared.IPC; using LanMountainDesktop.Shared.IPC.Abstractions.Services; @@ -7,31 +6,47 @@ namespace LanMountainDesktop.Launcher.Shell; internal sealed class AirAppRuntimeBridge { private const int ConnectAttempts = 8; + private const int AttachAttempts = 4; private readonly string _appRoot; private readonly string? _dataRoot; + private readonly IAirAppRuntimeBridgeBackend _backend; public AirAppRuntimeBridge(string appRoot, string? dataRoot) + : this(appRoot, dataRoot, new AirAppRuntimeBridgeBackend()) + { + } + + internal AirAppRuntimeBridge( + string appRoot, + string? dataRoot, + IAirAppRuntimeBridgeBackend backend) { _appRoot = appRoot; _dataRoot = dataRoot; + _backend = backend; } - public async Task EnsureStartedAsync() + public async Task EnsureStartedAsync() { Logger.Info($"AIRAPP: Checking if AirApp Runtime is available. AppRoot='{_appRoot}'"); - if (await TryGetStatusAsync().ConfigureAwait(false) is not null) + var status = await TryGetStatusAsync().ConfigureAwait(false); + if (status is not null) { Logger.Info("AIRAPP: AirApp Runtime is already available."); - return; + return new AirAppRuntimeAvailabilityResult( + true, + "already_available", + "AirApp Runtime is already available.", + status); } Logger.Info("AIRAPP: Starting AirApp Runtime..."); - Process? process; + int? processId; try { - process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest( + processId = _backend.Start(new AirAppRuntimeStartRequest( _appRoot, Environment.ProcessId, 0, @@ -40,72 +55,160 @@ internal sealed class AirAppRuntimeBridge catch (Exception ex) { Logger.Warn($"AIRAPP: AirApp Runtime start request failed. AppRoot='{_appRoot}'; Error='{ex.Message}'"); - return; + return new AirAppRuntimeAvailabilityResult( + false, + "start_failed", + $"AirApp Runtime start request failed: {ex.Message}", + null); } - Logger.Info($"AIRAPP: AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'."); + Logger.Info($"AIRAPP: AirApp Runtime start requested. Pid={processId ?? -1}; AppRoot='{_appRoot}'."); + if (processId is null) + { + Logger.Warn("AIRAPP: AirApp Runtime process was not created; Host fallback remains available."); + return new AirAppRuntimeAvailabilityResult( + false, + "process_not_created", + "AirApp Runtime process was not created.", + null); + } for (var attempt = 1; attempt <= ConnectAttempts; attempt++) { Logger.Info($"AIRAPP: Attempt {attempt}/{ConnectAttempts} - Checking IPC connection..."); - if (await TryGetStatusAsync().ConfigureAwait(false) is not null) + status = await TryGetStatusAsync().ConfigureAwait(false); + if (status is not null) { Logger.Info("AIRAPP: AirApp Runtime IPC is ready."); - return; + return new AirAppRuntimeAvailabilityResult( + true, + "started", + "AirApp Runtime IPC is ready.", + status); } - var delayMs = 250 * attempt; - Logger.Info($"AIRAPP: IPC not ready, waiting {delayMs}ms before retry..."); - await Task.Delay(TimeSpan.FromMilliseconds(delayMs)).ConfigureAwait(false); + if (attempt < ConnectAttempts) + { + var delay = TimeSpan.FromMilliseconds(250 * attempt); + Logger.Info($"AIRAPP: IPC not ready, waiting {delay.TotalMilliseconds:0}ms before retry..."); + await _backend.DelayAsync(delay).ConfigureAwait(false); + } } Logger.Warn("AIRAPP: AirApp Runtime did not become ready after pre-start; Host fallback remains available."); + return new AirAppRuntimeAvailabilityResult( + false, + "runtime_unavailable", + "AirApp Runtime did not become ready after pre-start.", + null); } - public async Task AttachHostAsync(int hostProcessId) + public async Task AttachHostAsync(int hostProcessId) { if (hostProcessId <= 0) { - return; + Logger.Warn($"AIRAPP: Cannot hand off runtime ownership because HostPid={hostProcessId} is invalid."); + return new AirAppRuntimeHandoffResult( + false, + "invalid_host_pid", + "Host process id must be positive.", + hostProcessId, + 0, + null); } - try + AirAppRuntimeControlResult? lastControlResult = null; + Exception? lastException = null; + var attemptsCompleted = 0; + + for (var attempt = 1; attempt <= AttachAttempts; attempt++) { - using var cts = new CancellationTokenSource(); - using var client = new LanMountainDesktopIpcClient(); + // Re-check availability before every attempt. If a pre-started runtime exits + // between discovery and attach, this starts a replacement while Launcher is alive. + var availability = await EnsureStartedAsync().ConfigureAwait(false); + if (!availability.Available) + { + return new AirAppRuntimeHandoffResult( + false, + availability.Code, + availability.Message, + hostProcessId, + attemptsCompleted, + availability.Status); + } - var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName); - await connectTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false); + try + { + attemptsCompleted = attempt; + lastControlResult = await _backend.AttachHostAsync(hostProcessId).ConfigureAwait(false); + if (IsConfirmedHostAttach(lastControlResult, hostProcessId)) + { + Logger.Info( + $"AIRAPP: Runtime ownership handed off to Host. HostPid={hostProcessId}; " + + $"RuntimePid={lastControlResult.Status.ProcessId}; Attempts={attemptsCompleted}."); + return new AirAppRuntimeHandoffResult( + true, + "host_attached", + "AirApp Runtime confirmed the live Host attachment.", + hostProcessId, + attemptsCompleted, + lastControlResult.Status); + } - var proxy = client.CreateProxy(); - var attachTask = proxy.AttachHostAsync(hostProcessId); - var result = await attachTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false); - Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}."); - } - catch (Exception ex) - { - Logger.Warn($"Failed to attach Host to AirApp Runtime: {ex.Message}"); + Logger.Warn( + $"AIRAPP: Runtime Host attach was not confirmed. Attempt={attempt}/{AttachAttempts}; " + + $"Accepted={lastControlResult.Accepted}; Code='{lastControlResult.Code}'; " + + $"ReturnedHostPid={lastControlResult.Status.HostProcessId}; " + + $"HostAlive={lastControlResult.Status.HostProcessAlive}."); + } + catch (Exception ex) + { + attemptsCompleted = attempt; + lastException = ex; + Logger.Warn( + $"AIRAPP: Runtime Host attach attempt failed. Attempt={attempt}/{AttachAttempts}; " + + $"HostPid={hostProcessId}; Error='{ex.Message}'."); + } + + if (attempt < AttachAttempts) + { + await _backend.DelayAsync(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false); + } } + + var code = lastControlResult is null ? "host_attach_failed" : "host_attach_unconfirmed"; + var message = lastControlResult is null + ? $"AirApp Runtime Host attach failed: {lastException?.Message ?? "unknown error"}" + : $"AirApp Runtime did not confirm Host attachment. LastCode='{lastControlResult.Code}'."; + Logger.Warn( + $"AIRAPP: Runtime ownership handoff failed; Host fallback remains available. " + + $"HostPid={hostProcessId}; Attempts={attemptsCompleted}; Code='{code}'."); + return new AirAppRuntimeHandoffResult( + false, + code, + message, + hostProcessId, + attemptsCompleted, + lastControlResult?.Status); } - private static async Task TryGetStatusAsync() + private static bool IsConfirmedHostAttach(AirAppRuntimeControlResult result, int hostProcessId) + { + return result.Accepted && + result.Status.HostProcessId == hostProcessId && + result.Status.HostProcessAlive; + } + + private async Task TryGetStatusAsync() { try { - using var cts = new CancellationTokenSource(); - using var client = new LanMountainDesktopIpcClient(); - - var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName); - await connectTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false); - - var proxy = client.CreateProxy(); - var statusTask = proxy.GetStatusAsync(); - return await statusTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false); + return await _backend.GetStatusAsync().ConfigureAwait(false); } catch (TimeoutException) { - Logger.Info("AIRAPP: TryGetStatusAsync timed out (2s)."); + Logger.Info("AIRAPP: TryGetStatusAsync timed out."); return null; } catch (OperationCanceledException) @@ -120,3 +223,65 @@ internal sealed class AirAppRuntimeBridge } } } + +internal sealed record AirAppRuntimeAvailabilityResult( + bool Available, + string Code, + string Message, + AirAppRuntimeStatus? Status); + +internal sealed record AirAppRuntimeHandoffResult( + bool Accepted, + string Code, + string Message, + int HostProcessId, + int Attempts, + AirAppRuntimeStatus? Status); + +internal interface IAirAppRuntimeBridgeBackend +{ + int? Start(AirAppRuntimeStartRequest request); + + Task GetStatusAsync(); + + Task AttachHostAsync(int hostProcessId); + + Task DelayAsync(TimeSpan delay); +} + +internal sealed class AirAppRuntimeBridgeBackend : IAirAppRuntimeBridgeBackend +{ + public int? Start(AirAppRuntimeStartRequest request) + { + using var process = AirAppRuntimeProcessStarter.Start(request); + return process?.Id; + } + + public async Task GetStatusAsync() + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName) + .WaitAsync(TimeSpan.FromSeconds(2)) + .ConfigureAwait(false); + + var proxy = client.CreateProxy(); + return await proxy.GetStatusAsync() + .WaitAsync(TimeSpan.FromSeconds(2)) + .ConfigureAwait(false); + } + + public async Task AttachHostAsync(int hostProcessId) + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName) + .WaitAsync(TimeSpan.FromSeconds(3)) + .ConfigureAwait(false); + + var proxy = client.CreateProxy(); + return await proxy.AttachHostAsync(hostProcessId) + .WaitAsync(TimeSpan.FromSeconds(3)) + .ConfigureAwait(false); + } + + public Task DelayAsync(TimeSpan delay) => Task.Delay(delay); +} diff --git a/LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs b/LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs index 78a22c4..e6b96f8 100644 --- a/LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs +++ b/LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs @@ -22,21 +22,35 @@ internal static class LaunchUiPresenter { try { - await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync()); + await splashWindow.DismissAsync().ConfigureAwait(false); } catch (Exception ex) { Logger.Error("Failed to dismiss splash window.", ex); + await ForceHideAndCloseSplashAsync(splashWindow).ConfigureAwait(false); } await Dispatcher.UIThread.InvokeAsync(() => { + if (loadingDetailsWindow is null) + { + return; + } + + loadingDetailsWindow.IsHitTestVisible = false; + try { - if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible) - { - loadingDetailsWindow.Close(); - } + loadingDetailsWindow.Hide(); + } + catch (Exception ex) + { + Logger.Error("Failed to hide loading details window.", ex); + } + + try + { + loadingDetailsWindow.Close(); } catch (Exception ex) { @@ -45,6 +59,39 @@ internal static class LaunchUiPresenter }); } + private static async Task ForceHideAndCloseSplashAsync(SplashWindow splashWindow) + { + try + { + await Dispatcher.UIThread.InvokeAsync(() => + { + splashWindow.IsHitTestVisible = false; + + try + { + splashWindow.Hide(); + } + catch (Exception ex) + { + Logger.Error("Failed to force-hide splash window.", ex); + } + + try + { + splashWindow.Close(); + } + catch (Exception ex) + { + Logger.Error("Failed to force-close splash window.", ex); + } + }); + } + catch (Exception ex) + { + Logger.Error("Failed to dispatch forced splash cleanup.", ex); + } + } + public static async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync() { ErrorWindow? errorWindow = null; diff --git a/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs index 280e0be..3602efa 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs @@ -116,17 +116,31 @@ internal static class LauncherGuiCoordinator } Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); - await WriteLauncherResultAsync(context, result).ConfigureAwait(false); - Environment.ExitCode = result.Success ? 0 : 1; if (result.Success) { var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); - var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot()); - await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false); - await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false); + try + { + var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot()); + var handoff = await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false); + RecordAirAppRuntimeHandoff(result, handoff); + } + catch (Exception ex) + { + Logger.Warn( + $"AIRAPP: Unexpected runtime ownership handoff failure; Host fallback remains available. " + + $"HostPid={hostPid}; Error='{ex.Message}'."); + result.Details["airAppRuntimeHandoffAccepted"] = bool.FalseString; + result.Details["airAppRuntimeHandoffCode"] = "handoff_exception"; + result.Details["airAppRuntimeHandoffMessage"] = ex.Message; + result.Details["airAppRuntimeHostPid"] = hostPid.ToString(); + result.Details["airAppRuntimeAttachAttempts"] = "0"; + } } + await WriteLauncherResultAsync(context, result).ConfigureAwait(false); + Logger.Info("Launcher coordination is complete; shutting down without extending the Host process lifetime."); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); } @@ -168,15 +182,16 @@ internal static class LauncherGuiCoordinator return fallbackHostPid; } - private static async Task WaitForHostProcessToExitAsync(int hostPid) + private static void RecordAirAppRuntimeHandoff( + LauncherResult result, + AirAppRuntimeHandoffResult handoff) { - Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}."); - while (TryGetLiveProcess(hostPid)) - { - await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - } - - Logger.Info("Launcher host background lifetime completed; host process is gone."); + result.Details["airAppRuntimeHandoffAccepted"] = handoff.Accepted.ToString(); + result.Details["airAppRuntimeHandoffCode"] = handoff.Code; + result.Details["airAppRuntimeHandoffMessage"] = handoff.Message; + result.Details["airAppRuntimeHostPid"] = handoff.HostProcessId.ToString(); + result.Details["airAppRuntimeAttachAttempts"] = handoff.Attempts.ToString(); + result.Details["airAppRuntimeProcessId"] = handoff.Status?.ProcessId.ToString() ?? string.Empty; } private static async Task AttachToExistingCoordinatorAsync( diff --git a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs index 3bd1b34..bd572af 100644 --- a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs @@ -16,10 +16,11 @@ public partial class SplashWindow : Window, ISplashStageReporter private const int DebugModeClickThreshold = 5; private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160); + private readonly object _dismissSync = new(); private int _versionTextClickCount; private bool _isDebugModeOpened; private bool _isOpened; - private bool _dismissed; + private Task? _dismissTask; public SplashWindow() { @@ -90,23 +91,84 @@ public partial class SplashWindow : Window, ISplashStageReporter await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false); } - public async Task DismissAsync() + public Task DismissAsync() { - if (_dismissed) + lock (_dismissSync) { - return; + return _dismissTask ??= DismissCoreAsync(); + } + } + + private async Task DismissCoreAsync() + { + try + { + var animationState = await Dispatcher.UIThread.InvokeAsync(() => + { + IsHitTestVisible = false; + + if (!IsVisible) + { + HideAndCloseOnUiThread(); + return (ShouldAnimate: false, StartOpacity: 0d); + } + + return (ShouldAnimate: true, StartOpacity: Opacity); + }); + + if (!animationState.ShouldAnimate) + { + return; + } + + await AnimateOpacityAsync( + animationState.StartOpacity, + 0d, + FadeAnimationDuration, + HideAndCloseOnUiThread) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"[SplashWindow] Fade-out failed; closing immediately: {ex.Message}"); + await Dispatcher.UIThread.InvokeAsync(HideAndCloseOnUiThread); + } + } + + private void HideAndCloseOnUiThread() + { + Dispatcher.UIThread.VerifyAccess(); + IsHitTestVisible = false; + + try + { + Hide(); + } + catch (Exception ex) + { + Logger.Warn($"[SplashWindow] Failed to hide splash window: {ex.Message}"); } - _dismissed = true; - - if (!Dispatcher.UIThread.CheckAccess()) + try { - await Dispatcher.UIThread.InvokeAsync(async () => await DismissAsync()); - return; + Close(); + } + catch (Exception ex) + { + Logger.Warn($"[SplashWindow] Failed to close splash window: {ex.Message}"); } - await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false); - Close(); + try + { + if (IsVisible) + { + Hide(); + } + } + catch (Exception ex) + { + Logger.Warn($"[SplashWindow] Failed to enforce hidden splash state: {ex.Message}"); + } } public void Report(string stage, string message) @@ -252,19 +314,27 @@ public partial class SplashWindow : Window, ISplashStageReporter } } - private async Task AnimateOpacityAsync(double from, double to, TimeSpan duration) + private async Task AnimateOpacityAsync( + double from, + double to, + TimeSpan duration, + Action? completed = null) { await AnimateAsync(progress => { Opacity = from + ((to - from) * progress); - }, duration, EaseOutCubic).ConfigureAwait(false); + }, duration, EaseOutCubic, completed).ConfigureAwait(false); } - private async Task AnimateAsync(Action update, TimeSpan duration, Func easing) + private async Task AnimateAsync( + Action update, + TimeSpan duration, + Func easing, + Action? completed) { if (duration <= TimeSpan.Zero) { - await Dispatcher.UIThread.InvokeAsync(() => update(1d)); + await Dispatcher.UIThread.InvokeAsync(() => ApplyFinalFrame(update, completed)); return; } @@ -277,7 +347,19 @@ public partial class SplashWindow : Window, ISplashStageReporter await Task.Delay(16).ConfigureAwait(false); } - await Dispatcher.UIThread.InvokeAsync(() => update(1d)); + await Dispatcher.UIThread.InvokeAsync(() => ApplyFinalFrame(update, completed)); + } + + private static void ApplyFinalFrame(Action update, Action? completed) + { + try + { + update(1d); + } + finally + { + completed?.Invoke(); + } } private static int ResolveProgress(string stage) diff --git a/LanMountainDesktop.Tests/AirAppRuntimeBridgeTests.cs b/LanMountainDesktop.Tests/AirAppRuntimeBridgeTests.cs new file mode 100644 index 0000000..e8e8375 --- /dev/null +++ b/LanMountainDesktop.Tests/AirAppRuntimeBridgeTests.cs @@ -0,0 +1,194 @@ +using LanMountainDesktop.Launcher.Shell; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class AirAppRuntimeBridgeTests +{ + [Fact] + public async Task EnsureStartedAsync_StartsRuntimeAndReturnsConfirmedAvailability() + { + var statusCalls = 0; + var backend = new TestBackend + { + GetStatusHandler = () => ++statusCalls == 1 + ? Task.FromException(new IOException("pipe is not ready")) + : Task.FromResult(CreateStatus(0, hostAlive: false)) + }; + var bridge = new AirAppRuntimeBridge("C:\\app", "C:\\data", backend); + + var result = await bridge.EnsureStartedAsync(); + + Assert.True(result.Available); + Assert.Equal("started", result.Code); + Assert.NotNull(result.Status); + Assert.Single(backend.StartRequests); + } + + [Fact] + public async Task EnsureStartedAsync_WhenProcessIsNotCreated_ReturnsWithoutPolling() + { + var backend = new TestBackend + { + StartHandler = _ => null + }; + var bridge = new AirAppRuntimeBridge("C:\\app", null, backend); + + var result = await bridge.EnsureStartedAsync(); + + Assert.False(result.Available); + Assert.Equal("process_not_created", result.Code); + Assert.Equal(1, backend.GetStatusCalls); + Assert.Single(backend.StartRequests); + } + + [Fact] + public async Task AttachHostAsync_RestartsRuntimeWhenItDisappearsBeforeAttach() + { + var hostProcessId = Environment.ProcessId; + var statusCalls = 0; + var attachCalls = 0; + var backend = new TestBackend + { + GetStatusHandler = () => ++statusCalls switch + { + 1 => Task.FromResult(CreateStatus(0, hostAlive: false)), + 2 => Task.FromException(new IOException("runtime exited")), + _ => Task.FromResult(CreateStatus(0, hostAlive: false)) + }, + AttachHandler = processId => ++attachCalls == 1 + ? Task.FromException(new IOException("connection closed")) + : Task.FromResult(CreateAttachResult(processId, accepted: true, hostAlive: true)) + }; + var bridge = new AirAppRuntimeBridge("C:\\app", null, backend); + + var result = await bridge.AttachHostAsync(hostProcessId); + + Assert.True(result.Accepted); + Assert.Equal("host_attached", result.Code); + Assert.Equal(2, result.Attempts); + Assert.Equal(2, attachCalls); + Assert.Single(backend.StartRequests); + } + + [Fact] + public async Task AttachHostAsync_RetriesAnUnconfirmedAttachResult() + { + var hostProcessId = Environment.ProcessId; + var attachCalls = 0; + var backend = new TestBackend + { + GetStatusHandler = () => Task.FromResult(CreateStatus(0, hostAlive: false)), + AttachHandler = processId => Task.FromResult(++attachCalls == 1 + ? CreateAttachResult(processId + 1, accepted: true, hostAlive: true) + : CreateAttachResult(processId, accepted: true, hostAlive: true)) + }; + var bridge = new AirAppRuntimeBridge("C:\\app", null, backend); + + var result = await bridge.AttachHostAsync(hostProcessId); + + Assert.True(result.Accepted); + Assert.Equal(2, result.Attempts); + Assert.Equal(hostProcessId, result.Status!.HostProcessId); + Assert.Empty(backend.StartRequests); + } + + [Fact] + public async Task AttachHostAsync_InvalidHostPidReturnsFailureWithoutRuntimeWork() + { + var backend = new TestBackend(); + var bridge = new AirAppRuntimeBridge("C:\\app", null, backend); + + var result = await bridge.AttachHostAsync(0); + + Assert.False(result.Accepted); + Assert.Equal("invalid_host_pid", result.Code); + Assert.Equal(0, result.Attempts); + Assert.Equal(0, backend.GetStatusCalls); + Assert.Empty(backend.StartRequests); + } + + [Fact] + public async Task AttachHostAsync_ReturnsObservableFailureAfterBoundedRetries() + { + var attachCalls = 0; + var backend = new TestBackend + { + GetStatusHandler = () => Task.FromResult(CreateStatus(0, hostAlive: false)), + AttachHandler = processId => + { + attachCalls++; + return Task.FromResult(CreateAttachResult(processId, accepted: false, hostAlive: false)); + } + }; + var bridge = new AirAppRuntimeBridge("C:\\app", null, backend); + + var result = await bridge.AttachHostAsync(Environment.ProcessId); + + Assert.False(result.Accepted); + Assert.Equal("host_attach_unconfirmed", result.Code); + Assert.Equal(4, result.Attempts); + Assert.Equal(4, attachCalls); + } + + private static AirAppRuntimeControlResult CreateAttachResult( + int hostProcessId, + bool accepted, + bool hostAlive) + { + return new AirAppRuntimeControlResult( + accepted, + accepted ? "host_attached" : "host_attach_rejected", + "test result", + CreateStatus(hostProcessId, hostAlive)); + } + + private static AirAppRuntimeStatus CreateStatus(int hostProcessId, bool hostAlive) + { + return new AirAppRuntimeStatus( + Environment.ProcessId, + 0, + hostProcessId, + false, + hostAlive, + false, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow); + } + + private sealed class TestBackend : IAirAppRuntimeBridgeBackend + { + public Func> GetStatusHandler { get; init; } = + () => Task.FromException(new IOException("runtime unavailable")); + + public Func> AttachHandler { get; init; } = + _ => Task.FromException(new IOException("runtime unavailable")); + + public Func StartHandler { get; init; } = _ => 4242; + + public List StartRequests { get; } = []; + + public int GetStatusCalls { get; private set; } + + public int? Start(AirAppRuntimeStartRequest request) + { + StartRequests.Add(request); + return StartHandler(request); + } + + public Task GetStatusAsync() + { + GetStatusCalls++; + return GetStatusHandler(); + } + + public Task AttachHostAsync(int hostProcessId) + { + return AttachHandler(hostProcessId); + } + + public Task DelayAsync(TimeSpan delay) => Task.CompletedTask; + } +} diff --git a/LanMountainDesktop.Tests/AirAppRuntimeLifecycleServiceTests.cs b/LanMountainDesktop.Tests/AirAppRuntimeLifecycleServiceTests.cs index 4cc8183..830e586 100644 --- a/LanMountainDesktop.Tests/AirAppRuntimeLifecycleServiceTests.cs +++ b/LanMountainDesktop.Tests/AirAppRuntimeLifecycleServiceTests.cs @@ -165,6 +165,7 @@ public sealed class AirAppRuntimeLifecycleServiceTests Assert.True(result.Accepted); Assert.Equal(Environment.ProcessId, result.Status.HostProcessId); Assert.True(result.Status.HostProcessAlive); + Assert.True(lifetime.ShouldKeepAlive()); } private sealed class TestAirAppProcessStarter : IAirAppProcessStarter diff --git a/LanMountainDesktop.Tests/LauncherArchitectureTests.cs b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs index 84ed4e1..640306b 100644 --- a/LanMountainDesktop.Tests/LauncherArchitectureTests.cs +++ b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs @@ -7,7 +7,7 @@ public sealed class LauncherArchitectureTests [Fact] public void CoreLauncherFolders_DoNotUseAvaloniaNamespaces() { - var forbidden = new[] { "Deployment", "Update", "Startup", "Infrastructure" }; + var forbidden = new[] { "Deployment", "Startup", "Infrastructure" }; foreach (var folder in forbidden.Select(folder => Path.Combine(LauncherProjectRoot, folder))) { var offenders = Directory @@ -142,6 +142,20 @@ public sealed class LauncherArchitectureTests AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Shell", "LauncherCompositionRoot.cs"), 80); } + [Fact] + public void SuccessfulLauncherHandoff_DoesNotWaitForHostProcessExit() + { + var coordinator = File.ReadAllText(Path.Combine( + LauncherProjectRoot, + "Shell", + "LauncherGuiCoordinator.cs")); + + Assert.Contains("AttachHostAsync(hostPid)", coordinator, StringComparison.Ordinal); + Assert.Contains("desktop.Shutdown(Environment.ExitCode)", coordinator, StringComparison.Ordinal); + Assert.DoesNotContain("WaitForHostProcessToExit", coordinator, StringComparison.Ordinal); + Assert.DoesNotContain("Launcher entering host background lifetime", coordinator, StringComparison.Ordinal); + } + private static string LauncherProjectRoot => Path.Combine(RepoRoot, "LanMountainDesktop.Launcher"); private static string RepoRoot diff --git a/LanMountainDesktop.Tests/SplashWindowLifecycleTests.cs b/LanMountainDesktop.Tests/SplashWindowLifecycleTests.cs new file mode 100644 index 0000000..e8f8ba1 --- /dev/null +++ b/LanMountainDesktop.Tests/SplashWindowLifecycleTests.cs @@ -0,0 +1,77 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Shell; +using LanMountainDesktop.Launcher.Views; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class SplashWindowLifecycleTests +{ + [AvaloniaFact] + public async Task DismissAsync_WhenCalledConcurrentlyOffUiThread_ClosesExactlyOnce() + { + var window = new SplashWindow(); + var closedCount = 0; + window.Closed += (_, _) => Interlocked.Increment(ref closedCount); + window.Show(); + + var dismissTasks = await Task.Run(() => + Enumerable.Range(0, 8) + .Select(_ => window.DismissAsync()) + .ToArray()); + + Assert.All(dismissTasks, task => Assert.Same(dismissTasks[0], task)); + await Task.WhenAll(dismissTasks); + + await Dispatcher.UIThread.InvokeAsync(() => + { + Assert.False(window.IsVisible); + Assert.False(window.IsHitTestVisible); + Assert.Equal(1, closedCount); + }); + } + + [AvaloniaFact] + public async Task CloseWindowsAsync_WaitsUntilSplashIsNoLongerVisible() + { + var window = new SplashWindow(); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + window.Closed += (_, _) => closed.TrySetResult(); + window.Show(); + + await Task.Run(() => LaunchUiPresenter.CloseWindowsAsync(window, loadingDetailsWindow: null)); + + Assert.True(closed.Task.IsCompleted); + await Dispatcher.UIThread.InvokeAsync(() => Assert.False(window.IsVisible)); + } + + [AvaloniaFact] + public async Task DismissAsync_WhenCloseIsCancelled_LeavesWindowHiddenAndNonHitTestable() + { + var window = new SplashWindow(); + var closeWasCancelled = false; + + void CancelClose(object? sender, WindowClosingEventArgs args) + { + closeWasCancelled = true; + args.Cancel = true; + } + + window.Closing += CancelClose; + window.Show(); + + await Task.Run(() => window.DismissAsync()); + + await Dispatcher.UIThread.InvokeAsync(() => + { + Assert.True(closeWasCancelled); + Assert.False(window.IsVisible); + Assert.False(window.IsHitTestVisible); + + window.Closing -= CancelClose; + window.Close(); + }); + } +} diff --git a/docs/02-AirApp开发/README.md b/docs/02-AirApp开发/README.md index d0f61f2..2f22c37 100644 --- a/docs/02-AirApp开发/README.md +++ b/docs/02-AirApp开发/README.md @@ -1,253 +1,93 @@ -# Air APP 开发完整指南 +# Air APP 现状与开发预览 -欢迎来到阑山桌面 Air APP 开发指南!Air APP 是运行在阑山桌面环境中的独立窗口应用。 +> [!IMPORTANT] +> 当前生产版本只支持编译内置的 `world-clock`、`whiteboard` 和 `rss-reader` 窗口链路。`LanMountainDesktop.AirAppSdk`、`LanMountainDesktop.AirAppTemplate` 与 `LanMountainDesktop.AirAppDevServer` 是尚未接入生产 Host/Runtime 的原型,不能据此宣称第三方 Air APP 会被桌面自动发现、安装或加载。 -## 什么是 Air APP? +## 当前已上线的内置链路 -**Air APP** 是阑山桌面生态中的独立应用形态,与桌面组件(Widget)不同: - -### 对比:Air APP vs 桌面组件 - -| 特性 | Air APP | 桌面组件 | -|------|---------|---------| -| **窗口形式** | 独立窗口,可移动、缩放 | 固定在桌面上 | -| **生命周期** | 独立进程,按需启动 | 随宿主启动 | -| **UI 复杂度** | 适合复杂界面 | 适合简单信息展示 | -| **资源占用** | 按需运行,不用时退出 | 始终运行 | -| **典型案例** | 白板、世界时钟、计算器 | 天气组件、时钟组件 | - -### Air APP 架构 +桌面轻应用入口本身是主 `LanMountainDesktop` Host 内的桌面组件,不在 Launcher、AirAppRuntime 或 AirAppSdk 中运行: ``` -┌──────────────────────────────────────┐ -│ LanMountainDesktop (桌面宿主) │ -│ │ -│ ┌────────────────────────────────┐ │ -│ │ LanMountainDesktop.AirAppRuntime│ │ -│ │ (Air APP 运行时容器) │ │ -│ │ │ │ -│ │ 管理所有 Air APP 进程 │ │ -│ │ - 启动/停止 │ │ -│ │ - 实例去重 │ │ -│ │ - 生命周期跟踪 │ │ -│ └─────────┬────────────────────────┘ │ -└────────────┼───────────────────────────┘ - │ IPC 通信 - │ - ┌────────▼──────────┐ - │ Air APP Process │ - │ │ - │ ┌─────────────┐ │ - │ │ AirAppHost │ │ - │ │ (渲染容器) │ │ - │ └─────────────┘ │ - │ │ - │ 你的 Air APP │ - │ - UI │ - │ - 业务逻辑 │ - │ - 数据管理 │ - └──────────────────┘ +主 Host 内置桌面组件 + 世界时钟 / 白板 / RSS 阅读器 + ↓ 点击 +Host 内 AirAppLauncherService + ↓ AirAppOpenRequest(IPC) +独立 LanMountainDesktop.AirAppRuntime + ↓ 启动或激活 +独立 LanMountainDesktop.AirAppHost 进程 + ↓ +按 appId 渲染编译内置的窗口视图 ``` -## 📚 学习路径 +各进程职责如下: -### 快速上手 +| 进程/模块 | 当前生产职责 | +|----------|--------------| +| `LanMountainDesktop` Host | 承载桌面入口组件;点击后构造请求并调用 Runtime IPC | +| `LanMountainDesktop.Launcher` | OOBE、Splash、版本选择、预启动 Runtime、启动 Host;执行有界的 `AttachHost(hostPid)` 交接后退出 | +| `LanMountainDesktop.AirAppRuntime` | 提供生命周期与控制 IPC、实例去重、启动/激活/关闭 AirAppHost | +| `LanMountainDesktop.AirAppHost` | 独立进程渲染一个内置 Air APP 窗口,并向 Runtime 注册/注销 | -1. **[Air APP 介绍](01-Air-APP介绍.md)** - 理解 Air APP 是什么 -2. **[创建第一个 Air APP](02-创建第一个AirApp.md)** - Hello World -3. **[架构与生命周期](03-架构与生命周期.md)** - 理解运行机制 +Launcher 不承载轻应用 UI,也不需要跟随 Host 常驻。正常启动中,它尝试把存活的 Host PID 交给 Runtime,成功时由 Host 接管 Runtime 生命周期;交接失败则记录诊断并依靠 Host 的按需启动兜底。两种情况下 Launcher 都不会等待 Host 退出。稳定运行期是 `Host ↔ AirAppRuntime ↔ AirAppHost`,不存在必须保留的透明 Launcher 窗口。 -### 深入学习 +### 当前内置应用与实例规则 -4. **[IPC 通信](04-IPC通信.md)** - 与宿主和其他 APP 通信 -5. **[窗口管理](05-窗口管理.md)** - 窗口模式、大小、位置 -6. **[数据持久化](06-数据持久化.md)** - 保存应用数据 -7. **[主题适配](07-主题适配.md)** - 适配亮色/暗色模式 +| `appId` | 入口位置 | AirAppHost 内容 | 实例规则 | +|---------|----------|----------------|----------| +| `world-clock` | Host 内的世界时钟/模拟时钟等组件 | 编译内置的时钟视图 | 全局共用 `world-clock:clock-suite:global` | +| `whiteboard` | Host 内的白板组件 | 编译内置的白板组件视图 | 按组件 ID 与放置 ID 区分 | +| `rss-reader` | Host 内的 RSS 阅读器组件 | 编译内置的 RSS 视图 | 全局共用 `rss-reader:global` | -### 实战案例 +Host 会把 `sourceComponentId`、`sourcePlacementId`(以及 RSS 的目标条目)经 Runtime 透传给 AirAppHost。若点击时 Runtime 管道不可用,Host 会直接启动 AirAppRuntime 并重试;这条兜底不依赖 Launcher 常驻。 -8. **[世界时钟 APP](08-实战-世界时钟.md)** - 完整示例 -9. **[白板 APP](09-实战-白板.md)** - 全屏交互应用 -10. **[打包与发布](10-打包与发布.md)** - 发布到市场 +## 第三方 AirAppSdk:Preview,尚未接入生产 -## 🎯 快速开始 +仓库中存在一组第三方 Air APP 开发原型,但它们不是上述生产链路的一部分: -### 创建 Air APP 项目 +| 原型项目 | 已有内容 | 当前缺口 | +|----------|----------|----------| +| `LanMountainDesktop.AirAppSdk` | API、清单、窗口与组件抽象 | 生产 Host/Runtime/AirAppHost/Launcher 没有项目引用,也没有 SDK 程序集加载器 | +| `LanMountainDesktop.AirAppTemplate` | 基于 AirAppSdk 的模板草案 | 模板输出不会被生产桌面自动发现或加载 | +| `LanMountainDesktop.AirAppDevServer` | 文件监视、构建与打包原型 | 预览宿主仍是 TODO;没有连接生产 Runtime/Host 的调试加载协议 | + +当前 `LanMountainDesktop.slnx` 不包含这三个原型项目;生产 AirAppHost 直接判断三个内置 `appId` 并创建内置视图,没有扫描 `airapp.json`、加载第三方程序集、寻找 `[AirAppEntrance]` 或调用 AirAppSdk 生命周期。 + +### `.laapp` 包格式冲突 + +不要把 AirAppDevServer 生成的 `.laapp` 复制到生产插件目录,也不要交给 Launcher 的插件安装命令: + +- 生产代码当前把 `.laapp` 作为插件包扩展名,并要求 ZIP 中存在 `plugin.json`。 +- AirAppDevServer 原型把构建输出打成同扩展名,并由 AirAppSdk/模板使用 `airapp.json` 语义。 +- 两条路径尚未统一;只有 `airapp.json` 的原型包会被现有插件安装/发现链路拒绝,也不会被 AirAppRuntime 加载。 + +在确定独立扩展名或兼容的清单/安装路由,并实现生产加载器以前,`.laapp + airapp.json` 只能视为设计原型,不能视为可发布格式。 + +## 原型文档的使用方式 + +当前目录只保留这份状态说明。第三方 API、模板和工具的设计草案位于仓库中的 `LanMountainDesktop.AirAppSdk`、`LanMountainDesktop.AirAppTemplate` 与 `LanMountainDesktop.AirAppDevServer` 项目;其中关于模板安装、`airapp.json` 自动发现、第三方代码加载、预览、热重载、市场安装、窗口模式或 IPC 的内容都必须按 Preview 理解。若与生产源码冲突,以当前 Host → Runtime → AirAppHost 的内置链路为准。 + +可以单独构建这些项目来研究 API 或验证原型代码,但这不会让输出自动进入生产桌面: ```powershell -# 安装模板 -dotnet new install LanMountainDesktop.AirAppTemplate - -# 创建项目 -dotnet new lmd-airapp -n MyAirApp - -# 构建 -cd MyAirApp -dotnet build +dotnet build LanMountainDesktop.AirAppSdk/LanMountainDesktop.AirAppSdk.csproj +dotnet build LanMountainDesktop.AirAppTemplate/LanMountainDesktop.AirAppTemplate.csproj +dotnet build LanMountainDesktop.AirAppDevServer/LanMountainDesktop.AirAppDevServer.csproj ``` -### 项目结构 +## 第三方能力转为生产支持的最低条件 -``` -MyAirApp/ -├── MyAirApp.csproj # 项目文件 -├── Program.cs # 程序入口 -├── App.axaml # 应用定义 -├── App.axaml.cs # 应用代码 -├── Views/ # 视图目录 -│ └── MainWindow.axaml # 主窗口 -├── ViewModels/ # 视图模型 -│ └── MainWindowViewModel.cs -├── Models/ # 数据模型 -├── Services/ # 业务服务 -├── Assets/ # 资源文件 -│ └── icon.png -└── airapp.json # Air APP 清单 -``` +在文档重新标记为“已支持”前,至少需要同时完成: -### Air APP 清单 (airapp.json) +- 定义不与插件 `plugin.json` 路由冲突的包格式、安装位置和发现规则。 +- 在生产 Host/Runtime/AirAppHost 中实现并引用受支持的 SDK 契约与程序集加载路径。 +- 实现 DevServer 到真实预览/运行宿主的协议,而不是只监视并重新构建文件。 +- 增加第三方包安装、清单校验、加载、窗口打开、卸载和版本兼容的端到端测试。 -```json -{ - "Id": "com.example.myairapp", - "Name": "My Air APP", - "Version": "1.0.0", - "Author": "Your Name", - "Description": "My first Air APP", - "MinHostVersion": "1.0.0", - "Icon": "Assets/icon.png", - "WindowMode": "Standard", - "DefaultSize": { - "Width": 800, - "Height": 600 - }, - "AllowMultipleInstances": false -} -``` +这些工作不属于当前内置 Air APP Runtime 容器与 Launcher 生命周期修复。 -### 窗口模式 +## 相关资源 -| 模式 | 说明 | 适用场景 | -|------|------|---------| -| `Standard` | 标准窗口,带标题栏和边框 | 大多数应用 | -| `Borderless` | 无边框窗口,自定义标题栏 | 自定义 UI | -| `FullScreen` | 全屏窗口 | 白板、游戏 | -| `Tool` | 工具窗口,始终置顶 | 小工具 | - -## 核心概念 - -### 生命周期 - -``` -用户点击启动 - ↓ -AirAppRuntime 检查是否已运行 - ↓ -否 → 启动新进程 -是 → 激活现有窗口(如果 AllowMultipleInstances=false) - ↓ -AirAppHost 初始化 - ↓ -加载 Air APP 代码 - ↓ -显示主窗口 - ↓ -应用运行中... - ↓ -用户关闭窗口 - ↓ -AirAppHost 清理资源 - ↓ -进程退出 - ↓ -AirAppRuntime 清理注册 -``` - -### IPC 通信 - -Air APP 可以通过 IPC 与桌面宿主通信: - -```csharp -// 获取宿主设置 -var theme = await ipcClient.InvokeAsync( - "LanMountainDesktop.Host.v1", - "GetCurrentTheme" -); - -// 订阅宿主事件 -ipcClient.OnNotify("lanmountain.theme.changed", (themeData) => -{ - // 主题变更,更新 UI - ApplyTheme(themeData); -}); -``` - -## 📖 章节目录 - -### [01-Air-APP介绍.md](01-Air-APP介绍.md) -什么是 Air APP,与桌面组件的区别,应用场景 - -### [02-创建第一个AirApp.md](02-创建第一个AirApp.md) -从零创建一个简单的 Air APP,运行和调试 - -### [03-架构与生命周期.md](03-架构与生命周期.md) -Air APP 架构、运行时、生命周期管理 - -### [04-IPC通信.md](04-IPC通信.md) -与桌面宿主通信、调用服务、订阅事件 - -### [05-窗口管理.md](05-窗口管理.md) -窗口模式、大小调整、位置记忆 - -### [06-数据持久化.md](06-数据持久化.md) -保存应用状态和用户数据 - -### [07-主题适配.md](07-主题适配.md) -适配亮色/暗色主题、圆角系统 - -### [08-实战-世界时钟.md](08-实战-世界时钟.md) -完整案例:世界时钟应用 - -### [09-实战-白板.md](09-实战-白板.md) -完整案例:全屏白板应用 - -### [10-打包与发布.md](10-打包与发布.md) -打包、签名、发布到市场 - -## 💡 最佳实践 - -### 性能优化 - -- ✅ 使用虚拟化列表处理大量数据 -- ✅ 图片和资源延迟加载 -- ✅ 避免复杂的布局嵌套 -- ✅ 使用 `RenderTransform` 而非 `Margin` 做动画 -- ✅ 及时取消不需要的异步操作 - -### 用户体验 - -- ✅ 记住窗口位置和大小 -- ✅ 提供键盘快捷键 -- ✅ 优雅处理错误和异常 -- ✅ 适配不同屏幕分辨率和 DPI -- ✅ 响应主题变更 - -### 安全性 - -- ✅ 验证用户输入 -- ✅ 使用 HTTPS 进行网络请求 -- ✅ 敏感数据加密存储 -- ✅ 避免路径遍历漏洞 -- ✅ 遵循最小权限原则 - -## 🔗 相关资源 - -- [插件开发指南](../01-插件开发/) - 如果需要桌面组件 -- [整体架构](../04-架构与实现/01-整体架构.md) - 系统架构 -- [设计规范](../03-组件设计规范/) - UI 设计指南 - -## 🎯 下一步 - -- [Air APP 介绍](01-Air-APP介绍.md) - 了解 Air APP -- [创建第一个 Air APP](02-创建第一个AirApp.md) - 动手实践 -- [架构与生命周期](03-架构与生命周期.md) - 理解原理 +- [整体架构](../04-架构与实现/01-整体架构.md) - 当前生产进程职责与启动/IPC 链路 +- [插件开发指南](../01-插件开发/) - 当前已有生产加载路径的扩展方式 +- [设计规范](../03-组件设计规范/) - UI 与桌面组件设计约束 diff --git a/docs/04-架构与实现/01-整体架构.md b/docs/04-架构与实现/01-整体架构.md index 9bab4c5..d50884e 100644 --- a/docs/04-架构与实现/01-整体架构.md +++ b/docs/04-架构与实现/01-整体架构.md @@ -6,42 +6,20 @@ ### 核心模块 +```mermaid +flowchart TD + L["LanMountainDesktop.Launcher
短生命周期:OOBE / Splash / 版本选择 / 启动协调"] + H["LanMountainDesktop 主 Host
桌面组件 / 主窗口 / 托盘 / 设置"] + R["AirAppRuntime
生命周期、实例去重、进程协调"] + A["AirAppHost(按实例多进程)
内置轻应用窗口渲染"] + + L -->|"启动 Host"| H + L -->|"预启动;Host 就绪后 AttachHost(hostPid),随后退出"| R + H -->|"内置组件点击 → AirAppLauncherService → 生命周期 IPC"| R + R -->|"启动或激活"| A ``` -┌─────────────────────────────────────────────────────────────┐ -│ 用户交互层 │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │桌面组件 │ │主窗口 │ │托盘图标 │ │设置窗口 │ │ -│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ LanMountainDesktop(桌面宿主) │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ 组件系统 │ │ -│ │ 组件注册 | 组件渲染 | 布局管理 | 编辑模式 │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ 插件运行时 │ │ -│ │ 插件加载 | 生命周期 | SDK 接入 | 契约装配 │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ 核心服务层 │ │ -│ │ 设置 | 主题 | 本地化 | 日志 | 遥测 | IPC │ │ -│ └──────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↓ ↓ -┌──────────────────────┐ ┌──────────────────────────────┐ -│ LanMountainDesktop │ │ LanMountainDesktop │ -│ .AirAppRuntime │←IPC→│ .AirAppHost (多实例) │ -│ Air APP 运行时容器 │ │ Air APP 渲染进程 │ -└──────────────────────┘ └──────────────────────────────┘ - ↑ -┌──────────────────────┐ -│ LanMountainDesktop │ -│ .Launcher │ -│ 启动器 (唯一入口) │ -└──────────────────────┘ -``` + +生产环境中,桌面上的世界时钟、白板和 RSS 阅读器入口属于主 Host 的组件系统。Launcher 不承载这些组件或窗口,也不应在完成 Host PID 交接后继续作为透明/后台窗口存活。 ## 项目结构 @@ -49,20 +27,29 @@ | 项目 | 职责 | 类型 | |------|------|------| -| **LanMountainDesktop** | 桌面宿主主程序 | WPF 应用 | -| **LanMountainDesktop.Launcher** | 启动器(OOBE、Splash、版本管理) | 独立可执行 | +| **LanMountainDesktop** | 桌面宿主主程序 | Avalonia 应用 | +| **LanMountainDesktop.Launcher** | 短生命周期启动协调器(OOBE、Splash、版本管理、Runtime 预启动与 Host 交接) | 独立可执行 | | **LanMountainDesktop.AirAppRuntime** | Air APP 运行时容器 | 独立服务 | -| **LanMountainDesktop.AirAppHost** | Air APP 渲染进程 | 框架依赖应用 | +| **LanMountainDesktop.AirAppHost** | 内置 Air APP 窗口渲染进程 | 框架依赖应用 | ### SDK 和基础设施 | 项目 | 职责 | 类型 | |------|------|------| | **LanMountainDesktop.PluginSdk** | 插件 SDK | NuGet 包 | -| **LanMountainDesktop.AirAppSdk** | Air APP SDK | NuGet 包 | | **LanMountainDesktop.Shared.Contracts** | 共享契约类型 | 类库 | | **LanMountainDesktop.Shared.IPC** | IPC 基础设施 | 类库 | +### Air APP 第三方开发原型(尚未接入生产) + +| 项目 | 当前状态 | +|------|---------| +| **LanMountainDesktop.AirAppSdk** | API/清单模型原型;生产 Host、Runtime、AirAppHost 与 Launcher 均未引用或加载它 | +| **LanMountainDesktop.AirAppTemplate** | 基于 AirAppSdk 的项目模板原型;其输出不会被生产 Host 自动发现 | +| **LanMountainDesktop.AirAppDevServer** | 构建监视、预览和打包原型;预览宿主仍为 TODO,不属于生产运行路径 | + +当前生产 AirAppHost 只按 `appId` 选择编译在程序中的 `world-clock`、`whiteboard`、`rss-reader` 视图,没有第三方 `airapp.json` 扫描、程序集加载或 SDK 生命周期装配。`.laapp` 扩展名当前由插件安装链路使用并要求包内存在 `plugin.json`;AirAppDevServer 以 `airapp.json` 为基础生成的同名扩展包不能作为生产 Air APP 安装包使用。 + ### 功能模块 | 项目 | 职责 | 类型 | @@ -113,11 +100,17 @@ - 注册组件 - 显示托盘图标 ↓ -9. 将 Host PID 附加给 AirAppRuntime +9. 启动协调成功,Launcher 获得存活的 Host PID ↓ -10. 清理标记为 .destroy 的旧版本 +10. Launcher 通过 Runtime 控制 IPC 调用 AttachHost(hostPid) + - Runtime 确认 Host PID 与进程存活状态 + - 交接失败时记录诊断;Host 仍保留按需启动 Runtime 的兜底 ↓ -11. 桌面就绪 +11. 写入启动结果并关闭 Splash/Launcher 生命周期 + - 不等待 Host 进程退出 + - 不保留透明或后台 Launcher 窗口 + ↓ +12. Host 与 AirAppRuntime 独立运行,桌面就绪 ``` ### 开发环境启动 @@ -129,11 +122,34 @@ ↓ 跳过 OOBE 和版本管理 ↓ -如果 AirAppRuntime 未运行,自动启动 - ↓ 正常初始化桌面环境 + ↓ +首次点击内置轻应用入口时尝试连接 AirAppRuntime + ↓ +若 Runtime IPC 不可用,由 Host 启动 Runtime 并重试 ``` +## 内置轻应用运行链路 + +当前已上线的轻应用链路只覆盖内置世界时钟、白板和 RSS 阅读器: + +``` +Host 内的桌面组件接收点击 + ↓ +AirAppLauncherService 构造 AirAppOpenRequest + ↓ LanMountainDesktop.AirAppRuntime.v1 +IAirAppLifecycleService.OpenAsync(request) + ↓ +AirAppRuntime 解析实例键,激活已有进程或启动 AirAppHost + ↓ +AirAppHost 根据 appId 创建编译内置的窗口内容 +``` + +- 世界时钟共用 `world-clock:clock-suite:global`,RSS 阅读器共用 `rss-reader:global`;白板等其他入口按 `{appId}:{sourceComponentId}:{sourcePlacementId}` 区分实例。 +- `sourceComponentId` 与 `sourcePlacementId` 从 Host 组件透传到 AirAppHost,使白板等窗口继续使用对应组件实例的上下文与数据。 +- AirAppHost 打开后向 Runtime 注册,关闭时注销;Runtime 负责实例表、激活与回收,不负责渲染窗口。 +- 这条链路没有调用 AirAppSdk、AirAppTemplate 或 AirAppDevServer,也不能据此推断第三方 Air APP 已可加载。 + ## 核心系统 ### 1. 组件系统 @@ -241,28 +257,33 @@ Settings Root **通信拓扑**: ``` -┌────────────────┐ -│ Host │ ← 外部 IPC 入口点 -│ (主程序) │ -└────────┬───────┘ - │ IPC - ┌────┴────┐ - ↓ ↓ -┌────────┐ ┌────────────┐ -│Launcher│ │AirAppRuntime│ -└────────┘ └──────┬──────┘ - │ IPC - ┌─────┴─────┐ - ↓ ↓ - ┌─────────┐ ┌─────────┐ - │AirAppHost│ │AirAppHost│ - └─────────┘ └─────────┘ +启动阶段(Launcher 存活) + +┌──────────┐ 启动/激活 ┌──────────────┐ +│ Launcher │────────────→│ Host │ ← 外部 IPC 入口点 +└─────┬────┘ └──────┬───────┘ + │ Runtime 控制 IPC │ Air APP 生命周期 IPC + ↓ ↓ +┌──────────────────────────────────────┐ +│ AirAppRuntime │ +│ GetStatus / AttachHost / Open/Close │ +└──────────────────┬───────────────────┘ + │ 启动、激活、注册、注销 + ↓ + ┌─────────────┐ + │ AirAppHost │(按实例多进程) + └─────────────┘ + +稳定阶段(Host attach 完成) + +Launcher 已退出;Host ←IPC→ AirAppRuntime ←IPC→ AirAppHost ``` **IPC 管道**: - `LanMountainDesktop.Host.v1` - 宿主公共服务 -- `LanMountainDesktop.AirAppRuntime.v1` - Air APP 生命周期 -- `LanMountainDesktop.AirAppRuntimeControl.v1` - Runtime 控制 +- `LanMountainDesktop.AirAppRuntime.v1` - 同一 Runtime 管道上的 Air APP 生命周期服务与 Runtime 控制服务 + +`IAirAppLifecycleService` 与 `IAirAppRuntimeControlService` 都注册在 `LanMountainDesktop.AirAppRuntime.v1`。`LanMountainDesktop.AirAppRuntimeControl.v1` 不是当前代码中的独立管道名称。 ## 数据流