runApp function
- Widget root, {
- TerminalDriver? driver,
- TerminalMode mode = TerminalMode.interactive,
- bool enableHotReload = true,
- bool requireInteractiveTerminal = true,
- void onStrayOutput(
- LogLine line
- TuiEventHandler? onEvent,
- Clipboard? clipboard,
- Duration sequenceTimeout = const Duration(milliseconds: 500),
- DebugConfig debug = const DebugConfig(),
- Duration frameInterval = Duration.zero,
- List<
String> args = const [],
Runs an fleury application.
This thin wrapper exists for one safety property: if ANYTHING escapes the implementation as a throw — including setup failures before the guarded zone (and its cleanup) are established — the fd-level stray-output capture is stopped so fd 1/2 point back at the real terminal. Without it, an embedding caller that catches the throw and keeps running would find its process's stdout/stderr silently swallowed. Once normal cleanup takes ownership, only that operation stops capture, after terminal borrows settle.
Own your shutdown
Resolves with an AppExit saying why the app ended, AFTER the terminal is restored. The caller owns process-exit semantics. A terminal-critical cleanup failure throws FleuryError instead. Overlapping invocations are rejected, including while an earlier cleanup is unfinished.
try {
final result = await runApp(app);
io.exitCode = switch (result.signal) {
AppSignal.interrupt => 130,
AppSignal.terminate => 143,
AppSignal.hangup => 129,
null => 0,
};
} finally {
await host.shutdown(); // resources owned by your command
}
SIGINT/SIGTERM/SIGHUP arrive as SignalEvents;
an unclaimed one terminates with AppExit.signal. The POSIX driver arms a
grace deadline at delivery (PosixTerminalDriver.signalGrace, default 5s)
and force-terminates a hung app — a second same-signal forces immediately —
so claiming a signal obliges finishing within the grace.
To keep the UI visible during cleanup, claim the signal with EventHandled
and call exitApp when finished. Preserve the original signal yourself:
that later exit returns AppExit.requested. Raw Ctrl+C reaches widget key
bindings first; if unhandled it exits before onEvent with an interrupt.
Ctrl+Z follows the same rule in a native POSIX terminal: a focused text
field undoes, an app binding fires, and only a press nothing handled
suspends (before onEvent) — when a job-control shell started the app.
It stops the whole job the shell started — with the hot-reload supervisor
of a plain dart run, or the fleury run launcher, that runs the app —
until the shell's fg. Started without one (a terminal emulator, a tmux
pane, or ssh -t host app running the app directly), the press is an
ordinary key: nothing could bring a stopped app back. While the debug
shell is expanded over the app, the press skips the hidden app. See
PosixTerminalDriver.suspendOnCtrlZ.
onStrayOutput takes ownership of captured output instead of replaying it
after exit. A throwing hook is disabled and reported through the runtime
error handler; its failed line and subsequent output return to normal replay.
Earlier successfully handled lines are not replayed a second time.
root is mounted exactly as supplied. The runtime owns terminal and
framework host services, but it does not choose an application shell.
Wrap the root in FleuryApp(home: ...) for Fleury's standard navigation
shell, or supply a Navigator/custom shell explicitly. Bare single-screen
roots remain valid.
Pass your main() argv as args if the app reads it. Under a plain
dart run, the hot-reload supervisor runs the app in a child process, and
a process cannot portably recover its own script arguments, so without this
the app sees an empty argv from its first launch and after every restart.
(fleury run passes the arguments itself.)
Future<void> main(List<String> args) => runApp(MyApp(), args: args);
Implementation
Future<AppExit> runApp(
Widget root, {
TerminalDriver? driver,
TerminalMode mode = TerminalMode.interactive,
bool enableHotReload = true,
bool requireInteractiveTerminal = true,
void Function(LogLine line)? onStrayOutput,
TuiEventHandler? onEvent,
Clipboard? clipboard,
Duration sequenceTimeout = const Duration(milliseconds: 500),
DebugConfig debug = const DebugConfig(),
Duration frameInterval = Duration.zero,
List<String> args = const [],
}) async {
// Acquire synchronously, before bootstrap awaits or descriptor capture.
// Queuing an overlapping call would deadlock a nested, awaited runApp.
final invocation = _RunAppInvocation();
try {
// Plain `dart run` dev sessions hand the process to the dev supervisor:
// it re-spawns this same script as a child process with the VM service
// enabled (the child re-enters runApp and takes the classic path below),
// and drives save-to-reload + hot restart from outside the app. Every
// other kind of run — tests (injected driver), AOT, Windows, no TTY,
// serve handles, editor/debugger sessions with a live VM service — falls
// through untouched, and (shouldConsider being synchronous) with runApp's
// original synchronous prefix intact.
if (DevBootstrap.shouldConsider(
driverInjected: driver != null,
enableHotReload: enableHotReload,
)) {
await DevBootstrap.runOrFallThrough(args: args);
// Reaching here means this run was ineligible after async checks or the
// bootstrap could not start; run classically.
}
// The supervised child's half of the handshake: silently self-enable the
// VM service and tell the supervisor where it is. Fire-and-forget; a
// no-op outside supervised sessions.
DevBootstrap.maybeStartSupervisedChildHandshake();
fd.Stdio? cap;
var captureCleanupOwned = false;
try {
return await runZoned(
() => _runAppImpl(
root,
invocation: invocation,
driver: driver,
mode: mode,
enableHotReload: enableHotReload,
requireInteractiveTerminal: requireInteractiveTerminal,
onStrayOutput: onStrayOutput,
onEvent: onEvent,
clipboard: clipboard,
sequenceTimeout: sequenceTimeout,
debug: debug,
frameInterval: frameInterval,
onFdCaptureStarted: (c) => cap = c,
onFdCaptureCleanupOwned: () => captureCleanupOwned = true,
),
zoneValues: {_invocationZoneKey: invocation},
);
} on Object {
invocation.closing = true;
final c = cap;
// Once normal cleanup owns capture, its actual operation can outlive a
// reporting timeout. Stopping it here would bypass the terminal/child
// barrier and close the saved output handle while it is still borrowed.
if (c != null && c.isActive && !captureCleanupOwned) {
try {
await invocation
.restoreCritical('stdio capture', c.stop)
.timeout(_stdioCleanupResourceTimeout);
} catch (_) {
throw _terminalCleanupError(['stdio capture']);
}
}
rethrow;
}
} finally {
invocation.release();
}
}