chain-studio/lib/data/system_actions.dart
flemming-it 14f9217007 feat(studio): show what a running update is doing, not just that it runs
Applying an update downloads and swaps a binary and takes minutes; the
doctor showed a spinner inside a disabled button for the whole time.
SystemActions gained a streaming CLI runner that hands each line to the
caller as it arrives, with CHAIN_PLAIN=1 set for the child so the CLI
emits one line per transition instead of its redraw-in-place block.

The update card now shows an indeterminate bar labelled with the
running step and an elapsed counter on its own timer, so the counter
keeps moving between lines that can be minutes apart. No invented
percentage: the CLI reports steps, not a measurable total.

The streaming path still defers to debugRunFaiOverride, so tests stay
hermetic and never spawn a process; three guards pin that.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 17:41:21 +02:00

395 lines
16 KiB
Dart

// 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:convert';
import 'dart:io';
import 'package:meta/meta.dart';
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.chainBinaryExists] 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.chain_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<void> 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<bool> 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 chainBinaryExists() => _faiExecutable() != null;
/// Test seam: when set, [_runFai] returns this function's result
/// instead of spawning a real process, and [resolvedChainBinary] /
/// [chainBinaryExists] answer from [debugResolveOverride]. Lets
/// widget tests drive the CLI error paths deterministically.
@visibleForTesting
static Future<({bool ok, String stdout, String stderr})> Function(
List<String> args,
)?
debugRunFaiOverride;
/// Test seam companion to [debugRunFaiOverride]: overrides binary
/// resolution (may return null to simulate "no binary found").
@visibleForTesting
static String? Function()? debugResolveOverride;
/// Absolute path of the `chain` binary Studio would execute right
/// now, or null when none can be located. Public so surfaces that
/// are about to spawn the binary (the guided-setup wizard) can say
/// WHAT they will run — before macOS asks the operator for folder
/// permission because of where that binary happens to live.
static String? resolvedChainBinary() => _faiExecutable();
/// 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})> chainDaemon(
List<String> args,
) async {
return _runFai(['daemon', ...args]);
}
/// Run a `chain init ...` subcommand. Used by the guided-setup
/// wizard to preview a plan (`--answers <file>`) and apply it
/// (`--answers <file> --apply --force`).
static Future<({bool ok, String stdout, String stderr})> chainInit(
List<String> args,
) async {
return _runFai(['init', ...args]);
}
/// Run `chain update apply --channel <c>`. Long-running on a slow
/// network — caller should show a spinner.
static Future<({bool ok, String stdout, String stderr})> chainUpdateApply(
String channel, {
void Function(String line)? onLine,
}) async {
// Streamed: applying an update downloads and swaps a binary and
// takes minutes. Without the lines, the operator stares at a
// spinner with no way to tell slow from stuck.
return _runFaiStreaming(['update', 'apply', '--channel', channel], onLine);
}
/// 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})> chainChannelSwitch(
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})> chainDaemonEnable(
String channel,
) async {
return _runFai(['daemon', 'enable', '--channel', channel]);
}
/// Remove the autostart unit installed by [chainDaemonEnable].
static Future<({bool ok, String stdout, String stderr})> chainDaemonDisable(
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})> chainReset({
bool keepModules = false,
bool keepData = false,
}) async {
final args = <String>['reset', '--yes'];
if (keepModules) args.add('--keep-modules');
if (keepData) args.add('--keep-data');
return _runFai(args);
}
/// Start a sealed project's isolated hub instance
/// (`chain project start <slug>`). Detached daemon on the
/// instance's fixed port — returns once the CLI has spawned it.
/// Used by Studio's workspace switch when the operator selects a
/// stopped sealed area.
static Future<({bool ok, String stdout, String stderr})> chainProjectStart(
String slug,
) async {
return _runFai(['project', 'start', slug]);
}
/// Import the bundled sample flows (`chain flows import-samples`;
/// existing files are kept). With [sealedSlug] the import targets
/// that sealed instance's own data/modules dirs — sealed areas
/// start without samples, this is the deliberate pull.
static Future<({bool ok, String stdout, String stderr})>
chainFlowsImportSamples({String? sealedSlug}) async {
if (sealedSlug == null || sealedSlug.isEmpty) {
return _runFai(['flows', 'import-samples']);
}
final home =
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'';
final sep = Platform.pathSeparator;
final root = '$home$sep.chain${sep}sealed$sep$sealedSlug';
return _runFai([
'flows',
'import-samples',
'--data-dir',
'$root${sep}data',
'--modules-dir',
'$root${sep}modules',
]);
}
static Future<({bool ok, String stdout, String stderr})> _runFai(
List<String> args,
) async {
final runOverride = debugRunFaiOverride;
if (runOverride != null) return runOverride(args);
final exe = _faiExecutable();
if (exe == null) {
// Sentinel, not a user-facing string. Callers detect this
// via `chainBinaryExists()` and render a localized,
// actionable recovery message (locate binary / install
// guide) instead of CLI jargon like "set CHAIN_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());
}
}
/// Like [_runFai], but hands each stdout/stderr line to [onLine] as
/// it arrives. Falls back to the buffered path when a test override
/// is installed, so tests stay hermetic and never spawn a process.
///
/// `CHAIN_PLAIN=1` is set for the child: the CLI's redraw-in-place
/// block is unreadable when captured, and the plain mode emits one
/// line per transition, which is exactly what a caller can show.
static Future<({bool ok, String stdout, String stderr})> _runFaiStreaming(
List<String> args,
void Function(String line)? onLine,
) async {
final runOverride = debugRunFaiOverride;
if (runOverride != null) return runOverride(args);
if (onLine == null) return _runFai(args);
final exe = _faiExecutable();
if (exe == null) {
return (ok: false, stdout: '', stderr: kFaiBinaryNotFound);
}
try {
final process = await Process.start(
exe,
args,
environment: {'CHAIN_PLAIN': '1'},
);
final out = StringBuffer();
final err = StringBuffer();
Stream<String> lines(Stream<List<int>> raw) =>
raw.transform(utf8.decoder).transform(const LineSplitter());
final stdoutDone = lines(process.stdout).listen((line) {
out.writeln(line);
if (line.trim().isNotEmpty) onLine(line.trim());
}).asFuture<void>();
final stderrDone = lines(process.stderr).listen((line) {
err.writeln(line);
if (line.trim().isNotEmpty) onLine(line.trim());
}).asFuture<void>();
final code = await process.exitCode;
await Future.wait([stdoutDone, stderrDone]);
return (ok: code == 0, stdout: out.toString(), stderr: err.toString());
} catch (e) {
return (ok: false, stdout: '', stderr: e.toString());
}
}
static ({String executable, List<String> 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), $CHAIN_BIN, PATH, fallback to the canonical
/// install location under the user's home dir.
static String? _faiExecutable() {
final resolveOverride = debugResolveOverride;
if (resolveOverride != null) return resolveOverride();
final override = _faiBinaryOverride;
if (override != null &&
override.isNotEmpty &&
File(override).existsSync()) {
return override;
}
final fromEnv = Platform.environment['CHAIN_BIN'];
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
return fromEnv;
}
final isWindows = Platform.isWindows;
// Post-rename the entry-point binary on PATH is `chain`; still
// accept a legacy `fai` for installs that predate the rename.
final fromPath =
_whichFai(isWindows ? 'chain.exe' : 'chain') ??
_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;
}
}