diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c130b..617635b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to `fai_studio` recorded here. Pubspec version + `kStudioVersion` in `lib/main.dart` stay in lockstep. +## 0.70.0 — 2026-06-13 + +### Added + +- **Federation panel.** A new "Föderation" destination shows the + satellites connected to this hub (name, region, version, wire + version, advertised capabilities) and adds them in one step: press + **Add satellite**, name it, and the hub issues a single-use + bootstrap token bundled with its CA as a ready-to-paste satellite + config — the bundled CA makes the satellite's first connect + tamper-proof. Localized (EN + DE) with an in-app help doc. Backed + by new `HubClient.listSatellites` / `issueBootstrapToken` in the + Dart SDK. + ## 0.68.0 — 2026-06-09 ### Added diff --git a/assets/docs/federation.md b/assets/docs/federation.md new file mode 100644 index 0000000..475cdf6 --- /dev/null +++ b/assets/docs/federation.md @@ -0,0 +1,43 @@ +# Federation + +Federation lets one hub (the **primary**) run capabilities on +another hub (a **satellite**) — for example to reach a GPU box or to +keep data inside a specific location. A flow step opts in with an +`on:` selector; everything else stays exactly like a local step. + +This panel is the **primary** side: it shows the satellites currently +connected and lets you enrol new ones. + +## Add a satellite + +1. Press **Add satellite** and give it a name (e.g. `satellite-a`). +2. The hub issues a single-use **bootstrap token** and bundles its + **CA certificate**. Copy the shown config into the satellite's + `config.yaml` (one paste) and export the token as + `FAI_BOOTSTRAP_TOKEN`. +3. Start the satellite. It dials the primary, the CA pin makes the + first connect tamper-proof, and the primary signs it a short-lived + certificate. It then appears in this list. + +## How it stays secure + +- The satellite **dials outbound** — no inbound firewall change. +- Every connection is **mTLS**. The first connect is verified against + the bundled CA; later connects authenticate with the satellite's + own certificate (no token needed). +- The satellite resolves and runs the capability in **its own + sandbox** — only inputs cross the wire, never code. +- Each cross-hub call is recorded on both sides (`dispatched_to`), so + the audit trail and replay determinism survive federation. + +## Using a satellite in a flow + +Add an `on:` selector to a step: + +```yaml +- id: extract + use: text.extract@^1 + on: region=berlin # or pin=satellite-a, auto:healthiest, auto:lowest_rtt + with: + input: $inputs.doc +``` diff --git a/assets/docs/federation_de.md b/assets/docs/federation_de.md new file mode 100644 index 0000000..b3f9628 --- /dev/null +++ b/assets/docs/federation_de.md @@ -0,0 +1,48 @@ +# Föderation + +Föderation lässt einen Hub (den **Primary**) Capabilities auf einem +anderen Hub (einem **Satelliten**) ausführen — etwa um eine GPU-Maschine +zu erreichen oder Daten an einem bestimmten Ort zu halten. Ein +Flow-Schritt entscheidet sich per `on:`-Selektor dafür; alles andere +bleibt wie bei einem lokalen Schritt. + +Dieses Panel ist die **Primary**-Seite: Es zeigt die aktuell +verbundenen Satelliten und richtet neue ein. + +## Satellit hinzufügen + +1. **Satellit hinzufügen** drücken und einen Namen vergeben + (z.B. `satellite-a`). +2. Der Hub stellt ein einmaliges **Bootstrap-Token** aus und legt sein + **CA-Zertifikat** bei. Kopiere die gezeigte Konfiguration in die + `config.yaml` des Satelliten (ein Einfügen) und exportiere das + Token als `FAI_BOOTSTRAP_TOKEN`. +3. Starte den Satelliten. Er wählt sich beim Primary ein, der CA-Pin + macht den ersten Connect manipulationssicher, und der Primary + signiert ihm ein kurzlebiges Zertifikat. Danach erscheint er in + dieser Liste. + +## Warum es sicher bleibt + +- Der Satellit **wählt sich nach außen ein** — keine eingehende + Firewall-Änderung nötig. +- Jede Verbindung ist **mTLS**. Der erste Connect wird gegen die + beigelegte CA verifiziert; spätere Connects authentifizieren sich + mit dem eigenen Zertifikat des Satelliten (kein Token nötig). +- Der Satellit löst auf und führt die Capability in **seiner eigenen + Sandbox** aus — nur Eingaben gehen über die Leitung, niemals Code. +- Jeder hub-übergreifende Aufruf wird auf beiden Seiten protokolliert + (`dispatched_to`), sodass Audit-Trail und Replay-Determinismus die + Föderation überstehen. + +## Satellit in einem Flow nutzen + +Ergänze einen `on:`-Selektor an einem Schritt: + +```yaml +- id: extract + use: text.extract@^1 + on: region=berlin # oder pin=satellite-a, auto:healthiest, auto:lowest_rtt + with: + input: $inputs.doc +``` diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/JetBrainsMono-Medium.ttf b/assets/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/assets/fonts/JetBrainsMono-Medium.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 1431e66..0de2e2d 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -11,6 +11,7 @@ import 'package:fai_client_sdk/fai_client_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../l10n/app_localizations.dart'; import 'flow_output.dart'; import 'hub_auth_token.dart'; export 'flow_output.dart'; @@ -858,6 +859,39 @@ class HubService { Future reject(String id, String reviewer, String reason) => _client.reject(approvalId: id, reviewer: reviewer, reason: reason); + /// Federation satellites currently connected to this hub + /// (primary side). Empty when none are connected. + Future> listSatellites() async { + final entries = await _client.listSatellites(); + return entries + .map( + (e) => Satellite( + hubId: e.hubId, + displayName: e.displayName, + hubVersion: e.hubVersion, + wireVersion: e.wireVersion, + region: e.region, + capabilities: e.capabilities.toList(), + ), + ) + .toList(); + } + + /// Issue a single-use federation bootstrap token enrolling + /// `name`. The result carries the primary CA so the operator can + /// bootstrap the satellite in one step (secure first connect). + Future issueSatelliteToken( + String name, { + String region = '', + }) async { + final r = await _client.issueBootstrapToken(name, region: region); + return SatelliteEnrollment( + token: r.token, + expiresAt: r.expiresAt, + primaryCaPem: r.primaryCaPem, + ); + } + /// Composite "doctor" snapshot. One round-trip per piece, run /// in parallel so the page populates fast. Future doctor() async { @@ -1207,6 +1241,51 @@ class PendingApproval { /// Full approval record including decided-side fields. Used by /// the History tab so operators can review what they (or the +/// A federation satellite currently connected to this hub. +class Satellite { + final String hubId; + final String displayName; + final String hubVersion; + final int wireVersion; + final String region; + final List capabilities; + + const Satellite({ + required this.hubId, + required this.displayName, + required this.hubVersion, + required this.wireVersion, + required this.region, + required this.capabilities, + }); +} + +/// The artifact a satellite operator needs to enrol: the single-use +/// bootstrap token plus the primary's CA cert (for a secure first +/// connect). Both are pasted into the satellite's config. +class SatelliteEnrollment { + final String token; + final String expiresAt; + final String primaryCaPem; + + const SatelliteEnrollment({ + required this.token, + required this.expiresAt, + required this.primaryCaPem, + }); + + /// A ready-to-paste `federation:` config block for the satellite. + String toConfigYaml(String name) { + final ca = primaryCaPem.trim().isEmpty + ? '' + : '\n primary_ca: |\n${primaryCaPem.trimRight().split('\n').map((l) => ' $l').join('\n')}'; + return 'federation:\n' + ' upstream: https://:\n' + ' hub_name: $name\n' + ' bootstrap_token_env: FAI_BOOTSTRAP_TOKEN$ca'; + } +} + /// system) decided. class ApprovalRecord { final String id; @@ -1306,24 +1385,25 @@ class AskAiResult { /// One-line fix hint per error kind, mirroring /// `docs/architecture/system-ai.md`. Empty on success. - String get fixHint { + /// Localized — pass the active [AppLocalizations]. + String fixHint(AppLocalizations l) { switch (errorKind) { case '': return ''; case 'disabled': - return 'Configure System AI in Settings (Cmd+,) → System AI panel.'; + return l.sysAiFixDisabled; case 'env_missing': - return 'The API-key env var is empty. `export =` in your shell, then `fai daemon restart`.'; + return l.sysAiFixEnvMissing; case 'network': - return 'Hub cannot reach the configured endpoint. Is your provider running?'; + return l.sysAiFixNetwork; case 'http': - return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.'; + return l.sysAiFixHttp; case 'parse': - return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.'; + return l.sysAiFixParse; case 'empty_response': - return 'Provider answered with no content. Try a different model or rephrase the prompt.'; + return l.sysAiFixEmptyResponse; default: - return 'Unknown failure kind ($errorKind). See system-ai.md.'; + return l.sysAiFixUnknown(errorKind); } } } diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index fc48ce6..2884edd 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -18,9 +18,61 @@ import 'dart:io'; +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.faiBinaryExists] first +/// and render a localized, actionable message instead. +const String kFaiBinaryNotFound = 'fai-binary-not-found'; + +/// Getting-started / install guide for the F∆I 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/platform#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.fai_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 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 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 faiBinaryExists() => _faiExecutable() != null; + /// 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 @@ -149,13 +201,11 @@ class SystemActions { ) 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.', - ); + // Sentinel, not a user-facing string. Callers detect this + // via `faiBinaryExists()` and render a localized, + // actionable recovery message (locate binary / install + // guide) instead of CLI jargon like "set FAI_BIN". + return (ok: false, stdout: '', stderr: kFaiBinaryNotFound); } try { final r = await Process.run(exe, args); @@ -175,9 +225,17 @@ class SystemActions { 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. + /// Locate the `fai` binary. Order: operator override (set via + /// the file picker), $FAI_BIN, PATH, fallback to the canonical + /// install location under the user's home dir. static String? _faiExecutable() { + final override = _faiBinaryOverride; + if (override != null && + override.isNotEmpty && + File(override).existsSync()) { + return override; + } + final fromEnv = Platform.environment['FAI_BIN']; if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) { return fromEnv; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5708e53..5b7dda8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1520,5 +1520,97 @@ "placeholders": { "error": {"type": "String"} } - } + }, + "welcomeHubDownTitle": "Der Hub läuft noch nicht", + "welcomeHubDownBody": "Studio spricht mit einem lokalen Hub — der Engine, die Module lädt und Flows ausführt. Sie ist gerade nicht erreichbar. Starte sie, um loszulegen.", + "welcomeHubDownStart": "Hub starten", + "welcomeHubDownStarting": "Wird gestartet …", + "welcomeHubDownDocsLink": "Klappt der Start nicht? Zur Erste-Schritte-Anleitung", + "welcomeHubDownEndpoint": "Endpunkt: {endpoint}", + "@welcomeHubDownEndpoint": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "daemonStartRequested": "Start des Daemons angefordert. Verbinde neu …", + "daemonStartFailed": "Daemon konnte nicht gestartet werden: {detail}", + "@daemonStartFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "faiBinaryNotFound": "Das Programm `fai` wurde auf diesem Rechner nicht gefunden.", + "faiBinaryNotFoundHint": "Installiere die F∆I-Plattform oder verweise Studio auf eine vorhandene `fai`-Datei.", + "faiBinaryLocateButton": "`fai`-Programm suchen …", + "faiBinaryInstallDocsButton": "Installationsanleitung öffnen", + "faiBinaryPickerTitle": "`fai`-Programm auswählen", + "faiBinarySetOk": "Verwende `fai` unter {path}", + "@faiBinarySetOk": { + "placeholders": { + "path": {"type": "String"} + } + }, + "sysAiFixParse": "Der Anbieter hat in einem unerwarteten Format geantwortet. Anbieter, die nicht OpenAI-kompatibel sind, brauchen ggf. einen Übersetzungs-Proxy.", + "sysAiFixUnknown": "Unerwarteter Fehler ({kind}). Siehe System-KI-Dokumentation.", + "@sysAiFixUnknown": { + "placeholders": { + "kind": {"type": "String"} + } + }, + "sysAiFixDisabled": "Richte die System-KI in den Einstellungen (Cmd+,) → Bereich System-KI ein.", + "sysAiFixEnvMissing": "Die Umgebungsvariable für den API-Schlüssel ist leer. Setze sie in deiner Shell und starte den Daemon neu.", + "sysAiFixNetwork": "Der Hub erreicht den konfigurierten Endpunkt nicht. Läuft dein Anbieter?", + "sysAiFixHttp": "Der Anbieter hat einen Status außerhalb 2xx geliefert. Prüfe Endpunkt, Modellname und API-Schlüssel.", + "sysAiFixEmptyResponse": "Der Anbieter hat keinen Inhalt geliefert. Probiere ein anderes Modell oder formuliere den Prompt um.", + "channelSwitchOk": "Aktiver Kanal auf \"{name}\" umgeschaltet. Daemon neu gestartet.", + "@channelSwitchOk": { + "placeholders": { + "name": {"type": "String"} + } + }, + "channelSwitchFailed": "Kanalwechsel fehlgeschlagen: {detail}", + "@channelSwitchFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "providerDescOllama": "Lokales `ollama serve`. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.", + "providerDescOpenai": "Gehostete OpenAI-API. Erfordert einen API-Schlüssel. Daten verlassen deinen Rechner.", + "providerDescLmstudio": "Lokaler LM-Studio-Server. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.", + "providerDescVllm": "Selbst gehostetes vLLM oder ein anderer OpenAI-kompatibler Server. Endpunkt erforderlich.", + "providerDescCustom": "Alles andere, das die OpenAI-kompatible Chat-Completions-Schnittstelle spricht (LiteLLM-Proxy, interner Spiegel, …).", + "welcomeDocLoadFailedBody": "Die mitgelieferte Doku \"{slug}\" ({locale}) konnte nicht geladen werden.\nVersucht: {attempted}\nUrsache: {underlying}\nMeist ist der installierte Studio-Build älter als das Doku-Bundle. Baue Studio neu oder öffne die Online-Kopie.", + "@welcomeDocLoadFailedBody": { + "placeholders": { + "slug": {"type": "String"}, + "locale": {"type": "String"}, + "attempted": {"type": "String"}, + "underlying": {"type": "String"} + } + }, + "hubUnreachableBanner": "{endpoint} nicht erreichbar — Einstellungen öffnen", + "@hubUnreachableBanner": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "hubUnreachableOpenSettings": "Einstellungen öffnen", + "navFederation": "Föderation", + "federationTitle": "Föderation", + "federationReloadTooltip": "Satelliten neu laden", + "federationAddSatellite": "Satellit hinzufügen", + "federationEmptyTitle": "Keine Satelliten verbunden", + "federationEmptyHint": "Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.", + "federationPillConnected": "verbunden", + "federationRegionNone": "keine Region", + "federationCapabilities": "{count} angebotene Capabilities", + "federationAddDialogTitle": "Enrollment-Token ausstellen", + "federationNameLabel": "Satelliten-Name", + "federationIssueButton": "Ausstellen", + "federationIssueFailed": "Token konnte nicht ausgestellt werden: {error}", + "federationEnrollmentTitle": "{name} einrichten", + "federationTokenLabel": "Bootstrap-Token (einmalig)", + "federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)", + "federationCopied": "In die Zwischenablage kopiert", + "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2e8c83e..0be3e46 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1523,5 +1523,112 @@ "placeholders": { "error": {"type": "String"} } - } + }, + "welcomeHubDownTitle": "The hub isn't running yet", + "welcomeHubDownBody": "Studio talks to a local hub — the engine that loads modules and runs flows. It isn't reachable right now. Start it to begin.", + "welcomeHubDownStart": "Start hub", + "welcomeHubDownStarting": "Starting…", + "welcomeHubDownDocsLink": "Can't start it? Read the getting-started guide", + "welcomeHubDownEndpoint": "Endpoint: {endpoint}", + "@welcomeHubDownEndpoint": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "daemonStartRequested": "Daemon start requested. Reconnecting…", + "daemonStartFailed": "Could not start daemon: {detail}", + "@daemonStartFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "faiBinaryNotFound": "Could not find the `fai` program on this machine.", + "faiBinaryNotFoundHint": "Install the F∆I platform, or point Studio at an existing `fai` binary.", + "faiBinaryLocateButton": "Locate `fai` binary…", + "faiBinaryInstallDocsButton": "Open install guide", + "faiBinaryPickerTitle": "Select the `fai` binary", + "faiBinarySetOk": "Using `fai` binary at {path}", + "@faiBinarySetOk": { + "placeholders": { + "path": {"type": "String"} + } + }, + "sysAiFixParse": "The provider sent a response in an unexpected format. Providers that aren't OpenAI-compatible may need a translation proxy.", + "sysAiFixUnknown": "Unexpected failure ({kind}). See the System AI documentation.", + "@sysAiFixUnknown": { + "placeholders": { + "kind": {"type": "String"} + } + }, + "sysAiFixDisabled": "Configure System AI in Settings (Cmd+,) → System AI panel.", + "sysAiFixEnvMissing": "The API-key environment variable is empty. Set it in your shell, then restart the daemon.", + "sysAiFixNetwork": "The hub can't reach the configured endpoint. Is your provider running?", + "sysAiFixHttp": "The provider returned a non-2xx status. Verify the endpoint, model name, and API key.", + "sysAiFixEmptyResponse": "The provider answered with no content. Try a different model or rephrase the prompt.", + "channelSwitchOk": "Switched active channel to \"{name}\". Daemon restarted.", + "@channelSwitchOk": { + "placeholders": { + "name": {"type": "String"} + } + }, + "channelSwitchFailed": "Channel switch failed: {detail}", + "@channelSwitchFailed": { + "placeholders": { + "detail": {"type": "String"} + } + }, + "providerDescOllama": "Local `ollama serve`. Models stay on your machine. No API key needed.", + "providerDescOpenai": "OpenAI hosted API. Requires an API key. Data leaves your machine.", + "providerDescLmstudio": "Local LM Studio server. Models stay on your machine. No API key needed.", + "providerDescVllm": "Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.", + "providerDescCustom": "Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).", + "welcomeDocLoadFailedBody": "Could not load the bundled doc \"{slug}\" ({locale}).\nTried: {attempted}\nUnderlying: {underlying}\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.", + "@welcomeDocLoadFailedBody": { + "placeholders": { + "slug": {"type": "String"}, + "locale": {"type": "String"}, + "attempted": {"type": "String"}, + "underlying": {"type": "String"} + } + }, + "hubUnreachableBanner": "Can't reach {endpoint} — open Settings", + "@hubUnreachableBanner": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, + "hubUnreachableOpenSettings": "Open Settings", + "navFederation": "Federation", + "federationTitle": "Federation", + "federationReloadTooltip": "Reload satellites", + "federationAddSatellite": "Add satellite", + "federationEmptyTitle": "No satellites connected", + "federationEmptyHint": "Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.", + "federationPillConnected": "connected", + "federationRegionNone": "no region", + "federationCapabilities": "{count} advertised capabilities", + "@federationCapabilities": { + "placeholders": { + "count": {"type": "int"} + } + }, + "federationAddDialogTitle": "Issue an enrollment token", + "federationNameLabel": "Satellite name", + "federationIssueButton": "Issue", + "federationIssueFailed": "Could not issue token: {error}", + "@federationIssueFailed": { + "placeholders": { + "error": {"type": "String"} + } + }, + "federationEnrollmentTitle": "Enroll {name}", + "@federationEnrollmentTitle": { + "placeholders": { + "name": {"type": "String"} + } + }, + "federationTokenLabel": "Bootstrap token (single use)", + "federationConfigLabel": "Satellite config (paste into the satellite)", + "federationCopied": "Copied to clipboard", + "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite's first connect tamper-proof." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bd6684b..748b286 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4279,6 +4279,305 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Install failed: {error}'** String installFailed(String error); + + /// No description provided for @welcomeHubDownTitle. + /// + /// In en, this message translates to: + /// **'The hub isn\'t running yet'** + String get welcomeHubDownTitle; + + /// No description provided for @welcomeHubDownBody. + /// + /// In en, this message translates to: + /// **'Studio talks to a local hub — the engine that loads modules and runs flows. It isn\'t reachable right now. Start it to begin.'** + String get welcomeHubDownBody; + + /// No description provided for @welcomeHubDownStart. + /// + /// In en, this message translates to: + /// **'Start hub'** + String get welcomeHubDownStart; + + /// No description provided for @welcomeHubDownStarting. + /// + /// In en, this message translates to: + /// **'Starting…'** + String get welcomeHubDownStarting; + + /// No description provided for @welcomeHubDownDocsLink. + /// + /// In en, this message translates to: + /// **'Can\'t start it? Read the getting-started guide'** + String get welcomeHubDownDocsLink; + + /// No description provided for @welcomeHubDownEndpoint. + /// + /// In en, this message translates to: + /// **'Endpoint: {endpoint}'** + String welcomeHubDownEndpoint(String endpoint); + + /// No description provided for @daemonStartRequested. + /// + /// In en, this message translates to: + /// **'Daemon start requested. Reconnecting…'** + String get daemonStartRequested; + + /// No description provided for @daemonStartFailed. + /// + /// In en, this message translates to: + /// **'Could not start daemon: {detail}'** + String daemonStartFailed(String detail); + + /// No description provided for @faiBinaryNotFound. + /// + /// In en, this message translates to: + /// **'Could not find the `fai` program on this machine.'** + String get faiBinaryNotFound; + + /// No description provided for @faiBinaryNotFoundHint. + /// + /// In en, this message translates to: + /// **'Install the F∆I platform, or point Studio at an existing `fai` binary.'** + String get faiBinaryNotFoundHint; + + /// No description provided for @faiBinaryLocateButton. + /// + /// In en, this message translates to: + /// **'Locate `fai` binary…'** + String get faiBinaryLocateButton; + + /// No description provided for @faiBinaryInstallDocsButton. + /// + /// In en, this message translates to: + /// **'Open install guide'** + String get faiBinaryInstallDocsButton; + + /// No description provided for @faiBinaryPickerTitle. + /// + /// In en, this message translates to: + /// **'Select the `fai` binary'** + String get faiBinaryPickerTitle; + + /// No description provided for @faiBinarySetOk. + /// + /// In en, this message translates to: + /// **'Using `fai` binary at {path}'** + String faiBinarySetOk(String path); + + /// No description provided for @sysAiFixParse. + /// + /// In en, this message translates to: + /// **'The provider sent a response in an unexpected format. Providers that aren\'t OpenAI-compatible may need a translation proxy.'** + String get sysAiFixParse; + + /// No description provided for @sysAiFixUnknown. + /// + /// In en, this message translates to: + /// **'Unexpected failure ({kind}). See the System AI documentation.'** + String sysAiFixUnknown(String kind); + + /// No description provided for @sysAiFixDisabled. + /// + /// In en, this message translates to: + /// **'Configure System AI in Settings (Cmd+,) → System AI panel.'** + String get sysAiFixDisabled; + + /// No description provided for @sysAiFixEnvMissing. + /// + /// In en, this message translates to: + /// **'The API-key environment variable is empty. Set it in your shell, then restart the daemon.'** + String get sysAiFixEnvMissing; + + /// No description provided for @sysAiFixNetwork. + /// + /// In en, this message translates to: + /// **'The hub can\'t reach the configured endpoint. Is your provider running?'** + String get sysAiFixNetwork; + + /// No description provided for @sysAiFixHttp. + /// + /// In en, this message translates to: + /// **'The provider returned a non-2xx status. Verify the endpoint, model name, and API key.'** + String get sysAiFixHttp; + + /// No description provided for @sysAiFixEmptyResponse. + /// + /// In en, this message translates to: + /// **'The provider answered with no content. Try a different model or rephrase the prompt.'** + String get sysAiFixEmptyResponse; + + /// No description provided for @channelSwitchOk. + /// + /// In en, this message translates to: + /// **'Switched active channel to \"{name}\". Daemon restarted.'** + String channelSwitchOk(String name); + + /// No description provided for @channelSwitchFailed. + /// + /// In en, this message translates to: + /// **'Channel switch failed: {detail}'** + String channelSwitchFailed(String detail); + + /// No description provided for @providerDescOllama. + /// + /// In en, this message translates to: + /// **'Local `ollama serve`. Models stay on your machine. No API key needed.'** + String get providerDescOllama; + + /// No description provided for @providerDescOpenai. + /// + /// In en, this message translates to: + /// **'OpenAI hosted API. Requires an API key. Data leaves your machine.'** + String get providerDescOpenai; + + /// No description provided for @providerDescLmstudio. + /// + /// In en, this message translates to: + /// **'Local LM Studio server. Models stay on your machine. No API key needed.'** + String get providerDescLmstudio; + + /// No description provided for @providerDescVllm. + /// + /// In en, this message translates to: + /// **'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.'** + String get providerDescVllm; + + /// No description provided for @providerDescCustom. + /// + /// In en, this message translates to: + /// **'Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).'** + String get providerDescCustom; + + /// No description provided for @welcomeDocLoadFailedBody. + /// + /// In en, this message translates to: + /// **'Could not load the bundled doc \"{slug}\" ({locale}).\nTried: {attempted}\nUnderlying: {underlying}\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.'** + String welcomeDocLoadFailedBody( + String slug, + String locale, + String attempted, + String underlying, + ); + + /// No description provided for @hubUnreachableBanner. + /// + /// In en, this message translates to: + /// **'Can\'t reach {endpoint} — open Settings'** + String hubUnreachableBanner(String endpoint); + + /// No description provided for @hubUnreachableOpenSettings. + /// + /// In en, this message translates to: + /// **'Open Settings'** + String get hubUnreachableOpenSettings; + + /// No description provided for @navFederation. + /// + /// In en, this message translates to: + /// **'Federation'** + String get navFederation; + + /// No description provided for @federationTitle. + /// + /// In en, this message translates to: + /// **'Federation'** + String get federationTitle; + + /// No description provided for @federationReloadTooltip. + /// + /// In en, this message translates to: + /// **'Reload satellites'** + String get federationReloadTooltip; + + /// No description provided for @federationAddSatellite. + /// + /// In en, this message translates to: + /// **'Add satellite'** + String get federationAddSatellite; + + /// No description provided for @federationEmptyTitle. + /// + /// In en, this message translates to: + /// **'No satellites connected'** + String get federationEmptyTitle; + + /// No description provided for @federationEmptyHint. + /// + /// In en, this message translates to: + /// **'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.'** + String get federationEmptyHint; + + /// No description provided for @federationPillConnected. + /// + /// In en, this message translates to: + /// **'connected'** + String get federationPillConnected; + + /// No description provided for @federationRegionNone. + /// + /// In en, this message translates to: + /// **'no region'** + String get federationRegionNone; + + /// No description provided for @federationCapabilities. + /// + /// In en, this message translates to: + /// **'{count} advertised capabilities'** + String federationCapabilities(int count); + + /// No description provided for @federationAddDialogTitle. + /// + /// In en, this message translates to: + /// **'Issue an enrollment token'** + String get federationAddDialogTitle; + + /// No description provided for @federationNameLabel. + /// + /// In en, this message translates to: + /// **'Satellite name'** + String get federationNameLabel; + + /// No description provided for @federationIssueButton. + /// + /// In en, this message translates to: + /// **'Issue'** + String get federationIssueButton; + + /// No description provided for @federationIssueFailed. + /// + /// In en, this message translates to: + /// **'Could not issue token: {error}'** + String federationIssueFailed(String error); + + /// No description provided for @federationEnrollmentTitle. + /// + /// In en, this message translates to: + /// **'Enroll {name}'** + String federationEnrollmentTitle(String name); + + /// No description provided for @federationTokenLabel. + /// + /// In en, this message translates to: + /// **'Bootstrap token (single use)'** + String get federationTokenLabel; + + /// No description provided for @federationConfigLabel. + /// + /// In en, this message translates to: + /// **'Satellite config (paste into the satellite)'** + String get federationConfigLabel; + + /// No description provided for @federationCopied. + /// + /// In en, this message translates to: + /// **'Copied to clipboard'** + String get federationCopied; + + /// No description provided for @federationEnrollmentHint. + /// + /// In en, this message translates to: + /// **'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'** + String get federationEnrollmentHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6d55a0c..7a4d453 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2502,4 +2502,197 @@ class AppLocalizationsDe extends AppLocalizations { String installFailed(String error) { return 'Installation fehlgeschlagen: $error'; } + + @override + String get welcomeHubDownTitle => 'Der Hub läuft noch nicht'; + + @override + String get welcomeHubDownBody => + 'Studio spricht mit einem lokalen Hub — der Engine, die Module lädt und Flows ausführt. Sie ist gerade nicht erreichbar. Starte sie, um loszulegen.'; + + @override + String get welcomeHubDownStart => 'Hub starten'; + + @override + String get welcomeHubDownStarting => 'Wird gestartet …'; + + @override + String get welcomeHubDownDocsLink => + 'Klappt der Start nicht? Zur Erste-Schritte-Anleitung'; + + @override + String welcomeHubDownEndpoint(String endpoint) { + return 'Endpunkt: $endpoint'; + } + + @override + String get daemonStartRequested => + 'Start des Daemons angefordert. Verbinde neu …'; + + @override + String daemonStartFailed(String detail) { + return 'Daemon konnte nicht gestartet werden: $detail'; + } + + @override + String get faiBinaryNotFound => + 'Das Programm `fai` wurde auf diesem Rechner nicht gefunden.'; + + @override + String get faiBinaryNotFoundHint => + 'Installiere die F∆I-Plattform oder verweise Studio auf eine vorhandene `fai`-Datei.'; + + @override + String get faiBinaryLocateButton => '`fai`-Programm suchen …'; + + @override + String get faiBinaryInstallDocsButton => 'Installationsanleitung öffnen'; + + @override + String get faiBinaryPickerTitle => '`fai`-Programm auswählen'; + + @override + String faiBinarySetOk(String path) { + return 'Verwende `fai` unter $path'; + } + + @override + String get sysAiFixParse => + 'Der Anbieter hat in einem unerwarteten Format geantwortet. Anbieter, die nicht OpenAI-kompatibel sind, brauchen ggf. einen Übersetzungs-Proxy.'; + + @override + String sysAiFixUnknown(String kind) { + return 'Unerwarteter Fehler ($kind). Siehe System-KI-Dokumentation.'; + } + + @override + String get sysAiFixDisabled => + 'Richte die System-KI in den Einstellungen (Cmd+,) → Bereich System-KI ein.'; + + @override + String get sysAiFixEnvMissing => + 'Die Umgebungsvariable für den API-Schlüssel ist leer. Setze sie in deiner Shell und starte den Daemon neu.'; + + @override + String get sysAiFixNetwork => + 'Der Hub erreicht den konfigurierten Endpunkt nicht. Läuft dein Anbieter?'; + + @override + String get sysAiFixHttp => + 'Der Anbieter hat einen Status außerhalb 2xx geliefert. Prüfe Endpunkt, Modellname und API-Schlüssel.'; + + @override + String get sysAiFixEmptyResponse => + 'Der Anbieter hat keinen Inhalt geliefert. Probiere ein anderes Modell oder formuliere den Prompt um.'; + + @override + String channelSwitchOk(String name) { + return 'Aktiver Kanal auf \"$name\" umgeschaltet. Daemon neu gestartet.'; + } + + @override + String channelSwitchFailed(String detail) { + return 'Kanalwechsel fehlgeschlagen: $detail'; + } + + @override + String get providerDescOllama => + 'Lokales `ollama serve`. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.'; + + @override + String get providerDescOpenai => + 'Gehostete OpenAI-API. Erfordert einen API-Schlüssel. Daten verlassen deinen Rechner.'; + + @override + String get providerDescLmstudio => + 'Lokaler LM-Studio-Server. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.'; + + @override + String get providerDescVllm => + 'Selbst gehostetes vLLM oder ein anderer OpenAI-kompatibler Server. Endpunkt erforderlich.'; + + @override + String get providerDescCustom => + 'Alles andere, das die OpenAI-kompatible Chat-Completions-Schnittstelle spricht (LiteLLM-Proxy, interner Spiegel, …).'; + + @override + String welcomeDocLoadFailedBody( + String slug, + String locale, + String attempted, + String underlying, + ) { + return 'Die mitgelieferte Doku \"$slug\" ($locale) konnte nicht geladen werden.\nVersucht: $attempted\nUrsache: $underlying\nMeist ist der installierte Studio-Build älter als das Doku-Bundle. Baue Studio neu oder öffne die Online-Kopie.'; + } + + @override + String hubUnreachableBanner(String endpoint) { + return '$endpoint nicht erreichbar — Einstellungen öffnen'; + } + + @override + String get hubUnreachableOpenSettings => 'Einstellungen öffnen'; + + @override + String get navFederation => 'Föderation'; + + @override + String get federationTitle => 'Föderation'; + + @override + String get federationReloadTooltip => 'Satelliten neu laden'; + + @override + String get federationAddSatellite => 'Satellit hinzufügen'; + + @override + String get federationEmptyTitle => 'Keine Satelliten verbunden'; + + @override + String get federationEmptyHint => + 'Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.'; + + @override + String get federationPillConnected => 'verbunden'; + + @override + String get federationRegionNone => 'keine Region'; + + @override + String federationCapabilities(int count) { + return '$count angebotene Capabilities'; + } + + @override + String get federationAddDialogTitle => 'Enrollment-Token ausstellen'; + + @override + String get federationNameLabel => 'Satelliten-Name'; + + @override + String get federationIssueButton => 'Ausstellen'; + + @override + String federationIssueFailed(String error) { + return 'Token konnte nicht ausgestellt werden: $error'; + } + + @override + String federationEnrollmentTitle(String name) { + return '$name einrichten'; + } + + @override + String get federationTokenLabel => 'Bootstrap-Token (einmalig)'; + + @override + String get federationConfigLabel => + 'Satelliten-Konfiguration (in den Satelliten einfügen)'; + + @override + String get federationCopied => 'In die Zwischenablage kopiert'; + + @override + String get federationEnrollmentHint => + 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9613671..f105d4b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2506,4 +2506,196 @@ class AppLocalizationsEn extends AppLocalizations { String installFailed(String error) { return 'Install failed: $error'; } + + @override + String get welcomeHubDownTitle => 'The hub isn\'t running yet'; + + @override + String get welcomeHubDownBody => + 'Studio talks to a local hub — the engine that loads modules and runs flows. It isn\'t reachable right now. Start it to begin.'; + + @override + String get welcomeHubDownStart => 'Start hub'; + + @override + String get welcomeHubDownStarting => 'Starting…'; + + @override + String get welcomeHubDownDocsLink => + 'Can\'t start it? Read the getting-started guide'; + + @override + String welcomeHubDownEndpoint(String endpoint) { + return 'Endpoint: $endpoint'; + } + + @override + String get daemonStartRequested => 'Daemon start requested. Reconnecting…'; + + @override + String daemonStartFailed(String detail) { + return 'Could not start daemon: $detail'; + } + + @override + String get faiBinaryNotFound => + 'Could not find the `fai` program on this machine.'; + + @override + String get faiBinaryNotFoundHint => + 'Install the F∆I platform, or point Studio at an existing `fai` binary.'; + + @override + String get faiBinaryLocateButton => 'Locate `fai` binary…'; + + @override + String get faiBinaryInstallDocsButton => 'Open install guide'; + + @override + String get faiBinaryPickerTitle => 'Select the `fai` binary'; + + @override + String faiBinarySetOk(String path) { + return 'Using `fai` binary at $path'; + } + + @override + String get sysAiFixParse => + 'The provider sent a response in an unexpected format. Providers that aren\'t OpenAI-compatible may need a translation proxy.'; + + @override + String sysAiFixUnknown(String kind) { + return 'Unexpected failure ($kind). See the System AI documentation.'; + } + + @override + String get sysAiFixDisabled => + 'Configure System AI in Settings (Cmd+,) → System AI panel.'; + + @override + String get sysAiFixEnvMissing => + 'The API-key environment variable is empty. Set it in your shell, then restart the daemon.'; + + @override + String get sysAiFixNetwork => + 'The hub can\'t reach the configured endpoint. Is your provider running?'; + + @override + String get sysAiFixHttp => + 'The provider returned a non-2xx status. Verify the endpoint, model name, and API key.'; + + @override + String get sysAiFixEmptyResponse => + 'The provider answered with no content. Try a different model or rephrase the prompt.'; + + @override + String channelSwitchOk(String name) { + return 'Switched active channel to \"$name\". Daemon restarted.'; + } + + @override + String channelSwitchFailed(String detail) { + return 'Channel switch failed: $detail'; + } + + @override + String get providerDescOllama => + 'Local `ollama serve`. Models stay on your machine. No API key needed.'; + + @override + String get providerDescOpenai => + 'OpenAI hosted API. Requires an API key. Data leaves your machine.'; + + @override + String get providerDescLmstudio => + 'Local LM Studio server. Models stay on your machine. No API key needed.'; + + @override + String get providerDescVllm => + 'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.'; + + @override + String get providerDescCustom => + 'Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).'; + + @override + String welcomeDocLoadFailedBody( + String slug, + String locale, + String attempted, + String underlying, + ) { + return 'Could not load the bundled doc \"$slug\" ($locale).\nTried: $attempted\nUnderlying: $underlying\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.'; + } + + @override + String hubUnreachableBanner(String endpoint) { + return 'Can\'t reach $endpoint — open Settings'; + } + + @override + String get hubUnreachableOpenSettings => 'Open Settings'; + + @override + String get navFederation => 'Federation'; + + @override + String get federationTitle => 'Federation'; + + @override + String get federationReloadTooltip => 'Reload satellites'; + + @override + String get federationAddSatellite => 'Add satellite'; + + @override + String get federationEmptyTitle => 'No satellites connected'; + + @override + String get federationEmptyHint => + 'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.'; + + @override + String get federationPillConnected => 'connected'; + + @override + String get federationRegionNone => 'no region'; + + @override + String federationCapabilities(int count) { + return '$count advertised capabilities'; + } + + @override + String get federationAddDialogTitle => 'Issue an enrollment token'; + + @override + String get federationNameLabel => 'Satellite name'; + + @override + String get federationIssueButton => 'Issue'; + + @override + String federationIssueFailed(String error) { + return 'Could not issue token: $error'; + } + + @override + String federationEnrollmentTitle(String name) { + return 'Enroll $name'; + } + + @override + String get federationTokenLabel => 'Bootstrap token (single use)'; + + @override + String get federationConfigLabel => + 'Satellite config (paste into the satellite)'; + + @override + String get federationCopied => 'Copied to clipboard'; + + @override + String get federationEnrollmentHint => + 'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'; } diff --git a/lib/main.dart b/lib/main.dart index 301e5be..0d10ff7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; +import 'pages/federation.dart'; import 'pages/flows.dart'; import 'pages/store.dart'; import 'pages/welcome.dart'; @@ -27,7 +28,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.69.0'; +const String kStudioVersion = '0.70.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -35,6 +36,7 @@ Future main() async { // (when installed) the operator's theme-plugin choice // before the first frame so nothing flickers on startup. await HubService.instance.loadPersistedEndpoint(); + await SystemActions.loadFaiBinaryOverride(); final themeMode = await HubService.instance.loadThemeMode(); final locale = await HubService.instance.loadLocale(); final themePlugin = await loadActiveThemePlugin(); @@ -245,6 +247,42 @@ class StudioShellState extends State { String? _activeChannel; // local / dev / beta / production Timer? _healthPoll; + /// Consecutive failed health polls. After [_unreachableThreshold] + /// in a row we stop showing an indefinite silent "connecting…" + /// and surface a one-line "can't reach the hub" banner with a + /// jump to Settings. + int _failedPolls = 0; + static const int _unreachableThreshold = 3; + + /// Whether to render the unreachable banner. Distinct from + /// `_connected == false` so a single transient miss doesn't + /// flash the banner — only sustained failure does. + bool get _hubUnreachable => _failedPolls >= _unreachableThreshold; + + /// Current connection state, for descendants (e.g. WelcomePage) + /// that adapt their content to hub availability. + bool? get connected => _connected; + + /// Start the local daemon via the platform binary, then surface + /// the outcome as a snackbar. Shared by the sidebar's connection + /// row and the WelcomePage hero CTA. + Future startDaemon() async { + final l = AppLocalizations.of(context)!; + final r = await SystemActions.faiDaemon(['start']); + if (!mounted) return; + final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), + ), + ), + ); + // Probe again shortly so the UI flips to "connected" without + // waiting for the next 5 s tick. + Future.delayed(const Duration(seconds: 1), _checkHealth); + } + /// Switch the sidebar's selected page by its registered id /// (`store`, `flows`, `audit`, etc.). Used by the welcome /// page's onboarding checklist to make each item a real @@ -311,6 +349,12 @@ class StudioShellState extends State { selectedIcon: Icons.inbox, page: ApprovalsPage(), ), + _NavPage( + id: 'federation', + icon: Icons.hub_outlined, + selectedIcon: Icons.hub, + page: FederationPage(), + ), ]; /// Pending-approval count surfaced as a sidebar badge so the @@ -331,7 +375,17 @@ class StudioShellState extends State { Future _checkHealth() async { final ok = await HubService.instance.healthy(); if (!mounted) return; - if (_connected != ok) setState(() => _connected = ok); + final wasUnreachable = _hubUnreachable; + final nextFailed = ok ? 0 : _failedPolls + 1; + final connectionChanged = _connected != ok; + final bannerChanged = + wasUnreachable != (nextFailed >= _unreachableThreshold); + if (connectionChanged || _failedPolls != nextFailed || bannerChanged) { + setState(() { + _connected = ok; + _failedPolls = nextFailed; + }); + } if (ok) { try { final snap = await HubService.instance.channelStatus(); @@ -454,24 +508,35 @@ class StudioShellState extends State { ), Container(width: 1, color: theme.colorScheme.outlineVariant), Expanded( - child: AnimatedSwitcher( - duration: FaiMotion.base, - switchInCurve: FaiMotion.easing, - switchOutCurve: FaiMotion.easing, - transitionBuilder: (child, anim) => FadeTransition( - opacity: anim, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.02), - end: Offset.zero, - ).animate(anim), - child: child, + child: Column( + children: [ + if (_hubUnreachable) + _HubUnreachableBanner( + endpoint: HubService.instance.endpointLabel, + onOpenSettings: () => FaiSettingsDialog.show(context), + ), + Expanded( + child: AnimatedSwitcher( + duration: FaiMotion.base, + switchInCurve: FaiMotion.easing, + switchOutCurve: FaiMotion.easing, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.02), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ), + child: KeyedSubtree( + key: ValueKey(_selectedIndex), + child: _pages[_selectedIndex].page, + ), + ), ), - ), - child: KeyedSubtree( - key: ValueKey(_selectedIndex), - child: _pages[_selectedIndex].page, - ), + ], ), ), ], @@ -483,6 +548,58 @@ class StudioShellState extends State { } } +/// One-line banner shown when the hub has been unreachable for +/// several consecutive health polls. Replaces the indefinite +/// silent "connecting…" with an explicit message + a jump to +/// Settings, where the operator can fix the endpoint. +class _HubUnreachableBanner extends StatelessWidget { + final String endpoint; + final VoidCallback onOpenSettings; + + const _HubUnreachableBanner({ + required this.endpoint, + required this.onOpenSettings, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Material( + color: theme.colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.lg, + vertical: FaiSpace.sm, + ), + child: Row( + children: [ + Icon( + Icons.cloud_off_outlined, + size: 18, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + l.hubUnreachableBanner(endpoint), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + const SizedBox(width: FaiSpace.sm), + TextButton( + onPressed: onOpenSettings, + child: Text(l.hubUnreachableOpenSettings), + ), + ], + ), + ), + ); + } +} + /// Intents driven by the Shortcuts/Actions pair on the shell. class _GoToPageIntent extends Intent { final int index; @@ -553,7 +670,12 @@ class _SidebarState extends State<_Sidebar> super.initState(); _ctrl = AnimationController( vsync: this, - duration: FaiMotion.fast, + // Expand uses the slow (~320 ms) token: the icon→label + // reveal is a deliberate, eased gesture, not a flicker. + // The collapse runs faster (reverseDuration) so dismissing + // the rail feels crisp rather than sluggish. + duration: FaiMotion.slow, + reverseDuration: FaiMotion.base, value: 0, ); } @@ -565,14 +687,14 @@ class _SidebarState extends State<_Sidebar> } Future _startDaemon(BuildContext context) async { + final l = AppLocalizations.of(context)!; final r = await SystemActions.faiDaemon(['start']); if (!context.mounted) return; + final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - r.ok - ? 'Daemon start requested. Reconnecting…' - : 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}', + r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), ), ), ); @@ -1161,7 +1283,16 @@ class _SidebarItemState extends State<_SidebarItem> { // the left. The bg highlight spans the rail's // current width (typical nav-rail behaviour). padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), - decoration: BoxDecoration(color: bg), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(FaiRadius.sm), + // Lift only the selected item — a whisper of depth + // marks "you are here" without competing with the + // accent fill. + boxShadow: widget.selected + ? FaiElevation.low(theme.brightness) + : null, + ), child: content, ), ), @@ -1273,6 +1404,8 @@ class _NavPage { return l.navAudit; case 'approvals': return l.navApprovals; + case 'federation': + return l.navFederation; default: return id; } diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index ea6a0c5..ebef8bf 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -973,7 +973,7 @@ class _ExplanationPanel extends StatelessWidget { : theme.colorScheme.error, ), ), - if (result!.fixHint.isNotEmpty) ...[ + if (result!.fixHint(l).isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Text( l.auditFixLabel, @@ -984,7 +984,7 @@ class _ExplanationPanel extends StatelessWidget { ), const SizedBox(height: 2), Text( - result!.fixHint, + result!.fixHint(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 247934b..4b3fe0a 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -402,7 +402,15 @@ class _PathRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - final isLog = !isDirectory && path.toLowerCase().endsWith('.log'); + final lower = path.toLowerCase(); + final isLog = !isDirectory && lower.endsWith('.log'); + // Text config files get an in-Studio viewer too (read top-down, + // no log colouring) so the operator can read config.yaml + // without leaving Studio or hunting for an external editor. + final isConfig = !isDirectory && + (lower.endsWith('.yaml') || + lower.endsWith('.yml') || + lower.endsWith('.toml')); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -428,10 +436,11 @@ class _PathRow extends StatelessWidget { ), ), const SizedBox(width: FaiSpace.sm), - if (isLog) ...[ + if (isLog || isConfig) ...[ OutlinedButton.icon( - onPressed: () => - showFaiLogViewer(context, path: path, title: label), + onPressed: () => isConfig + ? showFaiConfigViewer(context, path: path, title: label) + : showFaiLogViewer(context, path: path, title: label), icon: const Icon(Icons.visibility_outlined, size: 14), label: Text(l.buttonView), style: OutlinedButton.styleFrom( @@ -482,6 +491,11 @@ class _DaemonActionsCard extends StatefulWidget { class _DaemonActionsCardState extends State<_DaemonActionsCard> { bool _busy = false; String? _output; + + /// True when the last daemon action failed because no `fai` + /// binary could be located. Swaps the raw output line for the + /// actionable [FaiBinaryRecovery] block. + bool _binaryMissing = false; ChannelStatusSnapshot? _channels; @override @@ -511,13 +525,19 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { final r = await action(); if (!mounted) return; final l = AppLocalizations.of(context)!; + final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound; setState(() { _busy = false; - _output = - (r.ok - ? '${l.daemonActionResultOk(label)}\n${r.stdout}' - : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') - .trim(); + _binaryMissing = binaryMissing; + // When the binary is missing we render the actionable + // recovery block instead of a raw output line — the + // sentinel is not a user-facing string. + _output = binaryMissing + ? null + : (r.ok + ? '${l.daemonActionResultOk(label)}\n${r.stdout}' + : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') + .trim(); }); // Refresh the status pill — restart / stop / start all // change the running flag. @@ -659,6 +679,15 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { ], ), ], + if (_binaryMissing) ...[ + const SizedBox(height: FaiSpace.md), + FaiBinaryRecovery( + onLocated: () => _run( + l.daemonActionStatus, + () => SystemActions.faiDaemon(['status']), + ), + ), + ], if (_output != null) ...[ const SizedBox(height: FaiSpace.md), Container( diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart new file mode 100644 index 0000000..3f744aa --- /dev/null +++ b/lib/pages/federation.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; +import '../widgets/widgets.dart'; +import 'welcome.dart' show showFaiDoc; + +/// Federation panel: the satellites connected to this hub (primary +/// side) and a one-step "add satellite" flow that issues a bootstrap +/// token + the primary CA as a ready-to-paste config. +class FederationPage extends StatefulWidget { + const FederationPage({super.key}); + + @override + State createState() => _FederationPageState(); +} + +class _FederationPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _refresh(); + } + + void _refresh() { + setState(() { + _future = HubService.instance.listSatellites(); + }); + } + + Future _addSatellite() async { + final name = await _promptName(context); + if (name == null || name.isEmpty) return; + if (!mounted) return; + final l = AppLocalizations.of(context)!; + try { + final enrollment = await HubService.instance.issueSatelliteToken(name); + if (!mounted) return; + await showDialog( + context: context, + builder: (_) => _EnrollmentDialog(name: name, enrollment: enrollment), + ); + _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.federationIssueFailed(e.toString()))), + ); + } + } + + Future _promptName(BuildContext context) async { + final controller = TextEditingController(); + final l = AppLocalizations.of(context)!; + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.federationAddDialogTitle), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: l.federationNameLabel, + hintText: 'satellite-a', + border: const OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, null), + child: Text(l.buttonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: Text(l.federationIssueButton), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + title: Text(l.federationTitle), + actions: [ + IconButton( + icon: const Icon(Icons.help_outline, size: 18), + tooltip: l.helpTooltip, + onPressed: () => showFaiDoc(context, 'federation'), + ), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: l.federationReloadTooltip, + onPressed: _refresh, + ), + const SizedBox(width: FaiSpace.sm), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _addSatellite, + icon: const Icon(Icons.add_link), + label: Text(l.federationAddSatellite), + ), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: l.hubUnreachable, + hint: l.hubUnreachableHint, + action: FilledButton.tonal( + onPressed: _refresh, + child: Text(l.buttonRetry), + ), + ); + } + final sats = snapshot.data ?? []; + if (sats.isEmpty) { + return FaiEmptyState( + icon: Icons.hub_outlined, + title: l.federationEmptyTitle, + hint: l.federationEmptyHint, + action: FilledButton.icon( + onPressed: _addSatellite, + icon: const Icon(Icons.add_link, size: 16), + label: Text(l.federationAddSatellite), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(FaiSpace.xl), + itemCount: sats.length, + separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), + itemBuilder: (context, i) => _SatelliteCard(satellite: sats[i]), + ); + }, + ), + ); + } +} + +class _SatelliteCard extends StatelessWidget { + final Satellite satellite; + const _SatelliteCard({required this.satellite}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return FaiCard( + accentTop: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FaiPill( + label: l.federationPillConnected, + tone: FaiPillTone.success, + icon: Icons.link, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + satellite.displayName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + FaiPill( + label: satellite.region.isEmpty + ? l.federationRegionNone + : satellite.region, + tone: FaiPillTone.neutral, + icon: Icons.public, + ), + ], + ), + const SizedBox(height: FaiSpace.md), + Wrap( + spacing: FaiSpace.md, + runSpacing: FaiSpace.xs, + children: [ + _meta(theme, 'v${satellite.hubVersion}'), + _meta(theme, 'wire ${satellite.wireVersion}'), + _meta(theme, satellite.hubId), + ], + ), + if (satellite.capabilities.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.md), + Text( + l.federationCapabilities(satellite.capabilities.length), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: satellite.capabilities + .map( + (c) => FaiPill( + label: c, + tone: FaiPillTone.accent, + icon: Icons.extension_outlined, + ), + ) + .toList(), + ), + ], + ], + ), + ); + } + + Widget _meta(ThemeData theme, String text) => Text( + text, + style: FaiTheme.mono(size: 10, color: theme.colorScheme.onSurfaceVariant), + ); +} + +/// Shows the freshly issued token + a ready-to-paste satellite config +/// (with the primary CA inlined) the operator copies over. +class _EnrollmentDialog extends StatelessWidget { + final String name; + final SatelliteEnrollment enrollment; + const _EnrollmentDialog({required this.name, required this.enrollment}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final config = enrollment.toConfigYaml(name); + return AlertDialog( + title: Text(l.federationEnrollmentTitle(name)), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 520), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.federationTokenLabel, style: theme.textTheme.labelSmall), + const SizedBox(height: 4), + _CopyableBlock(text: enrollment.token, copiedMsg: l.federationCopied), + const SizedBox(height: FaiSpace.md), + Text(l.federationConfigLabel, style: theme.textTheme.labelSmall), + const SizedBox(height: 4), + _CopyableBlock(text: config, copiedMsg: l.federationCopied), + const SizedBox(height: FaiSpace.md), + Text( + l.federationEnrollmentHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l.buttonClose), + ), + ], + ); + } +} + +class _CopyableBlock extends StatelessWidget { + final String text; + final String copiedMsg; + const _CopyableBlock({required this.text, required this.copiedMsg}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Stack( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + FaiSpace.md, + FaiSpace.md, + 40, + FaiSpace.md, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + text, + style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), + ), + ), + Positioned( + top: 2, + right: 2, + child: IconButton( + icon: const Icon(Icons.copy, size: 16), + tooltip: copiedMsg, + onPressed: () { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(copiedMsg))); + }, + ), + ), + ], + ); + } +} diff --git a/lib/pages/store.dart b/lib/pages/store.dart index e017406..05a85ab 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1792,7 +1792,7 @@ class _FeaturedTile extends StatelessWidget { } } -class _StoreCard extends StatelessWidget { +class _StoreCard extends StatefulWidget { final StoreItem item; final String locale; @@ -1810,6 +1810,19 @@ class _StoreCard extends StatelessWidget { required this.onInstall, }); + @override + State<_StoreCard> createState() => _StoreCardState(); +} + +class _StoreCardState extends State<_StoreCard> { + bool _hovered = false; + + StoreItem get item => widget.item; + String get locale => widget.locale; + String? get installedVersion => widget.installedVersion; + VoidCallback get onTap => widget.onTap; + VoidCallback get onInstall => widget.onInstall; + /// True iff the operator has this module installed AND the /// store-index offers a strictly-newer best_version. Uses a /// dotted-numeric semver-light compare so 0.10.5 > 0.9.99. @@ -1836,146 +1849,167 @@ class _StoreCard extends StatelessWidget { final notInstallable = !installable && !item.installed; return Opacity( opacity: notInstallable ? 0.6 : 1.0, - child: Material( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: Padding( - padding: const EdgeInsets.all(FaiSpace.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + // Hover-lift: a 2px rise plus shadow bump signals the card + // is clickable without a heavy hover fill. Resting state + // carries the low elevation so cards read as physical. + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: FaiMotion.base, + curve: FaiMotion.easing, + transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + boxShadow: _hovered + ? FaiElevation.medium(theme.brightness) + : FaiElevation.low(theme.brightness), + ), + child: Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: Padding( + padding: const EdgeInsets.all(FaiSpace.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 18, - backgroundColor: theme.colorScheme.primary.withValues( - alpha: 0.12, - ), - child: Icon( - _iconForCategory(item.category), - size: 18, - color: theme.colorScheme.primary, - ), + Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.12, + ), + child: Icon( + _iconForCategory(item.category), + size: 18, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + item.category.isEmpty + ? '—' + : _categoryDisplayName( + context, + item.category, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (_hasUpdate) + Tooltip( + message: l.storeUpdateTooltip( + installedVersion ?? '?', + item.bestVersion, + ), + child: FaiPill( + label: l.storePillUpdate, + tone: FaiPillTone.warning, + icon: Icons.system_update_alt, + ), + ) + else if (item.installed) + FaiPill( + label: l.storePillInstalled, + tone: FaiPillTone.success, + icon: Icons.check, + ) + else if (item.status.isNotEmpty) + FaiPill( + label: _statusDisplayName(context, item.status), + tone: _toneForStatus(item.status), + ), + ], ), - const SizedBox(width: FaiSpace.sm), + const SizedBox(height: FaiSpace.sm), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - item.category.isEmpty - ? '—' - : _categoryDisplayName(context, item.category), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + child: Text( + tagline.isEmpty ? l.storeNoTagline : tagline, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), ), - if (_hasUpdate) - Tooltip( - message: l.storeUpdateTooltip( - installedVersion ?? '?', - item.bestVersion, - ), - child: FaiPill( - label: l.storePillUpdate, - tone: FaiPillTone.warning, - icon: Icons.system_update_alt, - ), - ) - else if (item.installed) - FaiPill( - label: l.storePillInstalled, - tone: FaiPillTone.success, - icon: Icons.check, - ) - else if (item.status.isNotEmpty) - FaiPill( - label: _statusDisplayName(context, item.status), - tone: _toneForStatus(item.status), - ), + const SizedBox(height: FaiSpace.sm), + Row( + children: [ + if (item.bestVersion.isNotEmpty) ...[ + Tooltip( + message: item.isFederated + ? l.storeAdvisoryVersionTooltip + : '', + child: FaiPill( + label: item.isFederated + ? '~v${item.bestVersion}' + : 'v${item.bestVersion}', + tone: FaiPillTone.neutral, + monospace: true, + ), + ), + const SizedBox(width: FaiSpace.xs), + ], + _ProvenancePill(item: item), + const Spacer(), + if (_hasUpdate) + FilledButton.icon( + onPressed: onInstall, + icon: const Icon(Icons.system_update_alt, size: 14), + label: Text(l.storeUpdateButton(item.bestVersion)), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ) + else if (item.installed) + TextButton.icon( + onPressed: onTap, + icon: const Icon(Icons.info_outline, size: 14), + label: Text(l.buttonDetails), + ) + else if (installable) + FilledButton.icon( + onPressed: onInstall, + icon: const Icon(Icons.download, size: 14), + label: Text(l.buttonInstall), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ) + else + OutlinedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.info_outline, size: 14), + label: Text(l.buttonDetails), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), ], ), - const SizedBox(height: FaiSpace.sm), - Expanded( - child: Text( - tagline.isEmpty ? l.storeNoTagline : tagline, - style: theme.textTheme.bodySmall, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(height: FaiSpace.sm), - Row( - children: [ - if (item.bestVersion.isNotEmpty) ...[ - Tooltip( - message: item.isFederated - ? l.storeAdvisoryVersionTooltip - : '', - child: FaiPill( - label: item.isFederated - ? '~v${item.bestVersion}' - : 'v${item.bestVersion}', - tone: FaiPillTone.neutral, - monospace: true, - ), - ), - const SizedBox(width: FaiSpace.xs), - ], - _ProvenancePill(item: item), - const Spacer(), - if (_hasUpdate) - FilledButton.icon( - onPressed: onInstall, - icon: const Icon(Icons.system_update_alt, size: 14), - label: Text(l.storeUpdateButton(item.bestVersion)), - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ) - else if (item.installed) - TextButton.icon( - onPressed: onTap, - icon: const Icon(Icons.info_outline, size: 14), - label: Text(l.buttonDetails), - ) - else if (installable) - FilledButton.icon( - onPressed: onInstall, - icon: const Icon(Icons.download, size: 14), - label: Text(l.buttonInstall), - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ) - else - OutlinedButton.icon( - onPressed: onTap, - icon: const Icon(Icons.info_outline, size: 14), - label: Text(l.buttonDetails), - style: OutlinedButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ], + ), ), ), ), @@ -2003,6 +2037,7 @@ class _StoreDetailSheet extends StatefulWidget { context: context, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index d5c6c29..1782d45 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -28,6 +28,11 @@ class WelcomePage extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; + // The shell polls hub health on a 5 s tick. When the hub is + // down a brand-new operator otherwise sees a dead, all- + // unchecked onboarding checklist with no obvious recovery — + // so we lead with a "start the hub" hero instead. + final hubDown = StudioShellState.of(context)?.connected == false; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)), @@ -52,22 +57,27 @@ class WelcomePage extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 960), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - _Hero(), - SizedBox(height: FaiSpace.xxl), - // Checklist comes second — operator-actionable - // path beats educational content. Auto-hides - // once the operator dismisses it. - _OnboardingChecklist(), - _PillarRow(), - SizedBox(height: FaiSpace.xxl), - _SectionLabel(textKey: _SectionLabelKey.trust), - SizedBox(height: FaiSpace.md), - _TrustPosture(), - SizedBox(height: FaiSpace.xxl), - _SectionLabel(textKey: _SectionLabelKey.docs), - SizedBox(height: FaiSpace.md), - _DocsRow(), + children: [ + const _Hero(), + const SizedBox(height: FaiSpace.xxl), + // When the hub is down the onboarding checklist + // would render dead (all-unchecked, nothing to + // probe). Lead with a "start the hub" card + // instead; restore the checklist once connected. + if (hubDown) ...[ + const _HubDownHero(), + const SizedBox(height: FaiSpace.xxl), + ] else + const _OnboardingChecklist(), + const _PillarRow(), + const SizedBox(height: FaiSpace.xxl), + const _SectionLabel(textKey: _SectionLabelKey.trust), + const SizedBox(height: FaiSpace.md), + const _TrustPosture(), + const SizedBox(height: FaiSpace.xxl), + const _SectionLabel(textKey: _SectionLabelKey.docs), + const SizedBox(height: FaiSpace.md), + const _DocsRow(), ], ), ), @@ -149,6 +159,113 @@ class _Hero extends StatelessWidget { } } +/// Prominent recovery hero shown on the Welcome page when the +/// hub is not reachable. Headline + one-line explanation + a big +/// "Start hub" CTA wired to the shell's daemon-start path, with +/// a secondary link to the install guide for the case where no +/// `fai` binary can be located. +class _HubDownHero extends StatefulWidget { + const _HubDownHero(); + + @override + State<_HubDownHero> createState() => _HubDownHeroState(); +} + +class _HubDownHeroState extends State<_HubDownHero> { + bool _starting = false; + + Future _start() async { + final shell = StudioShellState.of(context); + if (shell == null) return; + setState(() => _starting = true); + await shell.startDaemon(); + if (!mounted) return; + setState(() => _starting = false); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + // No `fai` binary → "Start hub" cannot work; lead the + // operator to the install guide instead. + final canStart = SystemActions.faiBinaryExists(); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.xl), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(FaiRadius.md), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.cloud_off_outlined, + size: 24, + color: theme.colorScheme.error, + ), + const SizedBox(width: FaiSpace.md), + Expanded( + child: Text( + l.welcomeHubDownTitle, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: FaiSpace.md), + Text( + l.welcomeHubDownBody, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: FaiSpace.xs), + Text( + l.welcomeHubDownEndpoint(HubService.instance.endpointLabel), + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.lg), + if (canStart) + FilledButton.icon( + onPressed: _starting ? null : _start, + icon: _starting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_arrow), + label: Text( + _starting ? l.welcomeHubDownStarting : l.welcomeHubDownStart, + ), + ) + else + const FaiBinaryRecovery(), + const SizedBox(height: FaiSpace.sm), + TextButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(l.welcomeHubDownDocsLink), + onPressed: () => SystemActions.openInOs(kFaiInstallDocsUrl), + ), + ], + ), + ); + } +} + enum _SectionLabelKey { trust, docs } class _SectionLabel extends StatelessWidget { @@ -904,58 +1021,82 @@ class _DocsRow extends StatelessWidget { } } -class _DocCard extends StatelessWidget { +class _DocCard extends StatefulWidget { final _DocEntry entry; const _DocCard({required this.entry}); + @override + State<_DocCard> createState() => _DocCardState(); +} + +class _DocCardState extends State<_DocCard> { + bool _hovered = false; + @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - return Material( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: InkWell( - onTap: () => _DocReaderSheet.show(context, entry), - borderRadius: BorderRadius.circular(FaiRadius.md), - child: Container( - padding: const EdgeInsets.all(FaiSpace.lg), - decoration: BoxDecoration( + final entry = widget.entry; + // Hover-lift mirrors the Store cards: a gentle 2px rise plus + // shadow bump. Resting state stays flat-with-border so the + // doc strip reads calm until the cursor invites a click. + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: FaiMotion.base, + curve: FaiMotion.easing, + transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + boxShadow: _hovered ? FaiElevation.medium(theme.brightness) : null, + ), + child: Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: InkWell( + onTap: () => _DocReaderSheet.show(context, entry), borderRadius: BorderRadius.circular(FaiRadius.md), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(entry.icon, size: 22, color: theme.colorScheme.primary), - const SizedBox(width: FaiSpace.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.title(l), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - entry.blurb(l), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.4, - ), - ), - ], - ), + child: Container( + padding: const EdgeInsets.all(FaiSpace.lg), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + border: Border.all(color: theme.colorScheme.outlineVariant), ), - Icon( - Icons.chevron_right, - size: 18, - color: theme.colorScheme.onSurfaceVariant, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(entry.icon, size: 22, color: theme.colorScheme.primary), + const SizedBox(width: FaiSpace.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title(l), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + entry.blurb(l), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ], ), - ], + ), ), ), ), @@ -983,6 +1124,15 @@ class _DocReaderError { String get forgejoUrl => 'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md'; + /// Localized, operator-facing failure body. Use this for any + /// on-screen rendering; [toString] stays English for logs. + String localizedBody(AppLocalizations l) => l.welcomeDocLoadFailedBody( + slug, + locale, + attempted.join(', '), + underlying, + ); + @override String toString() => 'Could not load the bundled doc "$slug" ($locale).\n' @@ -1006,6 +1156,7 @@ class _DocReaderSheet extends StatefulWidget { context: context, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), @@ -1135,7 +1286,18 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { ), ), const SizedBox(height: FaiSpace.sm), - FaiErrorBox(error: err, isError: true, maxHeight: 200), + if (structured != null) + FaiErrorBox( + text: structured.localizedBody(l), + isError: true, + maxHeight: 200, + ) + else + FaiErrorBox( + error: err, + isError: true, + maxHeight: 200, + ), if (structured != null) ...[ const SizedBox(height: FaiSpace.md), TextButton.icon( diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 67fb7bd..2036fdc 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -2,55 +2,75 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; class FaiTheme { FaiTheme._(); + /// Bundled font families. Declared in pubspec.yaml `fonts:` and + /// shipped as TTF assets under `assets/fonts/` — NOT fetched at + /// runtime via google_fonts. This keeps the app working fully + /// offline / air-gapped (KRITIS) and avoids the font-swap flash + /// google_fonts shows on first paint. + static const String _uiFamily = 'Inter'; + static const String _monoFamily = 'JetBrains Mono'; + /// Inter for UI, JetBrains Mono for technical strings (IDs, - /// paths, capability refs). Both via google_fonts so they - /// bundle on first run; for production we'd ship them as - /// app assets. + /// paths, capability refs). Both bundled (see [_uiFamily]). static TextTheme _textTheme(Brightness b) { final base = b == Brightness.dark ? Typography.whiteCupertino : Typography.blackCupertino; - return GoogleFonts.interTextTheme(base).copyWith( - displaySmall: GoogleFonts.inter( + return base.apply(fontFamily: _uiFamily).copyWith( + displaySmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 28, fontWeight: FontWeight.w600, letterSpacing: -0.4, ), - headlineSmall: GoogleFonts.inter( + headlineSmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), - titleMedium: GoogleFonts.inter( + titleMedium: const TextStyle( + fontFamily: _uiFamily, fontSize: 15, fontWeight: FontWeight.w600, letterSpacing: 0, ), - titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), - bodyLarge: GoogleFonts.inter( + titleSmall: const TextStyle( + fontFamily: _uiFamily, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + bodyLarge: const TextStyle( + fontFamily: _uiFamily, fontSize: 14, fontWeight: FontWeight.w400, height: 1.45, ), - bodyMedium: GoogleFonts.inter( + bodyMedium: const TextStyle( + fontFamily: _uiFamily, fontSize: 13, fontWeight: FontWeight.w400, height: 1.4, ), - bodySmall: GoogleFonts.inter( + bodySmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 12, fontWeight: FontWeight.w400, height: 1.4, ), - labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500), - labelSmall: GoogleFonts.inter( + labelMedium: const TextStyle( + fontFamily: _uiFamily, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + labelSmall: const TextStyle( + fontFamily: _uiFamily, fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.2, @@ -59,12 +79,14 @@ class FaiTheme { } /// JetBrains Mono for monospaced technical content. Used by - /// FaiMono helper. + /// FaiMono helper. Only Regular (400) and Medium (500) weights + /// are bundled — heavier requests fall back to the nearest. static TextStyle mono({ double size = 12, FontWeight weight = FontWeight.w400, Color? color, - }) => GoogleFonts.jetBrainsMono( + }) => TextStyle( + fontFamily: _monoFamily, fontSize: size, fontWeight: weight, color: color, @@ -221,8 +243,8 @@ class FaiTheme { scrolledUnderElevation: 0, centerTitle: false, // Bake the foreground colour straight into the title - // style. `headlineSmall` is built from GoogleFonts.inter - // with no color slot, and Material's "merge foreground + // style. `headlineSmall` is built from the bundled Inter + // family with no color slot, and Material's "merge foreground // colour at draw time" path leaves it null in some // light-mode builds — the title rendered white-on-white. titleTextStyle: textTheme.headlineSmall?.copyWith( diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index 845cedc..dbbc84b 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -61,6 +61,50 @@ class FaiRadius { static const md = 10.0; } +/// Elevation tokens. Subtle, Linear/Raycast-style depth — never +/// heavy drop-shadows. Two resting levels plus a hover bump. +/// Brightness-aware: dark themes lean on deeper, lower-alpha +/// shadows (they read as ambient occlusion against near-black +/// surfaces); light themes use softer, slightly larger spreads. +class FaiElevation { + FaiElevation._(); + + /// Resting elevation for cards and sheets. Just enough to lift + /// a surface off the canvas without announcing itself. + static List low(Brightness b) => b == Brightness.dark + ? const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 6, + offset: Offset(0, 2), + ), + ] + : const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ]; + + /// Raised elevation for hover-lifted cards and floating sheets. + static List medium(Brightness b) => b == Brightness.dark + ? const [ + BoxShadow( + color: Color(0x4D000000), + blurRadius: 16, + offset: Offset(0, 6), + ), + ] + : const [ + BoxShadow( + color: Color(0x1F000000), + blurRadius: 20, + offset: Offset(0, 8), + ), + ]; +} + /// Animation durations. Keep them under 300ms for power-user feel. class FaiMotion { FaiMotion._(); diff --git a/lib/widgets/fai_binary_recovery.dart b/lib/widgets/fai_binary_recovery.dart new file mode 100644 index 0000000..d92aa99 --- /dev/null +++ b/lib/widgets/fai_binary_recovery.dart @@ -0,0 +1,95 @@ +// Recovery affordance shown when Studio cannot locate the `fai` +// binary on the machine. Replaces the old CLI-jargon dead-end +// ("set FAI_BIN to its full path") with two acts a non-CLI +// operator can actually take: +// +// 1. Locate the binary with a native file picker, persisting +// the choice via SystemActions.setFaiBinaryPath. +// 2. Open the install guide in the OS browser. + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../data/system_actions.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; + +class FaiBinaryRecovery extends StatelessWidget { + /// Fired after the operator successfully picks a `fai` binary, + /// so the host can retry whatever action hit the missing + /// binary (e.g. re-run the daemon start / status probe). + final VoidCallback? onLocated; + + const FaiBinaryRecovery({super.key, this.onLocated}); + + Future _locate(BuildContext context) async { + final l = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.of(context); + final picked = await FilePicker.pickFiles( + dialogTitle: l.faiBinaryPickerTitle, + ); + final path = picked?.files.single.path; + if (path == null) return; + final ok = await SystemActions.setFaiBinaryPath(path); + messenger.showSnackBar( + SnackBar( + content: Text(ok ? l.faiBinarySetOk(path) : l.faiBinaryNotFound), + ), + ); + if (ok) onLocated?.call(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.faiBinaryNotFound, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: FaiSpace.xs), + Text( + l.faiBinaryNotFoundHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + Wrap( + spacing: FaiSpace.sm, + runSpacing: FaiSpace.sm, + children: [ + FilledButton.tonalIcon( + onPressed: () => _locate(context), + icon: const Icon(Icons.folder_open, size: 16), + label: Text(l.faiBinaryLocateButton), + ), + OutlinedButton.icon( + onPressed: () => + SystemActions.openInOs(kFaiInstallDocsUrl), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(l.faiBinaryInstallDocsButton), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/fai_empty_state.dart b/lib/widgets/fai_empty_state.dart index 10816da..cf393b9 100644 --- a/lib/widgets/fai_empty_state.dart +++ b/lib/widgets/fai_empty_state.dart @@ -1,9 +1,17 @@ // FaiEmptyState — gracious empty / loading / error placeholder. // Replaces "(no data)" strings with one tone, one icon, one tip. +// +// The blank surface is where an app most easily feels unfinished, +// so this state is deliberately *designed*: the F∆I delta (∆) +// sits as a soft watermark behind the icon badge, the badge +// carries a whisper of elevation, and the copy breathes. The +// result reads "nothing here yet, on purpose" rather than +// "something is broken". import 'package:flutter/material.dart'; import '../theme/tokens.dart'; +import 'fai_delta_mark.dart'; class FaiEmptyState extends StatelessWidget { final IconData icon; @@ -30,40 +38,67 @@ class FaiEmptyState extends StatelessWidget { final color = iconColor ?? theme.colorScheme.onSurfaceVariant; return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), + constraints: const BoxConstraints(maxWidth: 440), child: Padding( padding: const EdgeInsets.all(FaiSpace.xxl), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - shape: BoxShape.circle, + // Brand hero: an oversized, low-opacity ∆ watermark + // anchors the composition while the contextual icon + // badge sits crisply on top — so every empty surface + // still carries the F∆I signature, never a bare icon. + SizedBox( + width: 112, + height: 112, + child: Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: 0.10, + child: FaiDeltaMark( + color: theme.colorScheme.primary, + size: 112, + ), + ), + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + shape: BoxShape.circle, + border: Border.all( + color: color.withValues(alpha: 0.18), + ), + boxShadow: FaiElevation.low(theme.brightness), + ), + child: Icon(icon, size: 26, color: color), + ), + ], ), - child: Icon(icon, size: 24, color: color), ), - const SizedBox(height: FaiSpace.lg), + const SizedBox(height: FaiSpace.xl), Text( title, textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), if (hint != null) ...[ const SizedBox(height: FaiSpace.sm), Text( hint!, textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( + style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, + height: 1.5, ), ), ], if (action != null) ...[ - const SizedBox(height: FaiSpace.lg), + const SizedBox(height: FaiSpace.xl), action!, ], ], diff --git a/lib/widgets/fai_log_viewer.dart b/lib/widgets/fai_log_viewer.dart index c4fd243..a4b67eb 100644 --- a/lib/widgets/fai_log_viewer.dart +++ b/lib/widgets/fai_log_viewer.dart @@ -36,6 +36,8 @@ Future showFaiLogViewer( required String path, String? title, int tailLines = 500, + bool fromTop = false, + bool plain = false, }) { return showDialog( context: context, @@ -44,20 +46,51 @@ Future showFaiLogViewer( path: path, title: title ?? path.split(Platform.pathSeparator).last, tailLines: tailLines, + fromTop: fromTop, + plain: plain, ), ); } +/// Open a config / plain-text file (e.g. `config.yaml`) inside the +/// same modal viewer, read top-down with no log-level colouring. +/// A thin wrapper over [showFaiLogViewer] so call sites read by +/// intent. +Future showFaiConfigViewer( + BuildContext context, { + required String path, + String? title, + int maxLines = 2000, +}) { + return showFaiLogViewer( + context, + path: path, + title: title, + tailLines: maxLines, + fromTop: true, + plain: true, + ); +} + class FaiLogViewer extends StatefulWidget { final String path; final String title; final int tailLines; + /// Read from the top of the file (config) vs the tail (logs). + final bool fromTop; + + /// Skip log-level colouring and render every line neutral + /// (for config / plain-text files). + final bool plain; + const FaiLogViewer({ super.key, required this.path, required this.title, this.tailLines = 500, + this.fromTop = false, + this.plain = false, }); @override @@ -77,7 +110,11 @@ class _FaiLogViewerState extends State { Future _reload() async { setState(() => _loading = true); - final lines = await _readTail(widget.path, widget.tailLines); + final lines = await _readLines( + widget.path, + widget.tailLines, + fromTop: widget.fromTop, + ); if (!mounted) return; setState(() { _lines = lines; @@ -141,7 +178,7 @@ class _FaiLogViewerState extends State { ), ), ) - : _LogBody(lines: _lines), + : _LogBody(lines: _lines, plain: widget.plain), ), ], ), @@ -249,8 +286,9 @@ class _Header extends StatelessWidget { class _LogBody extends StatelessWidget { final List lines; + final bool plain; - const _LogBody({required this.lines}); + const _LogBody({required this.lines, this.plain = false}); @override Widget build(BuildContext context) { @@ -265,6 +303,7 @@ class _LogBody extends StatelessWidget { text: lines[i], gutterWidth: gutterWidth, theme: theme, + plain: plain, ), ), ); @@ -276,12 +315,14 @@ class _LogLine extends StatelessWidget { final String text; final double gutterWidth; final ThemeData theme; + final bool plain; const _LogLine({ required this.lineNumber, required this.text, required this.gutterWidth, required this.theme, + this.plain = false, }); @override @@ -307,7 +348,12 @@ class _LogLine extends StatelessWidget { const SizedBox(width: FaiSpace.sm), Expanded( child: SelectableText.rich( - _highlightLine(text, theme), + plain + ? TextSpan( + text: text, + style: TextStyle(color: theme.colorScheme.onSurface), + ) + : _highlightLine(text, theme), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, @@ -420,22 +466,43 @@ TextSpan _highlightTraceLine(String line, ThemeData theme) { ); } -/// Read the last [maxLines] of [path]. Returns an empty list on -/// any I/O failure — the viewer renders an empty-state hint in -/// that case rather than throwing. -Future> _readTail(String path, int maxLines) async { +/// Matches ANSI / VT100 escape sequences (colour codes, cursor +/// moves) plus stray control characters. Daemon logs written to a +/// file by older `fai` builds carried `\x1b[..m` colour codes that +/// rendered as garbled "special characters" in the viewer; we +/// strip them defensively so existing log files render clean even +/// though current builds no longer write ANSI to files. +final RegExp _ansiAndControl = RegExp( + r'\x1B\[[0-9;?]*[ -/]*[@-~]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', +); + +String _sanitizeLine(String line) => line.replaceAll(_ansiAndControl, ''); + +/// Read up to [maxLines] of [path], sanitising each line of ANSI / +/// control characters. When [fromTop] is true the *first* +/// [maxLines] are returned (config files, read top-down); +/// otherwise the *last* [maxLines] are returned (logs, tail-first). +/// Returns an empty list on any I/O failure — the viewer renders +/// an empty-state hint rather than throwing. +Future> _readLines( + String path, + int maxLines, { + bool fromTop = false, +}) async { try { final f = File(path); if (!await f.exists()) return const []; final raw = await f.readAsString(); - final lines = raw.split('\n'); + final lines = raw.split('\n').map(_sanitizeLine).toList(); // Drop the trailing blank line that `split` leaves behind // when the file ends with `\n`. if (lines.isNotEmpty && lines.last.isEmpty) { lines.removeLast(); } if (lines.length <= maxLines) return lines; - return lines.sublist(lines.length - maxLines); + return fromTop + ? lines.sublist(0, maxLines) + : lines.sublist(lines.length - maxLines); } catch (_) { return const []; } diff --git a/lib/widgets/fai_module_sheet.dart b/lib/widgets/fai_module_sheet.dart index 65a7974..5ae7d36 100644 --- a/lib/widgets/fai_module_sheet.dart +++ b/lib/widgets/fai_module_sheet.dart @@ -26,6 +26,7 @@ class FaiModuleSheet extends StatefulWidget { context: context, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, isScrollControlled: true, + elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 9ad7460..8510e17 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -293,11 +293,12 @@ class _FaiSettingsDialogState extends State { }); final r = await SystemActions.faiChannelSwitch(name); if (!mounted) return; + final l = AppLocalizations.of(context)!; setState(() { _saving = false; _channelToast = r.ok - ? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}' - : 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}'; + ? '${l.channelSwitchOk(name)}\n${r.stdout.trim()}' + : l.channelSwitchFailed(r.stderr.isEmpty ? r.stdout : r.stderr); }); if (r.ok) await _loadChannels(); } diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index 57da186..77c3589 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -86,6 +86,26 @@ class _ProviderPreset { static _ProviderPreset byWire(String wire) { return all.firstWhere((p) => p.wire == wire, orElse: () => all.first); } + + /// Localized help body for this provider. Falls back to the + /// const English [description] for any future wire that has no + /// ARB key yet. + String descriptionFor(AppLocalizations l) { + switch (wire) { + case 'ollama': + return l.providerDescOllama; + case 'openai': + return l.providerDescOpenai; + case 'lmstudio': + return l.providerDescLmstudio; + case 'vllm': + return l.providerDescVllm; + case 'custom': + return l.providerDescCustom; + default: + return description; + } + } } class FaiSystemAiEditor extends StatefulWidget { @@ -372,7 +392,7 @@ class _FaiSystemAiEditorState extends State { _ProviderDropdown(value: _preset, onChanged: _onProviderChanged), const SizedBox(height: 4), Text( - _preset.description, + _preset.descriptionFor(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -634,10 +654,10 @@ class _TestResultPanel extends StatelessWidget { ok ? l.systemAiReplyPrefix(result.text) : result.text, style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), ), - if (!ok && result.fixHint.isNotEmpty) ...[ + if (!ok && result.fixHint(l).isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Text( - result.fixHint, + result.fixHint(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index b9554ed..6f294c0 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -4,6 +4,7 @@ // primitives directly — no Material `Card` / generic `Container` // soup in page code. +export 'fai_binary_recovery.dart'; export 'fai_card.dart'; export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index df8d2f7..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 3c891f9..21128e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,14 +57,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" - url: "https://pub.dev" - source: hosted - version: "1.2.0" collection: dependency: transitive description: @@ -235,14 +227,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" - url: "https://pub.dev" - source: hosted - version: "8.1.0" google_identity_services_web: dependency: transitive description: @@ -283,14 +267,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" - hooks: - dependency: transitive - description: - name: hooks - sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 - url: "https://pub.dev" - source: hosted - version: "2.0.0" http: dependency: transitive description: @@ -323,22 +299,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" leak_tracker: dependency: transitive description: @@ -379,14 +339,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" markdown: dependency: transitive description: @@ -427,22 +379,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" - url: "https://pub.dev" - source: hosted - version: "9.4.1" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" path: dependency: "direct main" description: @@ -451,30 +387,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -531,22 +443,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" scrollable_positioned_list: dependency: transitive description: @@ -802,4 +698,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 931dc4e..4a794ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.69.0 +version: 0.70.0 environment: sdk: ^3.11.0-200.1.beta @@ -19,7 +19,6 @@ dependencies: # Sibling package; will move to Forgejo path once published. fai_client_sdk: path: ../fai_client_sdk_dart - google_fonts: ^8.1.0 shared_preferences: ^2.5.5 # Inline README rendering inside the Store detail sheet. # `flutter_markdown` is discontinued; `flutter_markdown_plus` @@ -68,3 +67,24 @@ flutter: # Air-gap-friendly — no network calls to read these. assets: - assets/docs/ + # Bundled fonts — Inter (UI) + JetBrains Mono (technical + # strings). Shipped as assets rather than fetched at runtime + # via google_fonts so the app works fully offline / air-gapped + # (KRITIS) and never shows a font-swap flash on first paint. + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Regular.ttf + weight: 400 + - asset: assets/fonts/Inter-Medium.ttf + weight: 500 + - asset: assets/fonts/Inter-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Inter-Bold.ttf + weight: 700 + - family: JetBrains Mono + fonts: + - asset: assets/fonts/JetBrainsMono-Regular.ttf + weight: 400 + - asset: assets/fonts/JetBrainsMono-Medium.ttf + weight: 500 diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a962892..88b22e5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES)