// 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 // `~/.chain/bin/chain` (Unix) or `%USERPROFILE%\.chain\bin\chain.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'; import 'package:shared_preferences/shared_preferences.dart'; /// Sentinel returned in `_runFai(...).stderr` when no `fai` /// binary could be located. UI layers should not display this /// verbatim — they check [SystemActions.faiBinaryExists] first /// and render a localized, actionable message instead. const String kFaiBinaryNotFound = 'fai-binary-not-found'; /// Getting-started / install guide for the Ch∆In platform. Opened /// in the OS browser as the fallback affordance when no `fai` /// binary can be located on the machine. const String kFaiInstallDocsUrl = 'https://git.flemming.ai/fai/chain#installation'; class SystemActions { SystemActions._(); /// SharedPreferences key for an operator-chosen `fai` binary /// path (set via the file picker when auto-detection fails). static const String _kFaiBinaryPrefKey = 'system.fai_binary_path'; /// In-memory cache of the operator override, loaded once at /// startup by [loadFaiBinaryOverride]. Null = no override. static String? _faiBinaryOverride; /// Restore the persisted `fai` binary override (if any) so it /// is available before the first daemon action. Call once at /// app start, alongside the other persisted-state loaders. static Future loadFaiBinaryOverride() async { final prefs = await SharedPreferences.getInstance(); final p = prefs.getString(_kFaiBinaryPrefKey); if (p != null && p.isNotEmpty && File(p).existsSync()) { _faiBinaryOverride = p; } } /// Persist an operator-chosen `fai` binary path. Used by the /// "Locate `fai` binary…" affordance when auto-detection on /// PATH and the canonical install dir both come up empty. /// Returns false (and persists nothing) when [path] is empty /// or does not point at an existing file. static Future setFaiBinaryPath(String path) async { if (path.isEmpty || !File(path).existsSync()) return false; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kFaiBinaryPrefKey, path); _faiBinaryOverride = path; return true; } /// True when a `fai` binary can be located (override, PATH, or /// the canonical install dir). UI uses this to decide between /// the normal daemon-control affordances and the "locate the /// binary / read the install guide" recovery path. static bool faiBinaryExists() => _faiExecutable() != null; /// 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()); } } /// Reveal [path] in the platform file manager. Works for binary /// files (the SQLite audit DB, the PID file) where `openInOs` /// would fall flat — macOS refuses to "open" a file with no /// registered app. macOS uses `open -R`; Windows /// `explorer /select,`; Linux falls back to opening the parent /// directory because there is no portable reveal-and-highlight. static Future<({bool ok, String stderr})> revealInOs(String path) async { if (path.isEmpty) { return (ok: false, stderr: 'empty path'); } try { final ProcessResult r; if (Platform.isMacOS) { r = await Process.run('open', ['-R', path]); } else if (Platform.isWindows) { // /select, requires no space after the comma; explorer // returns 1 even on success, so don't gate on exitCode. r = await Process.run('explorer', ['/select,$path']); return (ok: true, stderr: ''); } else { // Linux: open the containing directory in the default // file manager. Best portable approximation. final parent = File(path).parent.path; r = await Process.run('xdg-open', [parent]); } if (r.exitCode == 0) return (ok: true, stderr: ''); return (ok: false, stderr: r.stderr.toString().trim()); } catch (e) { return (ok: false, stderr: e.toString()); } } /// Convenience: reveal binary files, open directories / /// text-shaped paths normally. Doctor's path rows use this so /// the operator never has to know which strategy fits which /// kind of file. static Future<({bool ok, String stderr})> openOrReveal( String path, { required bool isDirectory, }) async { if (isDirectory) return openInOs(path); return revealInOs(path); } /// Run a `chain 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 `chain 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 `~/.chain/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]); } /// Run `chain reset --yes`. Wipes operator state with the same /// safety net as the CLI: stops every daemon (including off- /// channel orphans), atomically backs `~/.chain/` up, recreates /// it with `bin/`, `channels/`, `config.yaml`, `current-channel`, /// and `registry-token` preserved. The daemon restarts on the /// active channel before the call returns. /// /// Studio's gRPC channel goes down for ~1s while the daemon /// restarts — caller should follow up with a reconnect. static Future<({bool ok, String stdout, String stderr})> faiReset({ bool keepModules = false, bool keepData = false, }) async { final args = ['reset', '--yes']; if (keepModules) args.add('--keep-modules'); if (keepData) args.add('--keep-data'); return _runFai(args); } static Future<({bool ok, String stdout, String stderr})> _runFai( List args, ) async { final exe = _faiExecutable(); if (exe == null) { // Sentinel, not a user-facing string. Callers detect this // via `faiBinaryExists()` and render a localized, // actionable recovery message (locate binary / install // guide) instead of CLI jargon like "set FAI_BIN". return (ok: false, stdout: '', stderr: kFaiBinaryNotFound); } 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: operator override (set via /// the file picker), $FAI_BIN, PATH, fallback to the canonical /// install location under the user's home dir. static String? _faiExecutable() { final override = _faiBinaryOverride; if (override != null && override.isNotEmpty && File(override).existsSync()) { return override; } 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\\.chain\\bin\\chain.exe' : '$home/.chain/bin/chain'; 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; } }