214 lines
9.3 KiB
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))
|
|
}
|
|
}
|