// SealedAreaService — discovers the operator's sealed project // instances by reading their manifests directly, the same way the // CLI's `chain project list` does. // // A sealed project is NOT a row in the shared hub's registry — it is // its own hub instance (own data dir, port, store config). The shared // hub never learns it exists (docs/architecture/projects.md). So // Studio cannot ask a hub for the list; it scans the manifest // directory itself: // // ~/.chain/sealed//project.json — the manifest // ~/.chain/run/sealed-.pid — running PID (if up) // ~/.chain/run/sealed-.endpoint — bound endpoint (if up) // // This mirrors crates/chain_runtime_mgmt/src/sealed.rs. Reading the // real ~/.chain honours the operator's actual areas; a test harness // redirects $HOME so it never touches them. import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; /// One sealed project instance as Studio needs it for the switcher /// and the connection switch. class SealedArea { final String slug; final String name; /// Accent colour hex (e.g. `#e0a458`), empty when unset. This is /// the area's identity colour — the marking on the switcher and /// the identity bar. (Studio's blue stays the app accent.) final String color; /// Fixed loopback port of the instance's hub. final int port; /// True when the instance's hub daemon is currently running (a /// live PID in `~/.chain/run/sealed-.pid`). final bool running; const SealedArea({ required this.slug, required this.name, required this.color, required this.port, required this.running, }); /// Loopback gRPC endpoint of the instance's hub. String get endpoint => 'http://127.0.0.1:$port'; @override bool operator ==(Object other) => other is SealedArea && other.slug == slug && other.name == name && other.color == color && other.port == port && other.running == running; @override int get hashCode => Object.hash(slug, name, color, port, running); } class SealedAreaService { SealedAreaService._(); static SealedAreaService instance = SealedAreaService._(); /// Test hook (HubService.debugSetInstance pattern): swap in a fake /// so suites never scan the operator's real `~/.chain`. Pass null /// to restore the real service. @visibleForTesting static void debugSetInstance(SealedAreaService? replacement) { instance = replacement ?? SealedAreaService._(); } /// Extension seam for the test fake — the real constructor is /// library-private, and the fake must not inherit a live /// filesystem scan by accident, so it overrides the members. @visibleForTesting SealedAreaService.forTest(); /// `~/.chain` root, or null when the home dir can't be determined. String? _chainHome() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) return null; return '$home${Platform.pathSeparator}.chain'; } /// Every sealed area found under `~/.chain/sealed/`, sorted by /// display name (case-insensitive). Soft-fails to an empty list — /// an unreadable or absent directory is the normal "no sealed /// areas" case, not an error. Future> list() async { final base = _chainHome(); if (base == null) return const []; final sep = Platform.pathSeparator; final dir = Directory('$base${sep}sealed'); if (!dir.existsSync()) return const []; final areas = []; for (final entry in dir.listSync()) { if (entry is! Directory) continue; final manifest = File('${entry.path}${sep}project.json'); if (!manifest.existsSync()) continue; try { final m = jsonDecode(manifest.readAsStringSync()) as Map; final slug = (m['slug'] as String?) ?? ''; final port = (m['port'] as num?)?.toInt() ?? 0; if (slug.isEmpty || port == 0) continue; areas.add( SealedArea( slug: slug, name: (m['name'] as String?)?.isNotEmpty == true ? m['name'] as String : slug, color: (m['color'] as String?) ?? '', port: port, running: _isRunning(base, sep, slug), ), ); } catch (_) { // Skip an unparseable manifest rather than failing the list. } } areas.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); return areas; } /// True when `~/.chain/run/sealed-.pid` holds a live PID. bool _isRunning(String base, String sep, String slug) { try { final pidFile = File('$base${sep}run${sep}sealed-$slug.pid'); if (!pidFile.existsSync()) return false; final pid = int.tryParse(pidFile.readAsStringSync().trim()); if (pid == null) return false; return _pidAlive(pid); } catch (_) { return false; } } /// Liveness probe without killing: `kill -0` on POSIX, tasklist on /// Windows. Matches the CLI's is_pid_alive semantics closely /// enough for a UI status dot. bool _pidAlive(int pid) { try { if (Platform.isWindows) { final r = Process.runSync('tasklist', ['/FI', 'PID eq $pid']); return r.stdout.toString().contains('$pid'); } final r = Process.runSync('kill', ['-0', '$pid']); return r.exitCode == 0; } catch (_) { return false; } } /// Read the bound endpoint the instance's daemon wrote, if any. /// Falls back to the manifest port when the run-file is absent. Future boundEndpoint(String slug) async { final base = _chainHome(); if (base == null) return null; final sep = Platform.pathSeparator; final f = File('$base${sep}run${sep}sealed-$slug.endpoint'); if (!f.existsSync()) return null; final raw = f.readAsStringSync().trim(); return raw.isEmpty ? null : raw; } }