diff --git a/lib/data/error_presentation.dart b/lib/data/error_presentation.dart index 536fc1f..27ee75a 100644 --- a/lib/data/error_presentation.dart +++ b/lib/data/error_presentation.dart @@ -85,30 +85,6 @@ void showFaiErrorSnack( ); } -/// Surface a failed external-process result (daemon start, binary -/// probe, file-open) as a copyable error snack. The `ok/stdout/stderr` -/// record these actions return is not a thrown error, so call sites used -/// to drop it into a raw `SnackBar(Text(...))` — uncopyable, the exact -/// thing the operator needs to paste back. This joins stderr + stdout -/// into one selectable, one-tap-copyable, logged message. -void showFaiProcessError( - BuildContext context, - String source, - String stdout, - String stderr, { - String? title, -}) { - final detail = [stderr.trim(), stdout.trim()] - .where((s) => s.isNotEmpty) - .join('\n\n'); - showFaiErrorSnack( - context, - source, - detail.isEmpty ? 'process exited non-zero (no output)' : detail, - title: title, - ); -} - /// Show [error] as a centered modal dialog. Use this when the /// failure blocks meaningful interaction (auth missing, hub /// unreachable) — a SnackBar would be too easy to dismiss. diff --git a/lib/data/hub.dart b/lib/data/hub.dart index f113fa7..559dcaf 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -5,7 +5,6 @@ // Methods return UI-friendly types so pages stay free of // protobuf imports. -import 'dart:io'; import 'dart:typed_data'; import 'package:chain_client_sdk/chain_client_sdk.dart'; @@ -29,79 +28,10 @@ class HubService { HubEndpoint get currentEndpoint => _client.endpoint; - /// The channel Studio is connected to, discovered from - /// `~/.chain/current-channel` when no explicit endpoint was set. - /// Shown next to the connection caption so "connected" names which - /// daemon — never contradicting the Diagnose page again. - String? activeChannel; - - /// Best-effort name of the channel the current endpoint targets, - /// matched by its well-known port (50071 → production). Falls back to - /// the auto-discovered channel. `null` for a custom host/port. - String? get connectedChannelName { - final port = _client.endpoint.port; - for (final e in _channelPorts.entries) { - if (e.value == port) return e.key; - } - return activeChannel; - } - static const _kHostKey = 'hub.host'; static const _kPortKey = 'hub.port'; static const _kSecureKey = 'hub.secure'; - /// gRPC default ports per channel (mirror of the Rust - /// `Channel::default_port`). Used as the fallback when the daemon - /// has not written its `run/.endpoint` file yet. - static const _channelPorts = { - 'local': 50051, - 'dev': 50041, - 'beta': 50061, - 'production': 50071, - }; - - /// The endpoint of the channel the operator is actually on, read - /// from `~/.chain` (the hub writes `current-channel` and, when a - /// daemon is up, `run/.endpoint`). Lets a fresh Studio - /// follow the installed channel — a `curl | sh` user lands on - /// `production` (:50071), not the hard-coded local :50051. Returns - /// null when it cannot be determined (then we keep the default). - static ({String channel, HubEndpoint endpoint})? _discoverActiveChannel() { - try { - final home = Platform.environment['HOME'] ?? - Platform.environment['USERPROFILE']; - if (home == null || home.isEmpty) return null; - final sep = Platform.pathSeparator; - final base = '$home$sep.chain'; - final ccFile = File('$base${sep}current-channel'); - final channel = - ccFile.existsSync() ? ccFile.readAsStringSync().trim() : 'local'; - if (channel.isEmpty) return null; - // Prefer the actual bound endpoint the daemon wrote. - final epFile = File('$base${sep}run$sep$channel.endpoint'); - if (epFile.existsSync()) { - final raw = - epFile.readAsStringSync().trim().replaceFirst(RegExp(r'^\w+://'), ''); - final i = raw.lastIndexOf(':'); - if (i > 0) { - final host = raw.substring(0, i); - final port = int.tryParse(raw.substring(i + 1)); - if (port != null) { - return (channel: channel, endpoint: HubEndpoint(host: host, port: port)); - } - } - } - // Fall back to the channel's well-known default port. - return ( - channel: channel, - endpoint: - HubEndpoint(host: '127.0.0.1', port: _channelPorts[channel] ?? 50051), - ); - } catch (_) { - return null; - } - } - /// Sentinel distinguishing "caller did not pass authToken" /// from "caller passed null to drop the token". static const Object _unset = Object(); @@ -115,59 +45,32 @@ class HubService { final port = prefs.getInt(_kPortKey); final secure = prefs.getBool(_kSecureKey); final token = await HubAuthToken.read(); - - // 1) An endpoint the operator explicitly chose in Settings wins. - if (host != null) { - await reconnect( - HubEndpoint( - host: host, - port: port ?? _client.endpoint.port, - secure: secure ?? false, - ), - authToken: token, - ); - return; - } - - // 2) Otherwise follow the active channel from ~/.chain, so Studio - // connects to the daemon the operator installed (e.g. production - // :50071) instead of the hard-coded local :50051. Not persisted, - // so Studio re-discovers each launch and tracks channel switches. - final discovered = _discoverActiveChannel(); - if (discovered != null) { - activeChannel = discovered.channel; - await reconnect(discovered.endpoint, authToken: token, persist: false); - return; - } - - // 3) Nothing to discover: keep the default client, apply any token. - if (token != null) { - await reconnect(_client.endpoint, authToken: token, persist: false); - } + if (host == null && token == null) return; + final endpoint = HubEndpoint( + host: host ?? _client.endpoint.host, + port: port ?? _client.endpoint.port, + secure: secure ?? _client.endpoint.secure, + ); + await reconnect(endpoint, authToken: token); } - /// Reconnect to a new endpoint. With [persist] (the default) the - /// endpoint is saved for next launch — that is an explicit operator - /// choice. Auto-discovery passes `persist: false` so it never - /// overwrites a real choice and keeps re-following the active channel. - /// [authToken] is read from `~/.chain/hub-auth-token` by default — - /// pass `null` to drop a token, or omit to keep the current value. + /// Reconnect to a new endpoint and persist for next launch. + /// [authToken] is read from `~/.chain/hub-auth-token` by + /// default — pass `null` to drop a previously-loaded token, + /// or omit the parameter to keep the current value. Future reconnect( HubEndpoint endpoint, { Object? authToken = _unset, - bool persist = true, }) async { await _client.close(); final token = identical(authToken, _unset) ? await HubAuthToken.read() : authToken as String?; _client = HubClient(endpoint: endpoint, authToken: token); - if (persist) { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kHostKey, endpoint.host); - await prefs.setInt(_kPortKey, endpoint.port); - await prefs.setBool(_kSecureKey, endpoint.secure); - } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kHostKey, endpoint.host); + await prefs.setInt(_kPortKey, endpoint.port); + await prefs.setBool(_kSecureKey, endpoint.secure); } /// Reload the token from disk and reconnect using the current @@ -999,18 +902,7 @@ class HubService { .catchError((_) => []), _client.verifyEventChain().catchError((_) => VerifyEventChainResponse()), _client.listServices().catchError((_) => []), - // checkUpdate does a live manifest fetch (server-side 8s timeout). - // The other five are local-socket RPCs that return in ms, so cap - // this one tightly — otherwise it alone gates the whole Diagnose - // page open. A timeout reads as "release host unreachable", which - // the UI already renders as an offline banner, not an error. - _client - .checkUpdate() - .timeout( - const Duration(seconds: 2), - onTimeout: () => CheckUpdateResponse(), - ) - .catchError((_) => CheckUpdateResponse()), + _client.checkUpdate().catchError((_) => CheckUpdateResponse()), _client.daemonPaths().catchError((_) => DaemonPathsResponse()), ]); diff --git a/lib/main.dart b/lib/main.dart index fd3791a..3046664 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'data/chain_log.dart'; -import 'data/error_presentation.dart'; import 'data/hub.dart'; import 'data/system_actions.dart'; import 'data/theme_plugin.dart'; @@ -271,21 +270,14 @@ class StudioShellState extends State { final l = AppLocalizations.of(context)!; final r = await SystemActions.chainDaemon(['start']); if (!mounted) return; - if (r.ok) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.daemonStartRequested)), - ); - } else { - // Surface the daemon's stderr/stdout as a copyable error — this - // is the message the operator most often needs to paste back. - showFaiProcessError( - context, - 'daemon.start', - r.stdout, - r.stderr, - title: l.daemonStartFailed(''), - ); - } + 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); @@ -698,21 +690,14 @@ class _SidebarState extends State<_Sidebar> final l = AppLocalizations.of(context)!; final r = await SystemActions.chainDaemon(['start']); if (!context.mounted) return; - if (r.ok) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.daemonStartRequested)), - ); - } else { - // Surface the daemon's stderr/stdout as a copyable error — this - // is the message the operator most often needs to paste back. - showFaiProcessError( - context, - 'daemon.start', - r.stdout, - r.stderr, - title: l.daemonStartFailed(''), - ); - } + 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), + ), + ), + ); } @override @@ -763,11 +748,8 @@ class _SidebarState extends State<_Sidebar> Builder( builder: (context) { final l = AppLocalizations.of(context)!; - final ch = HubService.instance.connectedChannelName; final caption = widget.connected == true - ? (ch != null - ? '${l.connectionConnected} · $ch' - : l.connectionConnected) + ? l.connectionConnected : widget.connected == false ? l.connectionTapToStart : l.connectionConnecting; @@ -927,8 +909,7 @@ class _ChannelPill extends StatelessWidget { Color _accent(ColorScheme cs) { switch (channel) { case 'production': - // "Live & healthy", not an error — red here read as a fault. - return ChainColors.success; + return cs.error; case 'beta': return ChainColors.warning; case 'dev': @@ -1042,8 +1023,7 @@ class _CollapsedChannelChip extends StatelessWidget { Color _accent(ColorScheme cs) { switch (channel) { case 'production': - // "Live & healthy", not an error — red here read as a fault. - return ChainColors.success; + return cs.error; case 'beta': return ChainColors.warning; case 'dev': @@ -1130,12 +1110,8 @@ class _ConnectionLabel extends StatelessWidget { final captionColor = connected == false ? theme.colorScheme.error : theme.colorScheme.onSurface; - // Name the channel the connection is on (production / local / …) so - // "connected" can never look like it contradicts the Diagnose page, - // which reports the *active* channel's daemon. - final ch = HubService.instance.connectedChannelName; final caption = connected == true - ? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected) + ? l.connectionConnected : connected == false ? l.connectionTapToStart : l.connectionConnecting; diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart index a193380..b82baaf 100644 --- a/lib/pages/federation.dart +++ b/lib/pages/federation.dart @@ -5,7 +5,6 @@ import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; -import '../data/error_presentation.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; @@ -49,8 +48,9 @@ class _FederationPageState extends State { _refresh(); } catch (e) { if (!mounted) return; - showFaiErrorSnack(context, 'federation.issue', e, - title: l.federationIssueFailed('')); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.federationIssueFailed(e.toString()))), + ); } } @@ -131,13 +131,15 @@ class _FederationPageState extends State { } final sats = snapshot.data ?? []; if (sats.isEmpty) { - // The "add satellite" action lives in the FAB (always - // visible); don't repeat it here — that showed the button - // twice on the empty page. return ChainEmptyState( 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( diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 752b9ff..4120bf1 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -13,7 +13,6 @@ import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; import 'package:flutter/material.dart'; -import '../data/error_presentation.dart'; import '../data/flow_run_driver.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; @@ -95,13 +94,7 @@ class _FlowsPageState extends State { /// list so the editor can re-analyze without waiting for the /// parent widget rebuild. Future?> _onInstallCapability(String capability) async { - // The analyzer hands us the full `use:` value (e.g. `debug.echo@^0`). - // The hub resolves install sources by bare capability name; the - // `@` suffix would make the store lookup miss ("no store - // entry for 'debug.echo@^0'"). Strip it — same as the Store page. - final at = capability.indexOf('@'); - final bare = at < 0 ? capability : capability.substring(0, at); - return _runInstall(source: bare); + return _runInstall(source: capability); } /// `Add source for …` handler. Prompts the operator @@ -130,13 +123,15 @@ class _FlowsPageState extends State { } catch (e) { if (mounted) { final l = AppLocalizations.of(context); - // Copyable error (was a plain SnackBar) — install failures - // carry the real cause (network, signature, store lookup). - showFaiErrorSnack( - context, - 'flows.install', - e, - title: l != null ? l.installFailed('') : 'Install failed', + final msg = l != null + ? l.installFailed(e.toString()) + : 'Install failed: $e'; + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + behavior: SnackBarBehavior.floating, + ), ); } return null; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index b1be57a..12ee6f6 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "Ch\u2206In Studio"); + gtk_header_bar_set_title(header_bar, "F\u2206I Studio"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "Ch\u2206In Studio"); + gtk_window_set_title(window, "F\u2206I Studio"); } gtk_window_set_default_size(window, 1280, 900); diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index d4b23dd..3d844e0 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -13,9 +13,9 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Ch∆In Studio + F∆I Studio CFBundleDisplayName - Ch∆In Studio + F∆I Studio CFBundlePackageType APPL CFBundleShortVersionString diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 34a6e03..a856fe1 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -7,9 +7,9 @@ class MainFlutterWindow: NSWindow { self.contentViewController = flutterViewController // Override the window title so the OS task switcher and - // window chrome read "Ch∆In Studio" instead of the + // window chrome read "F∆I Studio" instead of the // pubspec-derived "chain_studio". - self.title = "Ch∆In Studio" + self.title = "F∆I Studio" // Pick a comfortable default window size. 1440x900 is the // effective Retina resolution of a 13" MacBook (Stefan's