Compare commits
4 commits
da30aae8e9
...
cb130b2f03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb130b2f03 | ||
|
|
5f56abcf5f | ||
|
|
ee4a0f6c62 | ||
|
|
a8feb159ce |
8 changed files with 219 additions and 60 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/<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"
|
||||
/// 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<void> 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
|
||||
|
|
@ -902,7 +999,18 @@ class HubService {
|
|||
.catchError((_) => <PendingApprovalEntry>[]),
|
||||
_client.verifyEventChain().catchError((_) => VerifyEventChainResponse()),
|
||||
_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()),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<StudioShell> {
|
|||
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
|
||||
|
|
@ -748,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;
|
||||
|
|
@ -909,7 +927,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 +1042,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':
|
||||
|
|
@ -1110,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;
|
||||
|
|
|
|||
|
|
@ -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<FederationPage> {
|
|||
_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(''));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,15 +131,13 @@ class _FederationPageState extends State<FederationPage> {
|
|||
}
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<FlowsPage> {
|
|||
/// list so the editor can re-analyze without waiting for the
|
||||
/// parent widget rebuild.
|
||||
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
|
||||
|
|
@ -123,15 +130,13 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
} 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@
|
|||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>F∆I Studio</string>
|
||||
<string>Ch∆In Studio</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>F∆I Studio</string>
|
||||
<string>Ch∆In Studio</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue