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>
This commit is contained in:
flemming-it 2026-09-08 17:41:21 +02:00
parent 1549334767
commit 14f9217007
3 changed files with 160 additions and 5 deletions

View file

@ -16,6 +16,7 @@
// 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';
@ -178,9 +179,13 @@ class SystemActions {
/// 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,
) async {
return _runFai(['update', 'apply', '--channel', channel]);
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`.
@ -289,6 +294,50 @@ class SystemActions {
}
}
/// 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 []);
@ -316,7 +365,8 @@ class SystemActions {
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') ??
final fromPath =
_whichFai(isWindows ? 'chain.exe' : 'chain') ??
_whichFai(isWindows ? 'fai.exe' : 'fai');
if (fromPath != null) return fromPath;