Compare commits

...

4 commits

Author SHA1 Message Date
flemming-it
cb130b2f03 fix(studio): connect to the active channel, not hard-coded :50051
Some checks failed
Security / Security check (push) Failing after 1s
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/<ch>.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 <stefan.a.flemming@googlemail.com>
2026-06-18 13:52:05 +02:00
flemming-it
5f56abcf5f fix(studio): audit batch — debug.echo install, copyable errors, fast Diagnose
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
  @<constraint> 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 <stefan.a.flemming@googlemail.com>
2026-06-18 13:02:21 +02:00
flemming-it
ee4a0f6c62 fix(ui): window title Ch∆In Studio (was F∆I); de-dup federation add-satellite
- 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 <stefan.a.flemming@googlemail.com>
2026-06-18 12:53:35 +02:00
flemming-it
a8feb159ce fix(ui): production channel pill is green, not error-red
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 <stefan.a.flemming@googlemail.com>
2026-06-18 12:45:59 +02:00
8 changed files with 219 additions and 60 deletions

View file

@ -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 /// Show [error] as a centered modal dialog. Use this when the
/// failure blocks meaningful interaction (auth missing, hub /// failure blocks meaningful interaction (auth missing, hub
/// unreachable) a SnackBar would be too easy to dismiss. /// unreachable) a SnackBar would be too easy to dismiss.

View file

@ -5,6 +5,7 @@
// Methods return UI-friendly types so pages stay free of // Methods return UI-friendly types so pages stay free of
// protobuf imports. // protobuf imports.
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart';
@ -28,10 +29,79 @@ class HubService {
HubEndpoint get currentEndpoint => _client.endpoint; 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 _kHostKey = 'hub.host';
static const _kPortKey = 'hub.port'; static const _kPortKey = 'hub.port';
static const _kSecureKey = 'hub.secure'; 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/<channel>.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/<channel>.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" /// Sentinel distinguishing "caller did not pass authToken"
/// from "caller passed null to drop the token". /// from "caller passed null to drop the token".
static const Object _unset = Object(); static const Object _unset = Object();
@ -45,32 +115,59 @@ class HubService {
final port = prefs.getInt(_kPortKey); final port = prefs.getInt(_kPortKey);
final secure = prefs.getBool(_kSecureKey); final secure = prefs.getBool(_kSecureKey);
final token = await HubAuthToken.read(); final token = await HubAuthToken.read();
if (host == null && token == null) return;
final endpoint = HubEndpoint( // 1) An endpoint the operator explicitly chose in Settings wins.
host: host ?? _client.endpoint.host, if (host != null) {
port: port ?? _client.endpoint.port, await reconnect(
secure: secure ?? _client.endpoint.secure, HubEndpoint(
); host: host,
await reconnect(endpoint, authToken: token); 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. /// Reconnect to a new endpoint. With [persist] (the default) the
/// [authToken] is read from `~/.chain/hub-auth-token` by /// endpoint is saved for next launch that is an explicit operator
/// default pass `null` to drop a previously-loaded token, /// choice. Auto-discovery passes `persist: false` so it never
/// or omit the parameter to keep the current value. /// 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<void> reconnect( Future<void> reconnect(
HubEndpoint endpoint, { HubEndpoint endpoint, {
Object? authToken = _unset, Object? authToken = _unset,
bool persist = true,
}) async { }) async {
await _client.close(); await _client.close();
final token = identical(authToken, _unset) final token = identical(authToken, _unset)
? await HubAuthToken.read() ? await HubAuthToken.read()
: authToken as String?; : authToken as String?;
_client = HubClient(endpoint: endpoint, authToken: token); _client = HubClient(endpoint: endpoint, authToken: token);
final prefs = await SharedPreferences.getInstance(); if (persist) {
await prefs.setString(_kHostKey, endpoint.host); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kPortKey, endpoint.port); await prefs.setString(_kHostKey, endpoint.host);
await prefs.setBool(_kSecureKey, endpoint.secure); await prefs.setInt(_kPortKey, endpoint.port);
await prefs.setBool(_kSecureKey, endpoint.secure);
}
} }
/// Reload the token from disk and reconnect using the current /// Reload the token from disk and reconnect using the current
@ -902,7 +999,18 @@ class HubService {
.catchError((_) => <PendingApprovalEntry>[]), .catchError((_) => <PendingApprovalEntry>[]),
_client.verifyEventChain().catchError((_) => VerifyEventChainResponse()), _client.verifyEventChain().catchError((_) => VerifyEventChainResponse()),
_client.listServices().catchError((_) => <DeclaredService>[]), _client.listServices().catchError((_) => <DeclaredService>[]),
_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()), _client.daemonPaths().catchError((_) => DaemonPathsResponse()),
]); ]);

View file

