From 14f9217007791887d07e2aad41611bb1ad393180 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 8 Sep 2026 17:41:21 +0200 Subject: [PATCH] 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 --- lib/data/system_actions.dart | 58 ++++++++++++++++++++++++++-- lib/pages/doctor.dart | 52 ++++++++++++++++++++++++- test/update_apply_progress_test.dart | 55 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 test/update_apply_progress_test.dart diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 0a3a96f..a0f3059 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -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 `. 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 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 lines(Stream> 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(); + final stderrDone = lines(process.stderr).listen((line) { + err.writeln(line); + if (line.trim().isNotEmpty) onLine(line.trim()); + }).asFuture(); + 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 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; diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index f4fe89d..f4be094 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -1357,15 +1359,48 @@ class _UpdateBannerState extends State<_UpdateBanner> { bool _applying = false; String? _applyOutput; + /// Last line the CLI printed, shown as the bar's label. + String? _applyLine; + + /// When the apply started, for the elapsed counter. + DateTime? _applySince; + + /// Redraws the elapsed counter between CLI lines, which can be + /// minutes apart during a download. + Timer? _applyClock; + + @override + void dispose() { + _applyClock?.cancel(); + super.dispose(); + } + Future _applyUpdate() async { setState(() { _applying = true; _applyOutput = null; + _applyLine = null; + _applySince = DateTime.now(); }); - final r = await SystemActions.chainUpdateApply(widget.status.channel); + _applyClock?.cancel(); + _applyClock = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted && _applying) setState(() {}); + }); + final r = await SystemActions.chainUpdateApply( + widget.status.channel, + // Each line the CLI prints becomes the label under the bar, so + // the operator sees which step is running rather than a spinner + // that could mean anything. + onLine: (line) { + if (mounted) setState(() => _applyLine = line); + }, + ); + _applyClock?.cancel(); + _applyClock = null; if (!mounted) return; setState(() { _applying = false; + _applyLine = null; _applyOutput = r.ok ? AppLocalizations.of(context)!.doctorApplyDone : (r.stderr.isEmpty ? r.stdout : r.stderr).trim(); @@ -1473,6 +1508,21 @@ class _UpdateBannerState extends State<_UpdateBanner> { ), ], ), + if (_applying) ...[ + const SizedBox(height: ChainSpace.md), + // No percentage to be had: the CLI reports steps, not a + // measurable total. So the bar runs indeterminate and + // carries the running step plus an elapsed counter, which + // is what separates "slow" from "stuck". + ChainProgressBar( + stage: _applyLine ?? l.doctorApplying, + detail: _applySince == null + ? null + : l.installElapsed( + DateTime.now().difference(_applySince!).inSeconds, + ), + ), + ], if (_applyOutput != null) ...[ const SizedBox(height: ChainSpace.md), ChainErrorBox(text: _applyOutput!, maxHeight: 240), diff --git a/test/update_apply_progress_test.dart b/test/update_apply_progress_test.dart new file mode 100644 index 0000000..34c8240 --- /dev/null +++ b/test/update_apply_progress_test.dart @@ -0,0 +1,55 @@ +// Guards for the streamed `chain update apply` path. +// +// Applying an update downloads and swaps a binary and takes minutes. +// The bug class: a caller that passes a line callback and silently +// gets nothing back, or a streaming path that stops honouring the +// test override and starts spawning real processes in the suite. + +import 'package:chain_studio/data/system_actions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + tearDown(() => SystemActions.debugRunFaiOverride = null); + + test('the test override still wins over the streaming path', () async { + // Hermetic by construction: with an override installed, no process + // may be started even when a line callback is supplied. + var seenArgs = []; + SystemActions.debugRunFaiOverride = (args) async { + seenArgs = args; + return (ok: true, stdout: 'done', stderr: ''); + }; + + var lines = []; + final r = await SystemActions.chainUpdateApply( + 'stable', + onLine: lines.add, + ); + + expect(r.ok, isTrue); + expect(r.stdout, 'done'); + expect(seenArgs, ['update', 'apply', '--channel', 'stable']); + expect(lines, isEmpty, reason: 'the override produces no live lines'); + }); + + test('the channel reaches the CLI unchanged', () async { + var seenArgs = []; + SystemActions.debugRunFaiOverride = (args) async { + seenArgs = args; + return (ok: true, stdout: '', stderr: ''); + }; + + await SystemActions.chainUpdateApply('beta', onLine: (_) {}); + expect(seenArgs, contains('beta')); + }); + + test('callers without a line callback keep the buffered behaviour', + () async { + SystemActions.debugRunFaiOverride = + (args) async => (ok: false, stdout: '', stderr: 'boom'); + + final r = await SystemActions.chainUpdateApply('stable'); + expect(r.ok, isFalse); + expect(r.stderr, 'boom'); + }); +}