// System-level actions Studio invokes by spawning the `fai` // binary or asking the OS to open a file. Used by Doctor's // daemon-control + open-paths affordances so Windows operators // who never touch a shell can restart the daemon, apply an // update, or open a log file from inside the GUI. // // Cross-platform notes: // - macOS uses `open` for files + URLs. // - Linux uses `xdg-open`. // - Windows uses `explorer` (file manager) and a quoted CLI // call for processes. // // The `fai` binary is resolved from PATH first; falls back to // `~/.fai/bin/fai` (Unix) or `%USERPROFILE%\.fai\bin\fai.exe` // (Windows). The PowerShell installer puts both in PATH but // some operators don't restart their shell after install — the // fallback covers that case. import 'dart:io'; class SystemActions { SystemActions._(); /// Ask the OS to open [path] in the default handler. On macOS /// this opens text files in TextEdit, configs in the registered /// editor, etc. Returns true on a clean spawn (process exited /// non-zero is still surfaced as false). static Future<({bool ok, String stderr})> openInOs(String path) async { if (path.isEmpty) { return (ok: false, stderr: 'empty path'); } final cmd = _openCommand(); try { final r = await Process.run(cmd.executable, [...cmd.args, path]); if (r.exitCode == 0) return (ok: true, stderr: ''); return (ok: false, stderr: r.stderr.toString().trim()); } catch (e) { return (ok: false, stderr: e.toString()); } } /// Run a `fai daemon ...` subcommand and surface the captured /// output. Used by Doctor's "Restart daemon" button. static Future<({bool ok, String stdout, String stderr})> faiDaemon( List args, ) async { return _runFai(['daemon', ...args]); } /// Run `fai update apply --channel `. Long-running on a slow /// network — caller should show a spinner. static Future<({bool ok, String stdout, String stderr})> faiUpdateApply( String channel, ) async { return _runFai(['update', 'apply', '--channel', channel]); } /// Switch the active channel pointer at `~/.fai/current-channel`. /// The CLI also restarts the daemon for the new channel, so the /// caller does not need a follow-up restart. static Future<({bool ok, String stdout, String stderr})> faiChannelSwitch( String channel, ) async { return _runFai(['channel', 'switch', channel]); } /// Install the platform-native autostart unit for [channel] /// (launchd plist on macOS, systemd-user unit on Linux). On /// Windows the platform CLI returns a "not supported" exit /// status that the caller surfaces as an error message. static Future<({bool ok, String stdout, String stderr})> faiDaemonEnable( String channel, ) async { return _runFai(['daemon', 'enable', '--channel', channel]); } /// Remove the autostart unit installed by [faiDaemonEnable]. static Future<({bool ok, String stdout, String stderr})> faiDaemonDisable( String channel, ) async { return _runFai(['daemon', 'disable', '--channel', channel]); } static Future<({bool ok, String stdout, String stderr})> _runFai( List args, ) async { final exe = _faiExecutable(); if (exe == null) { return ( ok: false, stdout: '', stderr: 'Could not locate the `fai` binary. Install via the ' 'platform installer or set FAI_BIN to its full path.', ); } try { final r = await Process.run(exe, args); return ( ok: r.exitCode == 0, stdout: r.stdout.toString(), stderr: r.stderr.toString(), ); } catch (e) { return (ok: false, stdout: '', stderr: e.toString()); } } static ({String executable, List args}) _openCommand() { if (Platform.isMacOS) return (executable: 'open', args: const []); if (Platform.isWindows) return (executable: 'explorer', args: const []); return (executable: 'xdg-open', args: const []); } /// Locate the `fai` binary. Order: $FAI_BIN, PATH, fallback to /// the canonical install location under the user's home dir. static String? _faiExecutable() { final fromEnv = Platform.environment['FAI_BIN']; if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) { return fromEnv; } final isWindows = Platform.isWindows; final fromPath = _whichFai(isWindows ? 'fai.exe' : 'fai'); if (fromPath != null) return fromPath; final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME']; if (home == null || home.isEmpty) return null; final fallback = isWindows ? '$home\\.fai\\bin\\fai.exe' : '$home/.fai/bin/fai'; return File(fallback).existsSync() ? fallback : null; } /// Tiny PATH walker — we cannot rely on `which`/`where` being /// reachable via Process.run on every platform. static String? _whichFai(String name) { final pathEnv = Platform.environment['PATH']; if (pathEnv == null || pathEnv.isEmpty) return null; final sep = Platform.isWindows ? ';' : ':'; final slash = Platform.isWindows ? '\\' : '/'; for (final dir in pathEnv.split(sep)) { if (dir.isEmpty) continue; final candidate = '$dir$slash$name'; if (File(candidate).existsSync()) return candidate; } return null; } }