From a8feb159ced928d44090c6778359e60e9b2fc11c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 12:45:59 +0200 Subject: [PATCH 1/4] fix(ui): production channel pill is green, not error-red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-channel pill coloured 'production' with the theme error colour — red read as 'something is broken'. Use ChainColors.success (live & healthy) instead; still visibly distinct from beta (amber), dev (accent) and local (grey). Signed-off-by: flemming-it --- lib/main.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 3046664..4e9d61a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -909,7 +909,8 @@ class _ChannelPill extends StatelessWidget { Color _accent(ColorScheme cs) { switch (channel) { case 'production': - return cs.error; + // "Live & healthy", not an error — red here read as a fault. + return ChainColors.success; case 'beta': return ChainColors.warning; case 'dev': @@ -1023,7 +1024,8 @@ class _CollapsedChannelChip extends StatelessWidget { Color _accent(ColorScheme cs) { switch (channel) { case 'production': - return cs.error; + // "Live & healthy", not an error — red here read as a fault. + return ChainColors.success; case 'beta': return ChainColors.warning; case 'dev': From ee4a0f6c627500114d7a04e7cf8382dc0476cc4c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 12:53:35 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(ui):=20window=20title=20Ch=E2=88=86In?= =?UTF-8?q?=20Studio=20(was=20F=E2=88=86I);=20de-dup=20federation=20add-sa?= =?UTF-8?q?tellite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - linux/macOS window chrome read 'F∆I Studio' (rename leftover) → 'Ch∆In Studio'. - The federation page showed 'Add satellite' twice when empty (FAB + empty-state button); drop the empty-state button, the FAB carries the action. Signed-off-by: flemming-it --- lib/pages/federation.dart | 8 +++----- linux/runner/my_application.cc | 4 ++-- macos/Runner/Info.plist | 4 ++-- macos/Runner/MainFlutterWindow.swift | 4 ++-- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart index b82baaf..3e26988 100644 --- a/lib/pages/federation.dart +++ b/lib/pages/federation.dart @@ -131,15 +131,13 @@ 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/linux/runner/my_application.cc b/linux/runner/my_application.cc index 12ee6f6..b1be57a 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, "F\u2206I Studio"); + gtk_header_bar_set_title(header_bar, "Ch\u2206In 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, "F\u2206I Studio"); + gtk_window_set_title(window, "Ch\u2206In Studio"); } gtk_window_set_default_size(window, 1280, 900); diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 3d844e0..d4b23dd 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -13,9 +13,9 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - F∆I Studio + Ch∆In Studio CFBundleDisplayName - F∆I Studio + Ch∆In Studio CFBundlePackageType APPL CFBundleShortVersionString diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index a856fe1..34a6e03 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 "F∆I Studio" instead of the + // window chrome read "Ch∆In Studio" instead of the // pubspec-derived "chain_studio". - self.title = "F∆I Studio" + self.title = "Ch∆In Studio" // Pick a comfortable default window size. 1440x900 is the // effective Retina resolution of a 13" MacBook (Stefan's From 5f56abcf5f8f1a1a5e32d9cbd2fc9bde39386dad Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 13:02:21 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(studio):=20audit=20batch=20=E2=80=94=20?= =?UTF-8?q?debug.echo=20install,=20copyable=20errors,=20fast=20Diagnose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a live-test audit: - Install via the flow-editor 'Fix' sent 'debug.echo@^0' (version constraint included) as the install source; the hub resolves by bare name so it missed ('no store entry for debug.echo@^0'). Strip the @ like the Store page does — debug.echo now installs. - Errors the operator could not copy: route the daemon-start failure (its stderr!), the flow-editor install failure and the federation issue failure through showFaiErrorSnack / a new showFaiProcessError helper, so every error is selectable, one-tap copyable and logged. - Diagnose page opened slowly because doctor() Future.wait-ed on a live manifest fetch (8s server timeout); cap that one check at 2s so the page no longer waits on the network. Signed-off-by: flemming-it --- lib/data/error_presentation.dart | 24 ++++++++++++++++ lib/data/hub.dart | 13 ++++++++- lib/main.dart | 47 +++++++++++++++++++++----------- lib/pages/federation.dart | 6 ++-- lib/pages/flows.dart | 25 ++++++++++------- 5 files changed, 85 insertions(+), 30 deletions(-) diff --git a/lib/data/error_presentation.dart b/lib/data/error_presentation.dart index 27ee75a..536fc1f 100644 --- a/lib/data/error_presentation.dart +++ b/lib/data/error_presentation.dart @@ -85,6 +85,30 @@ 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 559dcaf..f4a7985 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -902,7 +902,18 @@ class HubService { .catchError((_) => []), _client.verifyEventChain().catchError((_) => VerifyEventChainResponse()), _client.listServices().catchError((_) => []), - _client.checkUpdate().catchError((_) => CheckUpdateResponse()), + // 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.daemonPaths().catchError((_) => DaemonPathsResponse()), ]); diff --git a/lib/main.dart b/lib/main.dart index 4e9d61a..f0e94c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ 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'; @@ -270,14 +271,21 @@ class StudioShellState extends State { final l = AppLocalizations.of(context)!; final r = await SystemActions.chainDaemon(['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), - ), - ), - ); + 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(''), + ); + } // Probe again shortly so the UI flips to "connected" without // waiting for the next 5 s tick. Future.delayed(const Duration(seconds: 1), _checkHealth); @@ -690,14 +698,21 @@ class _SidebarState extends State<_Sidebar> final l = AppLocalizations.of(context)!; final r = await SystemActions.chainDaemon(['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 ? l.daemonStartRequested : l.daemonStartFailed(detail), - ), - ), - ); + 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(''), + ); + } } @override diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart index 3e26988..a193380 100644 --- a/lib/pages/federation.dart +++ b/lib/pages/federation.dart @@ -5,6 +5,7 @@ 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; @@ -48,9 +49,8 @@ class _FederationPageState extends State { _refresh(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.federationIssueFailed(e.toString()))), - ); + showFaiErrorSnack(context, 'federation.issue', e, + title: l.federationIssueFailed('')); } } diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 4120bf1..752b9ff 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -13,6 +13,7 @@ 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'; @@ -94,7 +95,13 @@ class _FlowsPageState extends State { /// list so the editor can re-analyze without waiting for the /// parent widget rebuild. Future?> _onInstallCapability(String capability) async { - return _runInstall(source: capability); + // 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); } /// `Add source for …` handler. Prompts the operator @@ -123,15 +130,13 @@ class _FlowsPageState extends State { } catch (e) { if (mounted) { final l = AppLocalizations.of(context); - 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, - ), + // 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', ); } return null; From cb130b2f032beecffff53099e68f3a28a896ac63 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 13:52:05 +0200 Subject: [PATCH 4/4] fix(studio): connect to the active channel, not hard-coded :50051 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio defaulted its endpoint to the local channel's port (:50051), but a curl|sh user is on the production channel (:50071) — so the sidebar probed a different daemon than the Diagnose page reported, showing 'connected' next to 'production daemon stopped'. On first run Studio now reads ~/.chain/current-channel (+ run/.endpoint) and follows the active channel; an explicit Settings endpoint still wins and persists, auto-discovery does not (re-follows the channel each launch). The connection caption now names the channel ('Connected · production') so it can never look contradictory again. Signed-off-by: flemming-it --- lib/data/hub.dart | 127 ++++++++++++++++++++++++++++++++++++++++------ lib/main.dart | 11 +++- 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index f4a7985..f113fa7 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -5,6 +5,7 @@ // 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'; @@ -28,10 +29,79 @@ 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(); @@ -45,32 +115,59 @@ class HubService { final port = prefs.getInt(_kPortKey); final secure = prefs.getBool(_kSecureKey); final token = await HubAuthToken.read(); - 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); + + // 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); + } } - /// 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. + /// 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. 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); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kHostKey, endpoint.host); - await prefs.setInt(_kPortKey, endpoint.port); - await prefs.setBool(_kSecureKey, endpoint.secure); + 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); + } } /// Reload the token from disk and reconnect using the current diff --git a/lib/main.dart b/lib/main.dart index f0e94c9..fd3791a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -763,8 +763,11 @@ class _SidebarState extends State<_Sidebar> Builder( builder: (context) { final l = AppLocalizations.of(context)!; + final ch = HubService.instance.connectedChannelName; final caption = widget.connected == true - ? l.connectionConnected + ? (ch != null + ? '${l.connectionConnected} · $ch' + : l.connectionConnected) : widget.connected == false ? l.connectionTapToStart : l.connectionConnecting; @@ -1127,8 +1130,12 @@ 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 - ? l.connectionConnected + ? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected) : connected == false ? l.connectionTapToStart : l.connectionConnecting;