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'); + }); +}