launchApp function
Launches a Flutter app as a detached background process and waits for the VM service URI to appear in the log.
Progress messages (heartbeats and warnings) are emitted via onProgress.
Heartbeats are plain tokens (e.g. "WAITING..."); warnings are prefixed with
"WARNING: " so adapters can route them to the appropriate output channel.
Never throws. All error conditions are represented as sealed result cases.
Implementation
Future<LaunchResult> launchApp(
LaunchInput input, {
void Function(String) onProgress = _noop,
}) async {
try {
final device = input.device;
final project = input.project ?? Directory.current.path;
final flavor = input.flavor;
final target = input.target;
final flutterSdk = input.flutterSdk;
final verbose = input.verbose;
if (device == null) return const LaunchMissingDevice();
// Point all session files at <project>/.fdb/
initSessionDir(project);
// Kill any previous log collector.
final oldCollectorPid = readLogCollectorPid();
if (oldCollectorPid != null && isProcessAlive(oldCollectorPid)) {
try {
Process.killPid(oldCollectorPid, ProcessSignal.sigterm);
} catch (_) {}
}
// Clean up previous state.
for (final path in [
pidFile,
appPidFile,
logFile,
logCollectorPidFile,
logCollectorScript,
vmUriFile,
launcherScript,
deviceFile,
platformFile,
]) {
final f = File(path);
if (f.existsSync()) f.deleteSync();
}
// Create .fdb/ session directory and persist device ID.
ensureSessionDir();
_ensureGitignored(project);
File(deviceFile).writeAsStringSync(device);
// Resolve the flutter binary: explicit --flutter-sdk, FVM auto-detect, or PATH.
final flutter = _resolveFlutter(project, flutterSdk, onProgress);
// Resolve and persist the target platform + emulator flag for this device.
// Used by `fdb screenshot` to dispatch to the correct capture backend.
// Non-fatal: screenshot falls back to the old heuristic if this fails.
await _writePlatformInfo(device, flutter);
// Persist the app bundle id / package name for later use by crash-report.
// Non-fatal: crash-report will ask the user for --app-id if this fails.
_writeAppIdFromProject(project, device);
// Build the flutter run command string.
final flutterArgs = [
flutter,
'run',
'-d',
device,
'--debug',
'--pid-file',
pidFile,
if (flavor != null) ...['--flavor', flavor],
if (target != null) ...['--target', target],
if (verbose) '--verbose',
];
final flutterCmd = flutterArgs.map(_shellEscape).join(' ');
// Write a launcher script that runs flutter in the foreground (nohup keeps
// it alive after the parent exits, and & backgrounds it from our perspective).
final script = '''
#!/bin/bash
cd ${_shellEscape(project)}
exec $flutterCmd > $logFile 2>&1
''';
File(launcherScript).writeAsStringSync(script);
Process.runSync('chmod', ['+x', launcherScript]);
// Launch via nohup + & so the process is fully detached from this parent.
final result = await Process.run('bash', [
'-c',
'nohup bash $launcherScript &\necho \$!',
]);
if (result.exitCode != 0) {
return LaunchLauncherFailed(result.stderr as String);
}
final launcherPid = int.tryParse((result.stdout as String).trim());
if (launcherPid == null) return const LaunchInvalidLauncherPid();
// Poll log file for VM service URI.
final stopwatch = Stopwatch()..start();
var lastHeartbeat = 0;
String? vmUri;
while (stopwatch.elapsed.inSeconds < launchTimeoutSeconds) {
await Future<void>.delayed(const Duration(milliseconds: pollIntervalMs));
// Heartbeat so the caller knows we're not stuck.
final elapsedSeconds = stopwatch.elapsed.inSeconds;
if (elapsedSeconds ~/ heartbeatIntervalSeconds > lastHeartbeat) {
lastHeartbeat = elapsedSeconds ~/ heartbeatIntervalSeconds;
onProgress('WAITING...');
}
// Check if the launcher process died unexpectedly.
if (!_isAlive(launcherPid)) {
final logExists = File(logFile).existsSync();
if (logExists) {
final logContent = File(logFile).readAsStringSync();
return LaunchProcessDied(fullLog: logContent);
} else {
return const LaunchProcessDied(noLogFile: true);
}
}
if (!File(logFile).existsSync()) continue;
final logContent = File(logFile).readAsStringSync();
vmUri = _extractVmUri(logContent);
if (vmUri != null) break;
}
if (vmUri == null) {
final tailLogLines = <String>[];
if (File(logFile).existsSync()) {
final lines = File(logFile).readAsLinesSync();
tailLogLines.addAll(
lines.length > 10 ? lines.sublist(lines.length - 10) : lines,
);
}
return LaunchTimeout(tailLogLines: tailLogLines);
}
// Save VM service URI.
File(vmUriFile).writeAsStringSync(vmUri);
// Read PID — flutter writes it via --pid-file, fall back to launcher PID.
final pid = File(pidFile).existsSync() ? File(pidFile).readAsStringSync().trim() : launcherPid.toString();
// Start the log collector — a background process that subscribes to the
// VM service Logging/Stdout/Stderr streams and appends to the log file.
// flutter run only forwards print() to stdout; developer.log() events are
// only available via the VM service, so this fills that gap.
await _startLogCollector(vmUri, onProgress);
// Retrieve the app VM PID via getVM and persist it to fdb.app_pid.
// This is the Dart VM process PID (different from the flutter-tools PID in
// fdb.pid). Used by vmServiceCall for liveness detection on macOS desktop.
// Non-fatal: if getVM fails for any reason, fdb.app_pid is simply not written
// and vmServiceCall falls back to the flutter-tools PID heuristic.
await _writeAppPid();
return LaunchSuccess(
vmServiceUri: vmUri,
pid: pid,
logFilePath: logFile,
);
} catch (e) {
return LaunchError(e.toString());
}
}