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;