@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'data/chain_log.dart'; import 'data/chain_log.dart';
import 'data/error_presentation.dart';
import 'data/hub.dart'; import 'data/hub.dart';
import 'data/system_actions.dart'; import 'data/system_actions.dart';
import 'data/theme_plugin.dart'; import 'data/theme_plugin.dart';
@ -270,14 +271,21 @@ class StudioShellState extends State<StudioShell> {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final r = await SystemActions.chainDaemon(['start']); final r = await SystemActions.chainDaemon(['start']);
if (!mounted) return; if (!mounted) return;
final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(l.daemonStartRequested)),
content: Text( );
r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), } 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 // Probe again shortly so the UI flips to "connected" without
// waiting for the next 5 s tick. // waiting for the next 5 s tick.
Future.delayed(const Duration(seconds: 1), _checkHealth); Future.delayed(const Duration(seconds: 1), _checkHealth);
@ -690,14 +698,21 @@ class _SidebarState extends State<_Sidebar>
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final r = await SystemActions.chainDaemon(['start']); final r = await SystemActions.chainDaemon(['start']);
if (!context.mounted) return; if (!context.mounted) return;
final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(l.daemonStartRequested)),
content: Text( );
r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), } 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 @override
@ -748,8 +763,11 @@ class _SidebarState extends State<_Sidebar>
Builder( Builder(
builder: (context) { builder: (context) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final ch = HubService.instance.connectedChannelName;
final caption = widget.connected == true final caption = widget.connected == true
? l.connectionConnected ? (ch != null
? '${l.connectionConnected} · $ch'
: l.connectionConnected)
: widget.connected == false : widget.connected == false
? l.connectionTapToStart ? l.connectionTapToStart
: l.connectionConnecting; : l.connectionConnecting;
@ -909,7 +927,8 @@ class _ChannelPill extends StatelessWidget {
Color _accent(ColorScheme cs) { Color _accent(ColorScheme cs) {
switch (channel) { switch (channel) {
case 'production': case 'production':
return cs.error; // "Live & healthy", not an error red here read as a fault.
return ChainColors.success;
case 'beta': case 'beta':
return ChainColors.warning; return ChainColors.warning;
case 'dev': case 'dev':
@ -1023,7 +1042,8 @@ class _CollapsedChannelChip extends StatelessWidget {
Color _accent(ColorScheme cs) { Color _accent(ColorScheme cs) {
switch (channel) { switch (channel) {
case 'production': case 'production':
return cs.error; // "Live & healthy", not an error red here read as a fault.
return ChainColors.success;
case 'beta': case 'beta':
return ChainColors.warning; return ChainColors.warning;
case 'dev': case 'dev':
@ -1110,8 +1130,12 @@ class _ConnectionLabel extends StatelessWidget {
final captionColor = connected == false final captionColor = connected == false
? theme.colorScheme.error ? theme.colorScheme.error
: theme.colorScheme.onSurface; : 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 final caption = connected == true
? l.connectionConnected ? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected)
: connected == false : connected == false
? l.connectionTapToStart ? l.connectionTapToStart
: l.connectionConnecting; : l.connectionConnecting;

View file

@ -5,6 +5,7 @@ import '../data/hub.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/theme.dart'; import '../theme/theme.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../data/error_presentation.dart';
import '../widgets/widgets.dart'; import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc; import 'welcome.dart' show showFaiDoc;
@ -48,9 +49,8 @@ class _FederationPageState extends State<FederationPage> {
_refresh(); _refresh();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( showFaiErrorSnack(context, 'federation.issue', e,
SnackBar(content: Text(l.federationIssueFailed(e.toString()))), title: l.federationIssueFailed(''));
);
} }
} }
@ -131,15 +131,13 @@ class _FederationPageState extends State<FederationPage> {
} }
final sats = snapshot.data ?? []; final sats = snapshot.data ?? [];
if (sats.isEmpty) { 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( return ChainEmptyState(
icon: Icons.hub_outlined, icon: Icons.hub_outlined,
title: l.federationEmptyTitle, title: l.federationEmptyTitle,
hint: l.federationEmptyHint, hint: l.federationEmptyHint,
action: FilledButton.icon(
onPressed: _addSatellite,
icon: const Icon(Icons.add_link, size: 16),
label: Text(l.federationAddSatellite),
),
); );
} }
return ListView.separated( return ListView.separated(

View file

@ -13,6 +13,7 @@
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/flow_run_driver.dart'; import '../data/flow_run_driver.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@ -94,7 +95,13 @@ class _FlowsPageState extends State<FlowsPage> {
/// list so the editor can re-analyze without waiting for the /// list so the editor can re-analyze without waiting for the
/// parent widget rebuild. /// parent widget rebuild.
Future<List<String>?> _onInstallCapability(String capability) async { Future<List<String>?> _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
// `@<constraint>` 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 <cap>` handler. Prompts the operator /// `Add source for <cap>` handler. Prompts the operator
@ -123,15 +130,13 @@ class _FlowsPageState extends State<FlowsPage> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final msg = l != null // Copyable error (was a plain SnackBar) install failures
? l.installFailed(e.toString()) // carry the real cause (network, signature, store lookup).
: 'Install failed: $e'; showFaiErrorSnack(
ScaffoldMessenger.of(context).removeCurrentSnackBar(); context,
ScaffoldMessenger.of(context).showSnackBar( 'flows.install',
SnackBar( e,
content: Text(msg), title: l != null ? l.installFailed('') : 'Install failed',
behavior: SnackBarBehavior.floating,
),
); );
} }
return null; return null;

View file

@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); 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_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } 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); gtk_window_set_default_size(window, 1280, 900);

View file

@ -13,9 +13,9 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>F∆I Studio</string> <string>Ch∆In Studio</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>F∆I Studio</string> <string>Ch∆In Studio</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View file

@ -7,9 +7,9 @@ class MainFlutterWindow: NSWindow {
self.contentViewController = flutterViewController self.contentViewController = flutterViewController
// Override the window title so the OS task switcher and // Override the window title so the OS task switcher and
// window chrome read "FI Studio" instead of the // window chrome read "ChIn Studio" instead of the
// pubspec-derived "chain_studio". // pubspec-derived "chain_studio".
self.title = "F∆I Studio" self.title = "Ch∆In Studio"
// Pick a comfortable default window size. 1440x900 is the // Pick a comfortable default window size. 1440x900 is the
// effective Retina resolution of a 13" MacBook (Stefan's // effective Retina resolution of a 13" MacBook (Stefan's