portico/Sources/Portico/App/PorticoRuntime.swift

214 lines
9.3 KiB
Swift

import Adw
import Gtk
import Gio
import Synchronization
#if canImport(Glibc)
import Glibc
#elseif canImport(Darwin)
import Darwin
#endif
/// The runtime that boots a Portico ``App``.
///
/// Creates an `Adw.Application`, wires the ``Scene`` tree via
/// `connectActivate`, runs the GTK main loop, and propagates the exit
/// code to the process.
///
/// On Linux, installing ``GLibMainExecutor`` as the process main executor
/// here means that every `Task { }`, `await`, and main-actor resumption in a
/// Portico app is drained by `g_application_run` directly. On Darwin, where
/// that install path needs a stdlib SPI Apple does not ship,
/// ``DarwinMainQueuePump`` drains the stdlib's default main executor instead
/// by periodically running `CFRunLoop` from a GLib timeout source - see its
/// doc comment for why. Either way, no boilerplate in user code and no
/// change to the GTK application lifecycle.
@_spi(Portico) @MainActor public enum PorticoRuntime {
/// The environment an ``App``'s ``DynamicProperty`` declarations resolve against.
///
/// Deliberately empty: there is no App-level `.environment(_:)` injection point, so every
/// key-path slot resolves to the same process-wide default box a view-level `@Environment`
/// sees when no ancestor injected that slot. An App-level declaration and an uninjected
/// view-level declaration of the same key therefore share one ``StateBox``, which is what
/// lets a write from deep in the tree re-evaluate a scene conditional.
@_spi(Portico) public static let rootEnvironment = EnvironmentValues()
private static var shutdownHandlers: [@MainActor () async -> Void] = []
private static var application: Adw.Application?
private static var isDraining = false
@_spi(Portico) public private(set) static var didDrain = false
private static let drainDone = Mutex<Bool>(false)
private static var drainHandle: _Concurrency.Task<Void, Never>?
private static var drainTaskHandle: _Concurrency.Task<Void, Never>?
/// Windows with a shutdown gate installed, keyed by wrapper identity so a
/// window is retired exactly once no matter which path retires it: a
/// `close-request` through the gate, or ``SceneHandle/dismantle()``, whose
/// `gtk_window_destroy` emits no `close-request` at all.
private static var gatedWindows: Set<ObjectIdentifier> = []
/// Number of gate-equipped windows still open. The shutdown drain starts
/// only when the last one is retired.
@_spi(Portico) public static var _openWindowCount: Int { gatedWindows.count }
/// Bounded total shutdown drain time.
@_spi(Portico) public static var _drainBound: Duration = .seconds(5)
/// Binds every ``DynamicProperty`` stored on `app` to ``rootEnvironment``.
///
/// Called once by ``run(_:)`` before anything reads `applicationId`, `body`, or `shutdown()`.
/// Property wrappers keep their resolution in shared reference storage, so resolving the copy
/// passed here also resolves the instance ``run(_:)`` captures.
///
/// - Parameter app: The application value whose stored properties are bound.
@_spi(Portico) public static func _resolveRootProperties<A: App>(_ app: A) {
_resolveDynamicProperties(app, in: rootEnvironment)
}
/// Registers one asynchronous shutdown handler in registration order.
@_spi(Portico) public static func _register(
_ work: @escaping @MainActor () async -> Void
) {
shutdownHandlers.append(work)
}
/// Resets process-global shutdown state for isolated tests, cancelling any
/// in-flight drain watchdog and handler task.
@_spi(Portico) public static func _resetShutdownState() {
shutdownHandlers.removeAll()
application = nil
isDraining = false
didDrain = false
gatedWindows.removeAll()
drainDone.withLock { $0 = false }
drainHandle?.cancel()
drainHandle = nil
drainTaskHandle?.cancel()
drainTaskHandle = nil
}
/// Installs the close-request handler that drives the shutdown drain.
///
/// The drain starts only when the last open window requests close: a close
/// request on a non-last window is allowed through immediately (the window
/// closes without tearing down shared resources). The first close request on
/// the last window is vetoed while registered handlers and the app shutdown
/// hook run; a close request during that drain is also vetoed. Once the
/// drain completes or reaches its bound, the window is closed again and
/// this handler allows the completing close request through.
@_spi(Portico) public static func _installShutdownGate(
on window: Adw.ApplicationWindow
) {
let key = ObjectIdentifier(window)
guard gatedWindows.insert(key).inserted else { return }
_ = window.connectCloseRequest { _ in
if PorticoRuntime.didDrain { return false }
if PorticoRuntime.isDraining { return true }
// Non-last window: let it close without starting the drain.
PorticoRuntime.retireGate(key)
if !PorticoRuntime.gatedWindows.isEmpty { return false }
// Last window: veto now and drain handlers while the loop spins.
PorticoRuntime.isDraining = true
PorticoRuntime.drainDone.withLock { $0 = false }
PorticoRuntime.application?.hold()
PorticoRuntime.drainHandle = _Concurrency.Task {
await PorticoRuntime.runDrain(on: window)
}
return true
}
}
/// Retires `window`'s shutdown gate without closing it.
///
/// Called by ``SceneHandle/dismantle()``: `gtk_window_destroy` emits no
/// `close-request`, so the gate handler never runs and the window would
/// otherwise be counted open forever. Idempotent.
@_spi(Portico) public static func _retireShutdownGate(
on window: Adw.ApplicationWindow
) {
retireGate(ObjectIdentifier(window))
}
/// Drops one gated window. Idempotent - a repeat retire is a no-op, which
/// is what makes the two retire paths safe to overlap.
private static func retireGate(_ key: ObjectIdentifier) {
gatedWindows.remove(key)
}
/// Drains registered handlers while the GLib main loop continues pumping.
///
/// The watchdog polls a completion flag on a short cadence instead of
/// awaiting the drain task, so a non-cancellable handler cannot prevent
/// the window from closing. The poll cadence is clamped to the remaining
/// bound so `_drainBound` is honored as a deadline. If the watchdog is
/// cancelled (by ``_resetShutdownState``), it leaves process state alone so
/// the reset path owns cleanup.
private static func runDrain(on window: Adw.ApplicationWindow) async {
PorticoRuntime.drainTaskHandle = _Concurrency.Task { @MainActor () -> Void in
for handler in PorticoRuntime.shutdownHandlers {
await handler()
}
PorticoRuntime.drainDone.withLock { $0 = true }
}
guard let drain = PorticoRuntime.drainTaskHandle else { return }
let pollInterval: Duration = .milliseconds(25)
var elapsed: Duration = .zero
while !PorticoRuntime.drainDone.withLock({ $0 })
&& elapsed < PorticoRuntime._drainBound
&& !_Concurrency.Task.isCancelled
{
let remaining = PorticoRuntime._drainBound - elapsed
let sleep = remaining < pollInterval ? remaining : pollInterval
_ = try? await _Concurrency.Task.sleep(for: sleep)
elapsed += sleep
}
// Cancelled by a reset: leave state for the reset path to own.
if _Concurrency.Task.isCancelled {
drain.cancel()
return
}
drain.cancel()
PorticoRuntime.didDrain = true
PorticoRuntime.application?.release()
window.close()
}
/// Boots the application, creates an `Adw.Application`, wires the
/// scene tree via `connectActivate`, runs the GTK main loop, and
/// terminates the process with the returned exit code.
///
/// - Precondition: `app.body`, and every branch of a conditional in it,
/// must conform to ``MountableScene``.
/// - Parameter app: The ``App`` instance to run.
public static func run<A: App>(_ app: A) {
#if canImport(Glibc)
GLibExecutorFactory.install()
#elseif canImport(Darwin)
DarwinMainQueuePump.install()
#endif
PorticoRuntime._resolveRootProperties(app)
let adwApp = Adw.Application(
applicationId: app.applicationId,
flags: .defaultFlags
)
PorticoRuntime.application = adwApp
// Register the app's own shutdown hook exactly once. ``App.init``
// runs at `Self()` construction - before ``run(_:)`` is entered - so
// any handler it registered via ``ApplicationLifecycle.onShutdown``
// is already queued by this point; appending here keeps the app hook
// last while ensuring a repeated `activate` (single-instance app) does
// not re-append it.
PorticoRuntime._register { await app.shutdown() }
let host = SceneHost(app: adwApp) { app.body }
_ = adwApp.connectActivate { _ in host.start() }
exit(adwApp.run(argv: CommandLine.arguments))
}
}