chain-studio/lib/data/system_actions.dart
flemming-it 93926741b1 feat(studio): channel switcher, autostart toggle, Store redesign (v0.18.0)
UI parity for operators who never touch a CLI, plus a Store
that reads more like an app store.

Settings dialog:
- Channel rows gain a popup menu: Connect / Make active /
  Enable autostart / Disable autostart. "Connect" still
  re-points Studio's wire; "Make active" actually writes
  ~/.fai/current-channel via `fai channel switch`.
- Inline output panel surfaces the spawned binary's stdout
  on success or stderr on failure, so operators see what
  happened without opening a terminal.

Store page rewrite:
- Big top search bar with a clear button. Live filter on
  every keystroke.
- Horizontal category strip auto-populated from the index;
  segmented status row (All / Published / Alpha / Planned),
  Installed-only chip, result count.
- Grid of cards that reflows to fit the viewport — replaces
  the previous single-column list. Each card shows
  category-aware icon, version, status, tagline preview, and
  a one-click Install (or Details for installed / planned).
- Per-module detail sheet renders the full bilingual
  description with a DE/EN toggle, separate Required-
  capabilities + Required-host-services sections, repo link,
  Read-docs button. Install + Uninstall live at the bottom.
- StoreItem and HubService.searchStore now carry the German
  tagline + description so the locale toggle has something
  to switch to.

SystemActions extended with `faiChannelSwitch`,
`faiDaemonEnable`, `faiDaemonDisable` so Settings can spawn
the right CLI without each call site reimplementing the
`fai` resolution rules.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-07 18:52:48 +02:00

148 lines
5.2 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
// `~/.fai/bin/fai` (Unix) or `%USERPROFILE%\.fai\bin\fai.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';
class SystemActions {
SystemActions._();
/// 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());
}
}
/// Run a `fai daemon ...` subcommand and surface the captured
/// output. Used by Doctor's "Restart daemon" button.
static Future<({bool ok, String stdout, String stderr})> faiDaemon(
List<String> args,
) async {
return _runFai(['daemon', ...args]);
}
/// Run `fai update apply --channel <c>`. 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 `~/.fai/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]);
}
static Future<({bool ok, String stdout, String stderr})> _runFai(
List<String> args,
) async {
final exe = _faiExecutable();
if (exe == null) {
return (
ok: false,
stdout: '',
stderr: 'Could not locate the `fai` binary. Install via the '
'platform installer or set FAI_BIN to its full path.',
);
}
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<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: $FAI_BIN, PATH, fallback to
/// the canonical install location under the user's home dir.
static String? _faiExecutable() {
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\\.fai\\bin\\fai.exe'
: '$home/.fai/bin/fai';
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;
}
}