diff --git a/.gitignore b/.gitignore index ad2f713c..b8fd25e4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,10 @@ app/test/sim/frames/ # Local QA artifacts (screenshots, issue logs) from tool/shoot-ports.sh .qa/ -.piano/ + +# Editor/agent scratch files must never be committed. +*.bak +*.orig # Local pnpm store, created when a store-dir is set for this checkout .pnpm-store/ diff --git a/app/integration_test/desktop/control_e2e_test.dart b/app/integration_test/desktop/control_e2e_test.dart index 8d595e44..fc67b85a 100644 --- a/app/integration_test/desktop/control_e2e_test.dart +++ b/app/integration_test/desktop/control_e2e_test.dart @@ -27,6 +27,7 @@ import 'package:makit/desktop/settings/sections/server_devices_section.dart'; import 'package:makit/desktop/settings/server_config.dart'; import 'package:makit/store/connection.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; const _socketPath = String.fromEnvironment('MAKIT_CONTROL_SOCK'); const _timeout = Duration(seconds: 20); @@ -92,7 +93,10 @@ void main() { controlClientProvider.overrideWithValue(client), desktopControllerProvider.overrideWithValue(controller), serverConfigProvider.overrideWith( - (ref) => ServerConfigController(prefs, const ServerConfig()), + (ref) => ServerConfigController( + ProfileScopedPrefs.unscoped(prefs), + const ServerConfig(), + ), ), connectionProvider.overrideWithValue(MakitConnState()), ], @@ -139,7 +143,7 @@ void main() { ); }); - testWidgets('Endpoint bind-mode picker drives the unified ServerConfig', ( + testWidgets('Reachability picker drives the unified ServerConfig', ( tester, ) async { expect( @@ -150,7 +154,10 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final config = ServerConfigController(prefs, const ServerConfig()); + final config = ServerConfigController( + ProfileScopedPrefs.unscoped(prefs), + const ServerConfig(), + ); final client = ReconnectingControlClient( create: () => MakitControlClient(socketPath: _socketPath), @@ -187,16 +194,32 @@ void main() { reason: 'lifecycle never showed a running daemon', ); - // Ships defaulting to Auto (the new secure default) with no host field. - expect(config.current.bindMode, ServerBindMode.auto); + // The real daemon is up (the active-profile row reads "Running"). + await _pumpUntil( + tester, + find.text('Running'), + reason: 'active-profile row never showed a running daemon', + ); + + // Ships defaulting to "My devices" (the secure default), no host field. + expect(config.current.reachability, Reachability.myDevices); expect( find.ancestor(of: find.text('Host'), matching: find.byType(TextField)), findsNothing, ); - // Selecting Custom reveals a host field and persists the mode. - await _scrollAndTap(tester, find.text('Custom')); - expect(config.current.bindMode, ServerBindMode.custom); + // Selecting "Just this Mac" pins loopback in serveArgs. + await _scrollAndTap(tester, find.text('Just this Mac')); + await tester.pumpAndSettle(); + expect(config.current.reachability, Reachability.thisMacOnly); + expect( + config.current.serveArgs(), + containsAllInOrder(['--host', '127.0.0.1']), + ); + + // The custom-host escape hatch lives under Diagnostics → Advanced. + await _scrollAndTap(tester, find.text('Diagnostics')); + await _scrollAndTap(tester, find.text('Advanced')); final host = find.ancestor( of: find.text('Host'), matching: find.byType(TextField), @@ -212,9 +235,5 @@ void main() { config.current.serveArgs(), containsAllInOrder(['--host', '0.0.0.0']), ); - - // Switching to Loopback persists too (no daemon restart is triggered). - await _scrollAndTap(tester, find.text('Loopback')); - expect(config.current.bindMode, ServerBindMode.loopback); }); } diff --git a/app/integration_test/desktop/settings_repo_test.dart b/app/integration_test/desktop/settings_repo_test.dart index a80860e4..ef41a143 100644 --- a/app/integration_test/desktop/settings_repo_test.dart +++ b/app/integration_test/desktop/settings_repo_test.dart @@ -38,6 +38,7 @@ import 'package:makit/desktop/settings/settings_window.dart'; import 'package:makit/store/connection.dart'; import 'package:makit/store/models.dart'; import 'package:makit/store/store.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; import 'package:makit/ui/home/repo_monogram.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -107,7 +108,10 @@ Widget _app() => ProviderScope( overrides: [ reposProvider.overrideWithValue(ReposState(_repos)), serverConfigProvider.overrideWith( - (ref) => ServerConfigController(_prefs, const ServerConfig()), + (ref) => ServerConfigController( + ProfileScopedPrefs.unscoped(_prefs), + const ServerConfig(), + ), ), desktopControllerProvider.overrideWithValue( DesktopController( diff --git a/app/lib/control/reconnecting_control_client.dart b/app/lib/control/reconnecting_control_client.dart index 0dd4ea7b..3d8d6eee 100644 --- a/app/lib/control/reconnecting_control_client.dart +++ b/app/lib/control/reconnecting_control_client.dart @@ -97,9 +97,25 @@ class ReconnectingControlClient implements ControlClient { } /// Disposes any live connection. Safe to call multiple times. + /// + /// An in-flight connect is awaited first: `close()` only nulling `_current` + /// would let a pending `_connectNew` complete *after* close and install a live + /// socket into `_current` that nothing ever closes. This leaked the old + /// profile's connection on a switch, which then kept polling under a torn-down + /// runtime. Awaiting the pending connect lets it settle into `_current` (or + /// fail and dispose itself), after which the single disposal below covers it. Future close() async { + final connecting = _connecting; + if (connecting != null) { + try { + await connecting; + } catch (_) { + // A failed connect already disposed its client in _connectNew. + } + } final current = _current; _current = null; + _connecting = null; if (current != null) await _safeDispose(current); } diff --git a/app/lib/control/reconnecting_control_client_test.dart b/app/lib/control/reconnecting_control_client_test.dart index 45e4fc66..e277dd84 100644 --- a/app/lib/control/reconnecting_control_client_test.dart +++ b/app/lib/control/reconnecting_control_client_test.dart @@ -158,5 +158,45 @@ void main() { expect(connected, [0, 1]); }, ); + + test( + 'close() disposes a client whose connect was still in flight', + () async { + // Regression: close() used to only null `_current`, so a connect still + // in flight would complete afterwards, install a live socket into + // `_current`, and leak it — the old profile's runtime kept polling after + // teardown. + final localCreated = <_FakeClient>[]; + final localDisposed = []; + final gate = Completer(); + var seq = 0; + final client = ReconnectingControlClient( + create: () { + final c = _FakeClient(seq++); + localCreated.add(c); + return c; + }, + connect: (_) async => gate.future, // stays in flight until released + dispose: (c) async => localDisposed.add((c as _FakeClient).id), + ); + + // Trigger connect but do not await the call yet. + final pending = client.status(); + await Future.delayed(Duration.zero); + expect(localCreated, hasLength(1)); + + // Close while the connect is in flight, then let the connect complete. + final closing = client.close(); + gate.complete(); + await closing; + await pending.then((_) {}, onError: (_) {}); + + // The in-flight client must have been disposed, not retained. + expect(localDisposed, contains(0)); + // And it is not reused: the next call connects a fresh client. + await client.status(); + expect(localCreated, hasLength(2)); + }, + ); }); } diff --git a/app/lib/desktop/chat/groups/groups_controller.dart b/app/lib/desktop/chat/groups/groups_controller.dart index 144f3acf..8bdb4b46 100644 --- a/app/lib/desktop/chat/groups/groups_controller.dart +++ b/app/lib/desktop/chat/groups/groups_controller.dart @@ -17,7 +17,8 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/legacy.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../store/prefs/profile_scoped_prefs.dart'; import '../panes/split_node.dart'; import '../panes/workspace_controller.dart'; @@ -200,10 +201,10 @@ class GroupsController extends StateNotifier { /// Builds a controller from persisted state, migrating the SPEC-28 single /// workspace when this is the first run with groups. - static GroupsController load(SharedPreferences prefs) => + static GroupsController load(ScopedPrefs prefs) => GroupsController(prefs, decode(prefs)); - final SharedPreferences? _prefs; + final ScopedPrefs? _prefs; /// The group with [id], or null. Group? groupById(String id) { @@ -506,7 +507,7 @@ class GroupsController extends StateNotifier { /// Decodes persisted state, migrating the SPEC-28 single workspace on first /// run and falling back to [GroupsState.fresh] for anything unusable. @visibleForTesting - static GroupsState decode(SharedPreferences prefs) { + static GroupsState decode(ScopedPrefs prefs) { final raw = prefs.getString(kGroupsPrefsKey); if (raw == null || raw.isEmpty) return _migrateLegacy(prefs); Object? decoded; @@ -579,7 +580,7 @@ class GroupsController extends StateNotifier { /// First run with groups: fold the SPEC-28 single workspace into one board so /// nobody loses their layout. Empty tabs are carried over verbatim (decision /// 21) and bound tabs become the board's membership. - static GroupsState _migrateLegacy(SharedPreferences prefs) { + static GroupsState _migrateLegacy(ScopedPrefs prefs) { final legacyRaw = prefs.getString(kWorkspacePrefsKey); if (legacyRaw == null || legacyRaw.isEmpty) return GroupsState.fresh(); final tree = WorkspaceController.decodeWorkspace(legacyRaw); @@ -711,7 +712,8 @@ class GroupsController extends StateNotifier { } /// The groups layer. Defaults to a non-persisting controller; `runDesktopApp` -/// overrides it with a [SharedPreferences]-backed one, and tests may too. +/// overrides it with a profile-scoped [ScopedPrefs]-backed one, and tests may +/// too. final groupsControllerProvider = StateNotifierProvider( (ref) => GroupsController.ephemeral(), diff --git a/app/lib/desktop/chat/server_profile_badge.dart b/app/lib/desktop/chat/server_profile_badge.dart index 303f0d8e..08249224 100644 --- a/app/lib/desktop/chat/server_profile_badge.dart +++ b/app/lib/desktop/chat/server_profile_badge.dart @@ -1,19 +1,28 @@ -/// A small colored pill naming the server profile this window runs against -/// (e.g. `main` vs a worktree). Renders nothing for the default (installed) -/// profile — shipped users have a single server and don't need the noise. +/// The title-bar profile pill — and the switcher it opens. /// -/// The color is derived deterministically from the profile id so the *same* -/// build always gets the *same* hue: a quick visual cue to tell a `main` window -/// apart from a worktree window at a glance. +/// Shown for **every** profile. It used to render nothing for the installed one, +/// which was right while profiles were invisible plumbing and wrong the moment +/// they became something the user chooses (SPEC-50): a single-profile user sees +/// one calm pill, and a multi-profile user is never left guessing which server a +/// window is talking to. +/// +/// The colour is derived deterministically from the profile id, so the *same* +/// profile always gets the *same* hue — a quick cue for telling two windows apart +/// at a glance. library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/theme.dart'; +import '../../status/status_event.dart'; +import '../../status/status_providers.dart'; +import '../daemon/server_profile.dart'; import '../desktop_app.dart' show serverProfileProvider; +import '../settings/sections/profile_switch_sheet.dart'; +import '../settings/sections/profiles_providers.dart'; -/// The title-bar profile badge. Reads [serverProfileProvider]. +/// The title-bar profile badge, which opens the profile switcher. class ServerProfileBadge extends ConsumerWidget { /// Creates the badge. const ServerProfileBadge({super.key}); @@ -21,9 +30,103 @@ class ServerProfileBadge extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final profile = ref.watch(serverProfileProvider); - if (profile.isDefault) return const SizedBox.shrink(); + final color = hueForProfileId(profile.id); + final controller = ref.watch(switcherProfilesProvider); + final switcher = ref.watch(profileSwitcherProvider); + // No profile wiring on this surface: show the label, not a dead menu. + if (controller == null || switcher == null) { + return _Pill(name: profile.name, color: color, tappable: false); + } + + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final rows = controller.rows + .where( + (r) => !r.stale || r.profile.id == controller.activeProfileId, + ) + .toList(); + return PopupMenuButton( + tooltip: 'Switch profile', + position: PopupMenuPosition.under, + // A one-profile user has nothing to switch to; keep the pill as a calm + // label rather than a menu that opens onto a single dead row. + enabled: rows.length > 1, + onSelected: (target) => _switch(context, ref, profile, target), + itemBuilder: (context) => [ + for (final row in rows) + PopupMenuItem( + value: row.profile, + child: _MenuRow( + name: row.profile.name, + hue: hueForProfileId(row.profile.id), + running: row.running, + active: row.profile.id == profile.id, + ), + ), + ], + child: _Pill( + name: profile.name, + color: color, + tappable: rows.length > 1, + ), + ); + }, + ); + } + + Future _switch( + BuildContext context, + WidgetRef ref, + ServerProfile from, + ServerProfile target, + ) async { + if (target.id == from.id) return; + final lifecycle = ref.read(profileLifecycleProvider); + final switcher = ref.read(profileSwitcherProvider)!; + // Captured before the awaits: `ref` throws once its widget is unmounted, and + // the record must outlive the thing reporting to it. + final status = ref.status; + + final running = await lifecycle.isRunning(target); + if (!context.mounted) return; + final ok = await confirmProfileSwitch( + context, + from: from, + to: target, + targetRunning: running, + ); + if (!ok) return; + + final result = await switcher(target); + if (result.switchFailure == null) { + status.success( + 'Switched to ${target.name}', + source: StatusSources.settings, + ); + } else { + status.failure( + 'Could not switch to ${target.name}', + source: StatusSources.settings, + detail: result.switchFailure, + ); + } + } +} + +class _Pill extends StatelessWidget { + const _Pill({ + required this.name, + required this.color, + required this.tappable, + }); - final color = _hueFor(profile.id); + final String name; + final Color color; + final bool tappable; + + @override + Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric( horizontal: kSpace8, @@ -44,23 +147,81 @@ class ServerProfileBadge extends ConsumerWidget { ), const SizedBox(width: kSpace6), Text( - profile.label, + name, style: Theme.of(context).textTheme.labelXs?.copyWith( fontWeight: FontWeight.w600, color: color, ), ), + if (tappable) ...[ + const SizedBox(width: kSpace2), + Icon(Icons.expand_more, size: 12, color: color), + ], ], ), ); } +} - /// Maps a profile id to a stable, well-spaced hue. - static Color _hueFor(String id) { - var h = 0; - for (final c in id.codeUnits) { - h = (h * 31 + c) & 0xffffff; - } - return HSLColor.fromAHSL(1, (h % 360).toDouble(), 0.6, 0.55).toColor(); +class _MenuRow extends StatelessWidget { + const _MenuRow({ + required this.name, + required this.hue, + required this.running, + required this.active, + }); + + final String name; + final Color hue; + final bool running; + final bool active; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Row( + children: [ + Container( + width: 9, + height: 9, + decoration: BoxDecoration(color: hue, shape: BoxShape.circle), + ), + const SizedBox(width: kSpace8), + Expanded( + child: Text( + name, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + if (!running) + Text( + 'stopped', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: cs.onSurfaceVariant), + ), + if (active) + Padding( + padding: const EdgeInsets.only(left: kSpace6), + child: Icon(Icons.check, size: 14, color: cs.primary), + ), + ], + ); + } +} + +/// Maps a profile id to a stable, well-spaced hue. +/// +/// Top-level so the Profiles list, the switcher menu and the switch sheet all +/// colour a profile identically — the colour is part of its identity, not +/// decoration private to the badge. +Color hueForProfileId(String id) { + var h = 0; + for (final c in id.codeUnits) { + h = (h * 31 + c) & 0xffffff; } + return HSLColor.fromAHSL(1, (h % 360).toDouble(), 0.6, 0.55).toColor(); } diff --git a/app/lib/desktop/daemon/daemon_lifecycle.dart b/app/lib/desktop/daemon/daemon_lifecycle.dart index cd6bfa0b..9b218308 100644 --- a/app/lib/desktop/daemon/daemon_lifecycle.dart +++ b/app/lib/desktop/daemon/daemon_lifecycle.dart @@ -9,6 +9,8 @@ library; import 'dart:io'; +import 'daemon_result_utils.dart'; + /// Runs an executable and resolves to its [ProcessResult]. Injected so tests /// can assert on the spawned command without touching real processes. typedef ProcessRunner = @@ -47,8 +49,13 @@ class MakitCliResolver { final String Function()? _overridePath; /// Returns the absolute path to `makit`, or `null` if it cannot be found. - Future resolve() async { - final override = _overridePath?.call().trim() ?? ''; + /// + /// [overridePath] takes precedence over the constructor's `overridePath` + /// closure, so a caller acting on **another** profile can supply that + /// profile's configured binary instead of the active profile's. + Future resolve({String? overridePath}) async { + final configured = overridePath ?? _overridePath?.call(); + final override = configured?.trim() ?? ''; if (override.isNotEmpty && _exists(override)) return override; for (final path in candidatePaths) { if (_exists(path)) return path; @@ -119,6 +126,21 @@ enum DaemonActionOutcome { failed, } +/// Builds the human-readable detail for a non-zero `makit ` exit. +/// +/// Both streams are consulted, because `makit start` reports *why* it failed on +/// **stdout**, not stderr: the daemon is spawned detached with its own output +/// redirected into the log file, so the parent process's only diagnostic is +/// `deps.out(...)` -> `console.log` (see `start` in +/// `server/src/daemon/service.ts` and `out:` in `server/src/index.ts`). +/// Reading stderr alone yielded the bare, useless `makit start exited 1: ` for +/// every real start failure -- including the common "port already in use" case, +/// whose message names the log file and the fix. +/// +/// stderr comes first (it carries the lower-level cause), duplicates are +/// collapsed, and the separator is dropped entirely when neither stream said +/// anything -- so the message never ends in a dangling colon. + /// Result of a lifecycle action, with an optional human-readable [message]. class DaemonActionResult { /// Creates a result. @@ -189,10 +211,9 @@ class DaemonLifecycle { try { final res = await run(path, [verb, ...extraArgs]); if (res.exitCode == 0) return DaemonActionResult(onSuccess); - final stderr = (res.stderr is String) ? res.stderr as String : ''; return DaemonActionResult( DaemonActionOutcome.failed, - message: 'makit $verb exited ${res.exitCode}: ${stderr.trim()}', + message: formatDaemonError(verb, res), ); } on ProcessException catch (e) { return DaemonActionResult( diff --git a/app/lib/desktop/daemon/daemon_lifecycle_test.dart b/app/lib/desktop/daemon/daemon_lifecycle_test.dart index fb29b98a..edee0ea8 100644 --- a/app/lib/desktop/daemon/daemon_lifecycle_test.dart +++ b/app/lib/desktop/daemon/daemon_lifecycle_test.dart @@ -101,6 +101,42 @@ void main() { expect(result.message, contains('boom')); }); + // `makit start` reports why it failed on STDOUT, not stderr: the daemon is + // spawned detached with its output redirected to the log file, so the + // parent's only diagnostic is `deps.out(...)` -> console.log (see + // server/src/daemon/service.ts `start` and server/src/index.ts `out:`). + // Reading stderr alone therefore produced the bare, useless string + // 'makit start exited 1: ' for every real-world start failure. + test('falls back to stdout when the CLI writes its reason there', () async { + final result = await build( + onRun: (_, _) => _exit( + 1, + out: + 'makit: failed to start — no response within 8000ms ' + '(see /Users/le/.makit-dev/a1b2c3d4/makit.log)', + ), + ).start(); + expect(result.outcome, DaemonActionOutcome.failed); + expect(result.message, contains('failed to start')); + expect(result.message, contains('makit.log')); + }); + + test('prefers stderr but keeps stdout when both are present', () async { + final result = await build( + onRun: (_, _) => _exit(1, out: 'see the log', err: 'EADDRINUSE'), + ).start(); + expect(result.message, contains('EADDRINUSE')); + expect(result.message, contains('see the log')); + }); + + test('never leaves a dangling colon when both streams are empty', () async { + final result = await build(onRun: (_, _) => _exit(1)).start(); + expect(result.outcome, DaemonActionOutcome.failed); + expect(result.message, isNotNull); + expect(result.message, isNot(endsWith(': '))); + expect(result.message, contains('exited 1')); + }); + test('reports failed when spawning throws', () async { final lifecycle = DaemonLifecycle( resolver: resolverReturning('/usr/local/bin/makit'), diff --git a/app/lib/desktop/daemon/daemon_result_utils.dart b/app/lib/desktop/daemon/daemon_result_utils.dart new file mode 100644 index 00000000..f87235ce --- /dev/null +++ b/app/lib/desktop/daemon/daemon_result_utils.dart @@ -0,0 +1,25 @@ +/// Utilities for daemon action results (SPEC-50). +library; + +import 'dart:io' show ProcessResult; + +/// Builds the human-readable detail for a non-zero `makit ` exit. +/// +/// Both streams are consulted, because `makit start` reports *why* it failed on +/// **stdout**, not stderr: the daemon is spawned detached with its own output +/// redirected into the log file, so the parent process's only diagnostic is +/// `deps.out(...)` -> `console.log` (see `start` in +/// `server/src/daemon/service.ts` and `out:` in `server/src/index.ts`). +/// Reading stderr alone yielded the bare, useless `makit start exited 1: ` for +/// every real start failure -- including the common "port already in use" case, +/// whose message names the log file and the fix. +String formatDaemonError(String verb, ProcessResult result) { + final head = 'makit $verb exited ${result.exitCode}'; + final parts = []; + for (final stream in [result.stderr, result.stdout]) { + if (stream is! String) continue; + final text = stream.trim(); + if (text.isNotEmpty && !parts.contains(text)) parts.add(text); + } + return parts.isEmpty ? head : '$head: ${parts.join(' — ')}'; +} diff --git a/app/lib/desktop/daemon/profile_deleter.dart b/app/lib/desktop/daemon/profile_deleter.dart new file mode 100644 index 00000000..03c4607d --- /dev/null +++ b/app/lib/desktop/daemon/profile_deleter.dart @@ -0,0 +1,499 @@ +/// Atomic, four-store deletion of a server profile (SPEC-50 D8). +/// +/// A profile is not one directory. Erasing it means clearing **all four** of the +/// stores that back it, or the leftovers rot: +/// +/// 1. `$MAKIT_HOME/` — the database, media, pairings, projects, certs and logs. +/// 2. The secure-store namespace file — the pairing bearer (`secure_store.`). +/// 3. The profile's scoped preference keys (`.*`). +/// 4. The registry entry in `profiles.json`. +/// +/// Deleting only (1) leaks the other three; omitting (4) resurrects the profile +/// *empty* on the next launch. This class removes what it verifiably can and +/// **names what it skipped and why** — it never pretends to have purged a store +/// it cannot reach (see the prefs note on [ProfileDeleter.delete]). +/// +/// Guards, in order and all refusals rather than throws: never the protected +/// legacy profile, never the currently active profile, never a `home` outside +/// `~/.makit*` (a corrupt registry entry must not delete the user's disk), and +/// never any unlink while the daemon is still confirmed live. +library; + +import 'dart:io'; + +import 'profile_lifecycle.dart'; +import 'profile_registry.dart'; +import 'server_profile.dart'; + +/// The disposition of a [ProfileDeleter.delete] call. +enum ProfileDeletionOutcome { + /// The profile's stores were erased (some may be [ProfileDeletionResult.skipped]). + deleted, + + /// Refused: the profile is the protected legacy profile. + refusedProtected, + + /// Refused: the profile is the currently active one. + refusedActive, + + /// Refused: the profile's `home` is not under `~/.makit*`. + refusedUnsafePath, + + /// Refused: the daemon would not stop, so nothing was unlinked. + refusedDaemonRunning, +} + +/// What a deletion did: its [outcome], a best-effort [bytesFreed], the human +/// descriptions of each store [removed], and each store [skipped] with its +/// reason. Immutable. +class ProfileDeletionResult { + /// Creates a result. + const ProfileDeletionResult({ + required this.outcome, + this.bytesFreed = 0, + this.removed = const [], + this.skipped = const [], + }); + + /// What happened. + final ProfileDeletionOutcome outcome; + + /// Best-effort sum of bytes reclaimed from the stores actually removed. + final int bytesFreed; + + /// Human descriptions of the stores that were erased. + final List removed; + + /// Human descriptions of the stores that were *not* erased, each with a reason. + final List skipped; + + /// Whether the profile was deleted (as opposed to refused). + bool get ok => outcome == ProfileDeletionOutcome.deleted; +} + +/// The narrow filesystem slice the deleter needs, injected so tests never touch +/// a real disk. +abstract interface class ProfileFileSystem { + /// Whether [path] exists (file or directory). + bool exists(String path); + + /// Whether [path] exists **and is a directory**. Distinguished from [exists] + /// because a `home` that is a regular file would silently no-op a recursive + /// directory delete while still looking removed. + bool isDirectory(String path); + + /// Recursive byte sum of [path] (a file's own size, or every file under a + /// directory). `0` when [path] does not exist. + Future sizeOf(String path); + + /// Recursively deletes the directory at [path]. A no-op when absent. + Future deleteDirectory(String path); + + /// Deletes the file at [path]. A no-op when absent. + Future deleteFile(String path); + + /// Resolves [path] to its canonical real path, following symlinks in every + /// ancestor component. Returns `null` when the path does not exist or cannot + /// be resolved — the destructive guard treats “cannot resolve” as “no symlink + /// to verify” and falls back to the lexical check. + String? resolveRealPath(String path); +} + +/// [ProfileFileSystem] over `dart:io`. +class RealProfileFileSystem implements ProfileFileSystem { + /// Creates the real filesystem adapter. + const RealProfileFileSystem(); + + @override + bool exists(String path) => + File(path).existsSync() || Directory(path).existsSync(); + + @override + bool isDirectory(String path) => Directory(path).existsSync(); + + @override + Future sizeOf(String path) async { + final dir = Directory(path); + if (dir.existsSync()) { + var total = 0; + try { + await for (final entity in dir.list( + recursive: true, + followLinks: false, + )) { + if (entity is File) { + try { + total += await entity.length(); + } on FileSystemException { + // Skip an entry that vanished mid-walk rather than aborting the + // sum. + } + } + } + } on FileSystemException { + // An unreadable directory (permissions) must not throw out of a size + // probe: report what was measured so far rather than failing the UI. + } + return total; + } + final file = File(path); + if (file.existsSync()) { + try { + return await file.length(); + } on FileSystemException { + return 0; + } + } + return 0; + } + + @override + Future deleteDirectory(String path) async { + final dir = Directory(path); + if (dir.existsSync()) await dir.delete(recursive: true); + } + + @override + Future deleteFile(String path) async { + final file = File(path); + if (file.existsSync()) await file.delete(); + } + + @override + String? resolveRealPath(String path) { + try { + return Directory(path).resolveSymbolicLinksSync(); + } on FileSystemException { + try { + return File(path).resolveSymbolicLinksSync(); + } on FileSystemException { + return null; + } + } + } +} + +/// Erases a profile across all four of its stores, or refuses with a reason. +class ProfileDeleter { + /// Creates a deleter. + /// + /// [registry] owns the entry removed last; [lifecycle] stops and confirms the + /// daemon; [activeProfileId] is the profile this window currently runs (never + /// deletable); [homeDir] is the user's home (`~`), used to bound the safe path + /// and locate the macOS secure-store file; [fs] is the filesystem slice. + ProfileDeleter({ + required this.registry, + required this.lifecycle, + required this.activeProfileId, + String? homeDir, + ProfileFileSystem? fs, + bool? isMacOS, + Future Function(ServerProfile profile)? purgePrefs, + }) : homeDir = homeDir ?? (Platform.environment['HOME'] ?? ''), + fs = fs ?? const RealProfileFileSystem(), + _isMacOS = isMacOS ?? Platform.isMacOS, + _purgePrefs = purgePrefs; + + /// The registry whose entry is removed last. + final ProfileRegistry registry; + + /// Stops and confirms the daemon before any unlink. + final ProfileLifecycle lifecycle; + + /// The currently active profile's id — refused to protect the live window. + final String activeProfileId; + + /// The user's home directory (`~`). + final String homeDir; + + /// The filesystem slice. + final ProfileFileSystem fs; + + final bool _isMacOS; + + /// Purges a profile's preference keys, returning how many were removed (or a + /// negative number when the scope refuses, e.g. the unscoped legacy view). + /// Null where no prefs are wired (tests, headless contexts), in which case the + /// store is honestly reported as skipped. + final Future Function(ServerProfile profile)? _purgePrefs; + + /// Recursive byte sum of [profile]'s `MAKIT_HOME`, for the Profiles UI size + /// column. `0` when the home is gone. Does not include the tiny secure-store + /// file, which is not part of the profile's disk footprint the user cares about. + /// + /// Refuses to measure a home the deleter would refuse to delete for being + /// outside `~/.makit*` (a corrupt `profiles.json` with `home: "/"` would + /// otherwise walk the whole disk). Unlike deletion, the protected legacy home + /// (`~/.makit` itself, which has no child segment) is measurable, so this uses + /// a containment check rather than the stricter delete guard. + Future diskUsage(ServerProfile profile) async { + if (!_isMeasurableHome(profile)) return 0; + return fs.sizeOf(profile.home); + } + + /// Whether [profile]'s home is a concrete path at or under `~/.makit*`, so it + /// is safe to recursively measure. Broader than [_unsafeHomeReason] by design: + /// it admits the legacy `~/.makit` home, which is measurable but not deletable. + bool _isMeasurableHome(ServerProfile profile) { + final home = _canonical(profile.home); + if (home == null) return false; + final base = _canonical(homeDir); + if (base == null) return false; + return home == '$base/.makit' || + home.startsWith('$base/.makit/') || + home.startsWith('$base/.makit-dev/'); + } + + /// Erases [profile] across its four stores, or refuses. + /// + /// The prefs store (3) is purged through the injected `purgePrefs` hook. This + /// is reachable because prefs are scoped by **key prefix** + /// (`ProfileScopedPrefs`) rather than the global + /// `SharedPreferences.setPrefix`, which could not be re-called after + /// `getInstance()` and so pinned this process to the active profile. Where no + /// prefs are wired (tests, headless contexts) the store is honestly reported in + /// [ProfileDeletionResult.skipped] rather than silently no-op'd. + Future delete(ServerProfile profile) async { + if (profile.isProtected) { + return const ProfileDeletionResult( + outcome: ProfileDeletionOutcome.refusedProtected, + skipped: ['protected legacy profile is never deletable'], + ); + } + if (profile.id == activeProfileId) { + return const ProfileDeletionResult( + outcome: ProfileDeletionOutcome.refusedActive, + skipped: ['the active profile cannot be deleted from under itself'], + ); + } + final unsafe = _unsafeHomeReason(profile); + if (unsafe != null) { + return ProfileDeletionResult( + outcome: ProfileDeletionOutcome.refusedUnsafePath, + skipped: [unsafe], + ); + } + + final stopped = await lifecycle.stopAndConfirm(profile); + if (!stopped) { + return const ProfileDeletionResult( + outcome: ProfileDeletionOutcome.refusedDaemonRunning, + skipped: [ + 'daemon still running — refused to unlink under a live daemon', + ], + ); + } + + final removed = []; + final skipped = []; + var bytesFreed = 0; + + // (1) $MAKIT_HOME/ + // + // Every store operation below is best-effort: a failure after the home is + // erased must not throw out of the method, or the caller gets no result and + // the registry entry survives to resurrect an empty home next launch. Each + // failure is recorded in `skipped` and a result is always returned. + try { + if (fs.isDirectory(profile.home)) { + bytesFreed += await fs.sizeOf(profile.home); + await fs.deleteDirectory(profile.home); + removed.add('MAKIT_HOME ${profile.home}'); + } else if (fs.exists(profile.home)) { + // A regular file at `home` is not a profile home. A recursive directory + // delete silently no-ops on it, so reporting it removed would be a lie; + // erase it as a file instead. + bytesFreed += await fs.sizeOf(profile.home); + await fs.deleteFile(profile.home); + removed.add('MAKIT_HOME ${profile.home} (was a file, not a directory)'); + } else { + skipped.add('MAKIT_HOME ${profile.home}: already absent'); + } + } on FileSystemException catch (e) { + skipped.add('MAKIT_HOME ${profile.home}: $e'); + } + + // (2) secure-store namespace file + final securePath = _secureStorePath(profile); + if (securePath == null) { + skipped.add( + 'secure store: no namespaced file to delete on this platform/profile', + ); + } else { + try { + if (fs.exists(securePath)) { + bytesFreed += await fs.sizeOf(securePath); + await fs.deleteFile(securePath); + removed.add('secure store $securePath'); + } else { + skipped.add('secure store $securePath: already absent'); + } + } on FileSystemException catch (e) { + skipped.add('secure store $securePath: $e'); + } + } + + // (3) preference keys. Reachable for a non-active profile now that prefs are + // scoped by key prefix (ProfileScopedPrefs) rather than the global + // SharedPreferences.setPrefix, so this actually purges instead of always + // reporting the store skipped. + final purge = _purgePrefs; + if (purge == null) { + skipped.add( + 'preference keys under "${profile.prefsKeyPrefix}": no prefs wired in ' + 'this context', + ); + } else { + try { + final count = await purge(profile); + if (count < 0) { + skipped.add( + 'preference keys under "${profile.prefsKeyPrefix}": refused — an ' + 'unscoped view cannot tell this profile\'s keys from another\'s', + ); + } else { + removed.add( + '$count preference key(s) under "${profile.prefsKeyPrefix}"', + ); + } + } catch (e) { + skipped.add('preference keys under "${profile.prefsKeyPrefix}": $e'); + } + } + + // (4) registry entry, LAST + try { + if (registry.remove(profile.id)) { + registry.save(); + removed.add('registry entry ${profile.id}'); + } else { + skipped.add('registry entry ${profile.id}: not present'); + } + } on FileSystemException catch (e) { + skipped.add( + 'registry entry ${profile.id}: could not persist removal: $e', + ); + } + + return ProfileDeletionResult( + outcome: ProfileDeletionOutcome.deleted, + bytesFreed: bytesFreed, + removed: removed, + skipped: skipped, + ); + } + + /// Why [profile]'s home must not be deleted, or `null` when it is safe. + /// + /// This is the guard that matters most: `profiles.json` is a plain + /// user-writable file, so `home` is attacker-influenced. A corrupt entry + /// pointing at `/` or `~` must never become a recursive delete of the + /// user's disk. + /// + /// **Canonicalise before comparing.** An earlier version compared raw strings + /// and was defeated by a single trailing slash: `~/.makit/` is not `==` to + /// `~/.makit`, so a legacy-home check missed it while `startsWith('~/.makit/')` + /// happily matched — and the delete erased `AuthKey_*.p8`, `server.key`, + /// `devices.json`, `ota/`, `push.json` and `host.json`. `//`, `/.` and + /// `/.makit-dev/../.makit` were the same class of bypass. + /// + /// Three rules, each proven to bite on its own by mutation: + /// 1. The path must be absolute and canonical — no empty, `.` or `..` segments. + /// The registry only ever writes canonical paths, so a non-canonical home is + /// itself evidence the file was hand-edited. This is what stops + /// `~/.makit-dev/../../Documents`, which passes containment on its prefix. + /// 2. It must sit **strictly inside** `~/.makit/` or `~/.makit-dev/`, with at + /// least one further segment. A bare `startsWith('$homeDir/.makit')` + /// accepted `~/.makitEVIL`; and requiring a child segment is what protects + /// the legacy home itself — `~/.makit` has no child — as well as the bare + /// `~/.makit-dev` container that every dev profile lives under. + /// 3. A home another registry entry also claims is refused: deleting it would + /// erase that profile's data behind its back. This also covers a rogue entry + /// aimed at a *relocated* legacy home, since the legacy entry is itself in + /// the registry and would share it. + /// + /// A fourth rule — "refuse `home == registry.legacyProfile.home`" — was written + /// and then removed: rules 2 and 3 already cover every reachable case, and + /// mutation testing showed no test could distinguish its presence. Unreachable + /// code on a destructive path is worse than no code, because it invites the + /// belief that it is doing something. + /// + /// Note the entry's own `isProtected` flag is checked by the caller but is NOT + /// relied on here: it lives in the same user-writable file. + String? _unsafeHomeReason(ServerProfile profile) { + final home = _canonical(profile.home); + if (home == null) { + return 'home "${profile.home}" is not an absolute, canonical path ' + '— refused'; + } + final base = _canonical(homeDir); + if (base == null) return 'no usable home directory — refused'; + if (home == base) return 'home is the home directory itself — refused'; + + // Rule 2: strictly inside, with a real profile segment of its own. + final inside = + home.startsWith('$base/.makit/') || + home.startsWith('$base/.makit-dev/'); + if (!inside) { + return 'home "$home" is not inside ~/.makit/ or ~/.makit-dev/ — refused'; + } + + // Rule 3: never a home another entry also claims. + final sharers = registry.profiles.where( + (p) => p.id != profile.id && _canonical(p.home) == home, + ); + if (sharers.isNotEmpty) { + return 'home "$home" is also claimed by "${sharers.first.id}" — refused'; + } + + // Rule 4: the *symlink-resolved* path must also be contained. The lexical + // rules above are defeated by a symlinked ancestor: `~/.makit/profiles` may + // itself be a link to an external directory, so a recursive delete would + // follow it and destroy data outside `~/.makit*`. `Directory.delete` follows + // ancestor symlinks, so the guard must too. Both sides are resolved so a + // symlinked home dir (e.g. macOS temp `/var` → `/private/var`) is not a + // false positive. "Cannot resolve" (absent path) falls back to the lexical + // rules, since there is then nothing on disk to follow. + final real = fs.resolveRealPath(profile.home); + if (real != null) { + final realHome = _canonical(real); + final resolvedBase = fs.resolveRealPath(homeDir); + final realBase = resolvedBase != null ? _canonical(resolvedBase) : base; + final insideReal = + realHome != null && + realBase != null && + (realHome.startsWith('$realBase/.makit/') || + realHome.startsWith('$realBase/.makit-dev/')); + if (!insideReal) { + return 'home "${profile.home}" resolves via symlink to "$real", ' + 'outside ~/.makit/ or ~/.makit-dev/ — refused'; + } + } + return null; + } + + /// An absolute path with duplicate separators collapsed, any trailing separator + /// removed, and `null` when it is relative or contains a `.`/`..` segment. + /// + /// Rejecting rather than resolving `..` is deliberate: resolving would require + /// touching the filesystem (and would follow symlinks), while the registry has + /// no legitimate reason to ever produce such a path. + static String? _canonical(String path) { + if (path.isEmpty || !path.startsWith('/')) return null; + final segments = path.split('/').where((s) => s.isNotEmpty).toList(); + if (segments.any((s) => s == '.' || s == '..')) return null; + if (segments.isEmpty) return null; + return '/${segments.join('/')}'; + } + + /// The macOS secure-store file for [profile], mirroring `defaultSecureStore` + /// in `lib/store/secure_store.dart`. `null` on non-macOS (keychain-backed, no + /// file to unlink) or for the unsuffixed legacy file (which is protected). + String? _secureStorePath(ServerProfile profile) { + if (!_isMacOS) return null; + final namespace = profile.secureStoreNamespace; + if (namespace == null || namespace.isEmpty) return null; + return '$homeDir/Library/Application Support/dev.getmakit.app/' + 'secure_store.$namespace.json'; + } +} diff --git a/app/lib/desktop/daemon/profile_lifecycle.dart b/app/lib/desktop/daemon/profile_lifecycle.dart new file mode 100644 index 00000000..2d0cb1e1 --- /dev/null +++ b/app/lib/desktop/daemon/profile_lifecycle.dart @@ -0,0 +1,266 @@ +/// Per-profile daemon lifecycle for the macOS desktop control app (SPEC-50 D7). +/// +/// [DaemonLifecycle] drives *the* daemon this app instance talks to: it captures +/// a single `environment` (its own `MAKIT_HOME`) at construction. But the +/// Profiles section must start and stop the daemon of an **arbitrary** profile — +/// one this window is not connected to — which needs a *different* `MAKIT_HOME` +/// per call. That is the whole reason this class exists. +/// +/// It reuses the existing CLI verbs unchanged: `MAKIT_HOME= makit start` +/// and `MAKIT_HOME= makit stop` (`server/src/index.ts:130` → +/// `daemon.stop()`). SIGTERM→SIGKILL semantics already live in the CLI; here we +/// only spawn with the right home and, for deletion, *confirm* the control +/// socket disappeared (SPEC-50 D8) before anyone unlinks files under what might +/// still be a live daemon holding `makit.db-wal`. +library; + +import 'dart:io'; + +import 'daemon_lifecycle.dart'; +import 'daemon_result_utils.dart'; +import 'server_profile.dart'; + +/// Runs an executable with an explicit [environment], resolving to its +/// [ProcessResult]. Distinct from [ProcessRunner] because a *profile's* daemon +/// needs a per-call `MAKIT_HOME`, not one fixed at construction. Injected so +/// tests never spawn a real process. +typedef ProfileProcessRunner = + Future Function( + String executable, + List args, { + Map? environment, + }); + +/// Default poll interval while waiting for a control socket to vanish. +const Duration _kSocketPollInterval = Duration(milliseconds: 50); + +/// How long [ProfileLifecycle.stopAndConfirm] waits by default for the socket to +/// disappear after `makit stop`. +const Duration _kDefaultStopTimeout = Duration(seconds: 5); + +/// Builds the human-readable detail for a non-zero `makit ` exit. +/// +/// Mirrors the logic in [DaemonLifecycle]: `makit start` reports *why* it failed +/// on **stdout** (the daemon is spawned detached), so both streams are consulted, +/// stderr first, duplicates collapsed, and the separator dropped when neither +/// stream said anything so the message never ends in a dangling colon. + +/// Starts, stops, and probes the daemon of any [ServerProfile]. +class ProfileLifecycle { + /// Creates a per-profile lifecycle driver. + /// + /// [resolver] locates the `makit` CLI (reused from [DaemonLifecycle]); [run] + /// spawns it with a per-profile environment (defaults to [Process.run]); + /// [socketExists] tests for a profile's control socket (defaults to a real + /// [File.existsSync]); [statusProbe] confirms a *live* daemon behind that + /// socket (defaults to connecting to the unix socket); [sleep] paces polling + /// (defaults to [Future.delayed]) and is injected so tests stay deterministic. + ProfileLifecycle({ + required this.resolver, + ProfileProcessRunner? run, + bool Function(String path)? socketExists, + Future Function(ServerProfile profile)? statusProbe, + Future Function(Duration duration)? sleep, + int? Function(ServerProfile profile)? readPid, + Future Function(int pid)? processAlive, + String? Function(ServerProfile profile)? cliPathFor, + List Function(ServerProfile profile)? serveArgsFor, + }) : run = run ?? _defaultRun, + _socketExists = socketExists ?? _fileExists, + _statusProbe = statusProbe ?? _connectProbe, + _sleep = sleep ?? Future.delayed, + _readPid = readPid ?? _readPidFile, + _processAlive = processAlive ?? _posixProcessAlive, + _cliPathFor = cliPathFor, + _serveArgsFor = serveArgsFor; + + /// Locates the `makit` executable. + final MakitCliResolver resolver; + + /// Spawns the CLI with a per-profile environment. + final ProfileProcessRunner run; + + final bool Function(String path) _socketExists; + final Future Function(ServerProfile profile) _statusProbe; + final Future Function(Duration duration) _sleep; + final int? Function(ServerProfile profile) _readPid; + final Future Function(int pid) _processAlive; + + /// The `makit` binary configured for a **given** profile, or null to fall back + /// to [resolver]'s own override. Without this, starting profile B from A's + /// window used A's configured `cliPath`. + final String? Function(ServerProfile profile)? _cliPathFor; + + /// The `serve` arguments configured for a **given** profile (host/port), used + /// by [start]. Without this, `makit start` fell back to the CLI's default port + /// (7777) instead of the target profile's allocated port — colliding with the + /// legacy daemon or landing on an endpoint its own `ServerConfig` disagrees + /// with. + final List Function(ServerProfile profile)? _serveArgsFor; + + /// Runs `MAKIT_HOME= makit start`. + Future start(ServerProfile profile) => + _invoke(profile, 'start', DaemonActionOutcome.started); + + /// Runs `MAKIT_HOME= makit stop` — the existing stop verb + /// (`server/src/index.ts:130` → `daemon.stop()`), no server change needed. + Future stop(ServerProfile profile) => + _invoke(profile, 'stop', DaemonActionOutcome.stopped); + + /// True when [profile]'s control socket exists **and** a status probe against + /// it succeeds. The socket file can linger after a crash, so the existence + /// check alone would report a dead daemon as running; the probe is what makes + /// the answer trustworthy. Kept cheap: no probe is attempted when the socket + /// is absent. + Future isRunning(ServerProfile profile) async { + if (!_socketExists(profile.controlSocketPath)) return false; + return _statusProbe(profile); + } + + /// Stops [profile]'s daemon, then polls until the daemon **process** has + /// exited. + /// + /// Deleting a profile must not unlink files under a live daemon holding + /// `makit.db-wal` (SPEC-50 D8). Confirming only that the control socket + /// stopped answering is not enough: the daemon's SIGTERM handler closes the + /// socket *first* and calls `process.exit(0)` ~100 ms later, so there is a + /// window where the socket is gone but the process is still alive and may + /// still be writing the database. `makit stop` itself returns the instant it + /// signals (it does not wait for exit) and removes the pid file, so this reads + /// the pid **before** stopping and then polls the OS for the process itself. + /// + /// Returns `true` once the process is confirmed gone within [timeout], and + /// `false` if it is still alive when time runs out — the caller must then abort + /// the delete. When the pid cannot be read (older daemon, race), it falls back + /// to the socket-liveness check ([isRunning]) so a profile is never made + /// permanently undeletable. If the stop command itself fails (CLI not found, + /// permission error), returns `false` immediately without polling. + Future stopAndConfirm( + ServerProfile profile, { + Duration timeout = _kDefaultStopTimeout, + }) async { + final pid = _readPid(profile); + final stopResult = await stop(profile); + if (stopResult.outcome == DaemonActionOutcome.failed || + stopResult.outcome == DaemonActionOutcome.cliNotFound) { + return false; + } + var elapsed = Duration.zero; + + // Phase 1: wait for the control socket to stop answering. + while (elapsed < timeout) { + if (!await isRunning(profile)) break; + await _sleep(_kSocketPollInterval); + elapsed += _kSocketPollInterval; + } + if (await isRunning(profile)) return false; + + // Phase 2: wait for the process itself to exit. Without a pid the socket + // check above is all we have. + if (pid == null) return true; + while (elapsed < timeout) { + if (!await _processAlive(pid)) return true; + await _sleep(_kSocketPollInterval); + elapsed += _kSocketPollInterval; + } + return !await _processAlive(pid); + } + + Future _invoke( + ServerProfile profile, + String verb, + DaemonActionOutcome onSuccess, + ) async { + // The TARGET profile's configured binary, not the active profile's. + final path = await resolver.resolve( + overridePath: _cliPathFor?.call(profile), + ); + if (path == null) { + return const DaemonActionResult( + DaemonActionOutcome.cliNotFound, + message: + 'The makit CLI was not found. Install it to control the server.', + ); + } + // Only `start` takes endpoint arguments; `stop` needs none. + final args = [ + verb, + if (verb == 'start') ...?_serveArgsFor?.call(profile), + ]; + try { + final res = await run(path, args, environment: profile.environment); + if (res.exitCode == 0) return DaemonActionResult(onSuccess); + return DaemonActionResult( + DaemonActionOutcome.failed, + message: formatDaemonError(verb, res), + ); + } on ProcessException catch (e) { + return DaemonActionResult( + DaemonActionOutcome.failed, + message: 'Failed to run makit $verb: ${e.message}', + ); + } + } + + static Future _defaultRun( + String exe, + List args, { + Map? environment, + }) => Process.run(exe, args, environment: environment); + + static bool _fileExists(String path) => File(path).existsSync(); + + /// Reads the daemon pid from `$MAKIT_HOME/makit.pid`, or `null` when the file + /// is absent or unparseable. Read before `makit stop`, which deletes it. + /// + /// Synchronous on purpose: it is one small read, once per stop — unlike + /// [_posixProcessAlive], which is polled up to ~100 times and therefore must + /// be async. Making this async introduced real filesystem microtasks that a + /// widget test's `pumpAndSettle` could never settle, hanging the delete tests. + static int? _readPidFile(ServerProfile profile) { + try { + final file = File(profile.pidFilePath); + if (!file.existsSync()) return null; + return int.tryParse(file.readAsStringSync().trim()); + } on FileSystemException { + return null; + } + } + + /// Whether an OS process [pid] is still alive, via POSIX `kill -0` (which only + /// probes; it delivers no signal). Returns `false` off POSIX, where the pid + /// wait is skipped and socket-liveness stands in. + /// + /// Asynchronous (`Process.run`, not `runSync`): `stopAndConfirm` can poll this + /// up to ~100 times across the stop timeout, and a synchronous spawn each time + /// would block the UI isolate during a profile deletion. + static Future _posixProcessAlive(int pid) async { + if (Platform.isWindows) return false; + try { + final res = await Process.run('/bin/kill', ['-0', '$pid']); + return res.exitCode == 0; + } on ProcessException { + return false; + } + } + + /// Connects to the unix control socket and immediately closes it. A daemon + /// listening there accepts the connection; a stale socket file refuses it. + static Future _connectProbe(ServerProfile profile) async { + try { + final socket = await Socket.connect( + InternetAddress( + profile.controlSocketPath, + type: InternetAddressType.unix, + ), + 0, + ); + socket.destroy(); + return true; + } on SocketException { + return false; + } on OSError { + return false; + } + } +} diff --git a/app/lib/desktop/daemon/profile_registry.dart b/app/lib/desktop/daemon/profile_registry.dart new file mode 100644 index 00000000..9800b0e1 --- /dev/null +++ b/app/lib/desktop/daemon/profile_registry.dart @@ -0,0 +1,692 @@ +/// The persisted set of server profiles, and the identity rules around it. +/// +/// Backed by a single small JSON file, `/profiles.json`, which is the +/// **source of truth for identity** (SPEC-50 D3). Before this existed a +/// profile's id was `fnv1a(repoRoot)` recomputed on every launch, so moving or +/// renaming a worktree minted a *new* profile and silently orphaned the old +/// one's `MAKIT_HOME`, pairings, projects and prefs. Measured on the author's +/// machine: 27 of 33 dev profile homes were already unreachable. +/// +/// The registry also owns port allocation, because a port must be unique across +/// profiles — a set-wide invariant no single profile can enforce. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'server_profile.dart'; +import 'server_profile_paths.dart'; + +/// Probes whether [port] is free. Injected so tests never bind real sockets. +typedef PortProbe = Future Function(int port); + +/// Binds and immediately releases [port] on the loopback interface. +/// +/// Loopback specifically: the daemon may bind a Tailscale or LAN address, but a +/// conflict on *any* interface of the same port is what matters, and loopback is +/// the one interface guaranteed to exist. `shared: false` so the probe cannot +/// succeed against a socket another process already holds. +Future probePortIsFree(int port) async { + ServerSocket? socket; + try { + socket = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + port, + shared: false, + ); + return true; + } on SocketException { + return false; + } finally { + await socket?.close(); + } +} + +/// The profile set, loaded from and saved to `/profiles.json`. +class ProfileRegistry { + /// Creates a registry over [profiles]. Prefer [load]. + /// + /// [fs] is retained so [save] needs no argument: persistence is the registry's + /// own responsibility, and a caller that mutates it should not have to know + /// where the bytes go. + ProfileRegistry({ + required String makitRoot, + required List profiles, + PortProbe? probe, + FileSystemAdapter? fs, + }) : _makitRoot = makitRoot, + _profiles = [...profiles], + _probe = probe ?? probePortIsFree, + _fs = fs ?? const FileSystemAdapter(); + + final String _makitRoot; + final List _profiles; + final PortProbe _probe; + final FileSystemAdapter _fs; + + /// Ids this instance deleted, so a concurrent-write merge cannot resurrect + /// them from a stale on-disk copy. Persisted as `deletedIds` tombstones so + /// *other* instances honour the deletion too (SPEC-50 D1). + final Set _deleted = {}; + + /// Ids this instance actually mutated (rename/setPort/setOrigin/create), so + /// [save] overrides the on-disk copy only for profiles it truly changed and + /// never reverts another window's edit to a profile it merely happens to hold. + final Set _modified = {}; + + /// Where the registry file lives. + String get filePath => '$_makitRoot/profiles.json'; + + /// Every known profile, in insertion order. Unmodifiable. + List get profiles => List.unmodifiable(_profiles); + + /// The single [ProfileStorage.legacy] profile, or `null` before one exists. + ServerProfile? get legacyProfile => + _profiles.where((p) => p.storage == ProfileStorage.legacy).firstOrNull; + + /// The profile with [id], or `null`. + ServerProfile? byId(String id) => + _profiles.where((p) => p.id == id).firstOrNull; + + /// Reads the registry, tolerating a missing, empty or corrupt file by + /// returning an empty registry rather than throwing — a bad `profiles.json` + /// must never stop the app from launching. Unparseable entries are dropped + /// individually. + static ProfileRegistry load({ + required String makitRoot, + PortProbe? probe, + FileSystemAdapter? fs, + }) { + final io = fs ?? const FileSystemAdapter(); + final raw = io.readOrNull('$makitRoot/profiles.json'); + final deleted = _parseDeletedIds(raw); + final parsed = raw == null ? const [] : _parse(raw); + // Honour tombstones from any instance, then break any port collisions the + // file may carry (a hand-edited or fallback port that duplicates another, + // notably the legacy 7777) before they reach a daemon as `EADDRINUSE`. + final live = [ + for (final p in parsed) + if (!deleted.contains(p.id)) p, + ]; + final reg = ProfileRegistry( + makitRoot: makitRoot, + profiles: _dedupePorts(live), + probe: probe, + fs: io, + ); + reg._lastActiveId = _parseLastActive(raw); + reg._deleted.addAll(deleted); + return reg; + } + + /// Reads the `deletedIds` tombstone list, tolerating every shape the file may + /// take. + static Set _parseDeletedIds(String? raw) { + if (raw == null || raw.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + final v = decoded['deletedIds']; + if (v is List) { + return { + for (final e in v) + if (e is String && e.isNotEmpty) e, + }; + } + } + } on FormatException { + return {}; + } + return {}; + } + + /// Reads `lastActive`, tolerating every shape the file may take. + static String? _parseLastActive(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + final v = decoded['lastActive']; + if (v is String && v.isNotEmpty) return v; + } + } on FormatException { + return null; + } + return null; + } + + /// Parses a `profiles.json` body, dropping entries that cannot be trusted. + /// + /// Tolerant by design: a bad registry must never stop the app from launching. + /// A corrupt file yields an empty list, the bootstrap re-creates the legacy + /// profile, and dev profiles re-bind by `origin`, so little is lost. + static List _parse(String raw) { + final parsed = []; + var seenLegacy = false; + try { + final decoded = jsonDecode(raw); + final list = decoded is Map + ? decoded['profiles'] + : decoded; + if (list is List) { + for (final entry in list) { + if (entry is! Map) continue; + final p = ServerProfile.fromJson(entry); + if (p == null) continue; + // Drop a duplicate id rather than letting two entries fight over one + // home: first-seen wins, matching server-side mergeProjects. + if (parsed.any((e) => e.id == p.id)) continue; + // At most one legacy profile may exist (SPEC-50 D2): it owns the + // unprefixed prefs keys and the unsuffixed secure-store file. A + // hand-edited file with two would silently clobber each other's + // settings and credentials, so extra legacy entries are dropped. + if (p.storage == ProfileStorage.legacy) { + if (seenLegacy) continue; + seenLegacy = true; + } + parsed.add(p); + } + } + } on FormatException { + return const []; + } + return parsed; + } + + /// Writes the registry atomically, merging in anything another instance added + /// since this one loaded. + /// + /// Several app instances run at once by design (SPEC-50 D1), and each holds its + /// own in-memory list. A plain whole-file write would therefore *lose* a + /// profile another window created: window A adds `Personal` and saves; window + /// B, loaded earlier, saves anything at all and clobbers the file. A `user` + /// profile has no `origin`, so `resolveFor` could never re-bind it — its home, + /// pairings and prefs would be exactly the kind of orphan this class exists to + /// prevent. + /// + /// So: re-read immediately before writing and union by id, with **this** + /// instance winning for ids it knows (its edits are the newer intent) and + /// unknown ids preserved. Deletions are still honoured: [remove] records the + /// id in [_deleted] so a merge cannot resurrect it. + /// + /// The whole read-merge-write runs under an inter-process advisory lock + /// ([FileSystemAdapter.withLock]). Union-by-id alone loses an update when two + /// instances *both* read the same on-disk state before either writes: each + /// merges in only its own new profile and the second rename drops the first's. + /// The lock serialises the sequence so the second instance always reads the + /// first's write. + /// + /// `lastActive` is preserved from disk unless *this* instance explicitly set it + /// via [setLastActive]. A window that loaded before another changed the active + /// profile would otherwise write its stale (or null) id back on an unrelated + /// rename/create, silently reopening the wrong profile next launch. + /// + /// The temp file is per-process, because two instances sharing one + /// `profiles.json.tmp` would race and the loser's `renameSync` would throw out + /// of `save()`. + void save() { + _fs.withLock(filePath, () { + final diskRaw = _fs.readOrNull(filePath); + // Learn deletions made by other instances so an unrelated save cannot + // resurrect a profile another window already erased (its on-disk stores + // are gone, so a revived entry would point at deleted data). + _deleted.addAll(_parseDeletedIds(diskRaw)); + final diskProfiles = (diskRaw == null || diskRaw.trim().isEmpty) + ? const [] + : _parse(diskRaw); + + final merged = {}; + for (final p in diskProfiles) { + if (_deleted.contains(p.id)) continue; + merged[p.id] = p; + } + for (final p in _profiles) { + if (_deleted.contains(p.id)) continue; + // Override the on-disk copy only for profiles THIS instance actually + // changed (or newly created / not yet on disk). An unmodified profile + // this window merely holds must not overwrite another window's newer + // rename/port/origin edit. + if (_modified.contains(p.id) || !merged.containsKey(p.id)) { + merged[p.id] = p; + } + } + // Order on-disk profiles first, then append the ones only this instance + // knows (freshly created, not yet persisted). This ordering matters for + // the port reconcile below: `_dedupePorts` keeps the first occurrence of a + // port, so an already-persisted profile (whose daemon may be running on + // that port) keeps it, and a newly-created local profile that happened to + // allocate the same free port is the one reassigned. + final ordered = [ + for (final p in diskProfiles) + if (merged.containsKey(p.id)) merged[p.id]!, + for (final entry in merged.entries) + if (!diskProfiles.any((p) => p.id == entry.key)) entry.value, + ]; + // Break any port collision the merge produced: two instances can each + // allocate the same free port before either writes (SPEC-50 D1), so the + // reconcile happens here, under the lock, on the merged set. + _profiles + ..clear() + ..addAll(_dedupePorts(ordered)); + + // Keep the newer on-disk selection unless this instance changed it. + if (!_lastActiveTouched) { + _lastActiveId = _parseLastActive(diskRaw) ?? _lastActiveId; + } + + final body = const JsonEncoder.withIndent(' ').convert({ + 'profiles': [for (final p in _profiles) p.toJson()], + if (_deleted.isNotEmpty) 'deletedIds': (_deleted.toList()..sort()), + if (_lastActiveId != null) 'lastActive': _lastActiveId, + }); + _fs.writeAtomic(filePath, '$body\n'); + // These edits are now persisted; a later unrelated save must not re-assert + // them over another window's newer change (the reverse lost-update). + _modified.clear(); + }); + } + + /// Reassigns any profile whose port duplicates an earlier one to a free port + /// in the dev range, so no two profiles ever claim the same port. + /// + /// Protected (legacy) profiles keep their port unconditionally — 7777 is the + /// shipped default and the one every device is paired against. A synchronous, + /// deterministic reassignment (no probe) is enough: it only has to make the + /// *set* internally consistent; a port also held by an external process is + /// still caught by the daemon's own `EADDRINUSE` path. + static List _dedupePorts(List profiles) { + final claimed = { + for (final p in profiles) + if (p.isProtected) p.port, + }; + final result = []; + for (final p in profiles) { + if (p.isProtected) { + result.add(p); + continue; + } + if (!claimed.contains(p.port)) { + claimed.add(p.port); + result.add(p); + } else { + final port = _firstFreeDevPort(claimed); + claimed.add(port); + result.add(p.copyWith(port: port)); + } + } + return result; + } + + /// The lowest dev-range port not in [claimed], wrapping to the range start. + static int _firstFreeDevPort(Set claimed) { + for (var i = 0; i < kDevPortRangeLength; i++) { + final candidate = kDevPortRangeStart + i; + if (!claimed.contains(candidate)) return candidate; + } + // Every dev port is claimed (thousands of profiles): fall back to the start + // and let the daemon's EADDRINUSE path sort it out rather than throwing. + return kDevPortRangeStart; + } + + /// Resolves the profile this executable should run against, creating one when + /// the registry has never seen it. + /// + /// Bootstrap rules (SPEC-50 D3): + /// - Not a dev-build path → the [ProfileStorage.legacy] profile, created with + /// the shipped `~/.makit` + port 7777 defaults if absent. + /// - A dev-build path → the profile whose [ServerProfile.origin] matches this + /// repo root, so a *rebuilt* app re-binds instead of forking. Otherwise a + /// new `dev` profile with a freshly probed port. + /// + /// Reports whether the registry changed, so the caller decides when to [save]. + Future<({ServerProfile profile, bool created})> resolveFor({ + required String executablePath, + required String home, + }) async { + final repoRoot = devBuildRepoRoot(executablePath); + if (repoRoot == null) { + final existing = legacyProfile; + if (existing != null) return (profile: existing, created: false); + final created = ServerProfile( + id: 'default', + name: 'Makit', + kind: ProfileKind.user, + home: '$home/.makit', + port: kDefaultServerPort, + storage: ProfileStorage.legacy, + ); + _profiles.insert(0, created); + _modified.add(created.id); + return (profile: created, created: true); + } + + final matched = _profiles + .where((p) => p.kind == ProfileKind.dev && p.origin == repoRoot) + .firstOrNull; + if (matched != null) return (profile: matched, created: false); + + final id = _uniqueId(devIdGuess(repoRoot)); + final created = ServerProfile( + id: id, + name: labelForRepoRoot(repoRoot), + kind: ProfileKind.dev, + home: '$home/.makit-dev/$id', + port: await allocatePort(startingGuess: devPortGuess(repoRoot)), + storage: ProfileStorage.namespaced, + origin: repoRoot, + ); + _profiles.add(created); + _modified.add(id); + return (profile: created, created: true); + } + + /// Creates a user profile named [name] under `/profiles/`. + /// + /// Throws [ArgumentError] on a blank name so a nameless row can never reach + /// the registry. + Future createUserProfile({required String name}) async { + final trimmed = name.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(name, 'name', 'must not be blank'); + } + final id = _uniqueId(_slug(trimmed)); + final created = ServerProfile( + id: id, + name: trimmed, + kind: ProfileKind.user, + home: '$_makitRoot/profiles/$id', + port: await allocatePort(startingGuess: kDevPortRangeStart), + storage: ProfileStorage.namespaced, + ); + _profiles.add(created); + _modified.add(id); + return created; + } + + /// Renames [id]. Returns false when no such profile exists or [name] is blank. + bool rename(String id, String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return false; + final i = _profiles.indexWhere((p) => p.id == id); + if (i < 0) return false; + _profiles[i] = _profiles[i].copyWith(name: trimmed); + _modified.add(id); + return true; + } + + /// Removes [id] from the registry. Refuses a protected (legacy) profile — + /// deleting it would take `AuthKey_*.p8`, `ota/` and `push.json` with it. + /// + /// This drops only the *entry*; erasing the on-disk stores is the deleter's + /// job, and it calls this last. + bool remove(String id) { + final p = byId(id); + if (p == null || p.isProtected) return false; + _profiles.removeWhere((e) => e.id == id); + _deleted.add(id); + return true; + } + + /// Replaces [id]'s stored port, e.g. after an `EADDRINUSE` retry, so the new + /// port survives the next launch instead of colliding again. + bool setPort(String id, int port) { + final i = _profiles.indexWhere((p) => p.id == id); + if (i < 0 || port <= 0 || port > 65535) return false; + _profiles[i] = _profiles[i].copyWith(port: port); + _modified.add(id); + return true; + } + + /// Re-points [id]'s `origin` (and nothing else) at [repoRoot], for when a dev + /// build is recognised at a new location. + bool setOrigin(String id, String repoRoot) { + final i = _profiles.indexWhere((p) => p.id == id); + if (i < 0) return false; + _profiles[i] = _profiles[i].copyWith(origin: repoRoot); + _modified.add(id); + return true; + } + + /// The profile the user last switched to, or `null`. + /// + /// Read only when the *installed* app launches (see [preferredFor]): a dev + /// build must always open its own profile, or building a worktree would + /// silently reopen `Work` and look like the build did nothing. + String? get lastActiveId => _lastActiveId; + String? _lastActiveId; + + /// Whether *this* instance explicitly chose the last-active profile. Guards + /// [save] from writing a stale in-memory id over a newer on-disk one another + /// window persisted. + bool _lastActiveTouched = false; + + /// Records [id] as the last profile the user chose. Returns false for an + /// unknown id, so a stale value can never be written. + bool setLastActive(String id) { + if (byId(id) == null) return false; + _lastActiveId = id; + _lastActiveTouched = true; + return true; + } + + /// The profile to open for a [bootstrap] resolution. + /// + /// Honours [lastActiveId] **only** when the bootstrap profile is the installed + /// (legacy) one. A dev build always gets its own profile: its whole purpose is + /// to isolate that worktree, and reopening a different one would defeat it. + ServerProfile preferredFor(ServerProfile bootstrap) { + if (bootstrap.storage != ProfileStorage.legacy) return bootstrap; + final last = _lastActiveId; + if (last == null || last == bootstrap.id) return bootstrap; + return byId(last) ?? bootstrap; + } + + /// The `dev` profiles whose origin folder no longer exists (SPEC-50 D9). + /// + /// `user` profiles are never stale: they have no origin and were created + /// deliberately. [dirExists] is injected so tests need no real directories. + List staleProfiles({bool Function(String path)? dirExists}) { + final exists = dirExists ?? _realDirExists; + return [ + for (final p in _profiles) + if (p.kind == ProfileKind.dev && p.origin != null && !exists(p.origin!)) + p, + ]; + } + + static bool _realDirExists(String path) => Directory(path).existsSync(); + + /// Finds a free port at or above [startingGuess], skipping ports already + /// claimed by another profile in this registry. + /// + /// Two distinct sources of conflict, both real: another *profile* (which may + /// not be running right now, so a probe would wrongly call its port free) and + /// another *process*. Registry-claimed ports are excluded up front; the rest + /// are probed. + /// + /// Wraps around the dev range before giving up, and falls back to the guess + /// when every candidate is busy — the daemon's own `EADDRINUSE` path then + /// reports it, which is strictly better than throwing during launch. + Future allocatePort({required int startingGuess}) async { + final claimed = {for (final p in _profiles) p.port}; + final start = startingGuess < kDevPortRangeStart + ? kDevPortRangeStart + : startingGuess; + for (var i = 0; i < kDevPortRangeLength; i++) { + final candidate = + kDevPortRangeStart + + ((start - kDevPortRangeStart + i) % kDevPortRangeLength); + if (claimed.contains(candidate)) continue; + if (await _probe(candidate)) return candidate; + } + return startingGuess; + } + + /// Mints an id from [seed] that is free of both live profiles **and** + /// tombstoned ids. + /// + /// A tombstone (`_deleted`) causes [save] to drop any profile carrying that + /// id, so reusing a just-deleted slug would silently discard the freshly + /// created profile and orphan its home. An id is therefore “taken” if a live + /// profile holds it or a tombstone still names it. + String _uniqueId(String seed) { + bool taken(String id) => byId(id) != null || _deleted.contains(id); + final base = seed.isEmpty ? 'profile' : seed; + if (!taken(base)) return base; + for (var n = 2; n < 1000; n++) { + final candidate = '$base-$n'; + if (!taken(candidate)) return candidate; + } + return '$base-${DateTime.now().microsecondsSinceEpoch}'; + } + + /// Whether every profile currently held would survive a save/reload cycle. + /// + /// A mint-time invariant: an id the registry creates but + /// `ServerProfile.fromJson` later rejects would silently vanish on relaunch, + /// taking the profile's home and pairings with it. Asserted by test after every + /// mint path. (Not annotated `@visibleForTesting`: this repo co-locates tests + /// inside `lib/`, where the analyzer does not recognise them as tests.) + bool get allIdsRoundTrip => _profiles.every((p) => isSafeProfileId(p.id)); + + /// Lowercase, `-`-separated, `[a-z0-9-]` only — safe in a path, a prefs key + /// and a filename. + /// + /// Truncated to [_kMaxSlugLength] so the result always satisfies + /// [isSafeProfileId], which caps ids at 64 characters. Without this cap the + /// registry would happily *mint* a 100-character id from a long profile name + /// and then silently **drop that profile** on the next launch, when + /// `fromJson` rejected it — losing the user's data rather than protecting it. + /// The margin below 64 leaves room for a `-2`-style uniqueness suffix. + static String _slug(String name) { + final s = name + .toLowerCase() + .replaceAll(RegExp('[^a-z0-9]+'), '-') + .replaceAll(RegExp('^-+'), '') + .replaceAll(RegExp(r'-+$'), ''); + if (s.isEmpty) return 'profile'; + if (s.length <= _kMaxSlugLength) return s; + // Trim to the cap, then drop a trailing '-' so the id never ends in one. + return s.substring(0, _kMaxSlugLength).replaceAll(RegExp(r'-+$'), ''); + } + + /// Longest slug the registry will mint, leaving room under [isSafeProfileId]'s + /// 64-character cap for a uniqueness suffix. + static const int _kMaxSlugLength = 48; +} + +/// The narrow slice of filesystem the registry needs, so tests can run without +/// touching a real disk. +class FileSystemAdapter { + /// Creates an adapter over the real filesystem. + const FileSystemAdapter(); + + /// Directory mode for `~/.makit`, matching the server's `MAKIT_HOME_MODE`. + static const int homeMode = 0x1c0; // 0700 + + /// File mode for registry data, matching the server's `MAKIT_FILE_MODE`. + static const int fileMode = 0x180; // 0600 + + /// Runs [body] while holding an exclusive, inter-process advisory lock keyed + /// on [path]. + /// + /// Serialises the registry's read-merge-write across the several app instances + /// that run at once (SPEC-50 D1). Without it, two instances can each read the + /// same `profiles.json`, merge in only their own new profile, and have the + /// second atomic rename silently drop the first's — orphaning a `user` + /// profile's home, pairings and prefs, since it has no `origin` to re-bind by. + /// The lock file (`.lock`) is a separate sentinel so the data file's + /// atomic replace is never itself the locked handle. + T withLock(String path, T Function() body) { + final lockFile = File('$path.lock'); + RandomAccessFile? raf; + try { + lockFile.parent.createSync(recursive: true); + raf = lockFile.openSync(mode: FileMode.write); + raf.lockSync(FileLock.blockingExclusive); + } on FileSystemException { + // The lock could not be taken (e.g. a filesystem that does not support + // advisory locks): fall back to running unlocked rather than refusing to + // persist — the union-by-id merge still protects the common case. Crucially + // this catch covers only lock ACQUISITION; [body] runs below, outside it, + // so a filesystem failure inside [body] (a failed `writeAtomic`) is never + // silently retried unlocked — which would re-run side effects and could + // race another process, the very lost-update the lock prevents. + raf = null; + } + try { + return body(); + } finally { + try { + raf?.unlockSync(); + } on FileSystemException { + // Best effort: the handle is closed next regardless. + } + try { + raf?.closeSync(); + } on FileSystemException { + // Nothing more to do. + } + } + } + + /// Returns the contents of [path], or `null` when it does not exist or cannot + /// be read. + String? readOrNull(String path) { + try { + final f = File(path); + return f.existsSync() ? f.readAsStringSync() : null; + } on FileSystemException { + return null; + } + } + + /// Writes [contents] to [path] via a temp file + rename, `0600`, inside a + /// directory forced to `0700`. + /// + /// The modes are not cosmetic. The server guarantees `MAKIT_HOME` is `0700` and + /// its files `0600` (`server/src/daemon/paths.ts`) because that directory holds + /// an APNs auth key and a TLS private key. `Directory.createSync` gives `0755` + /// and `writeAsStringSync` gives `0644`, so an app that created the directory + /// first would silently *downgrade* the server's guarantee and leave those + /// secrets readable by every local user. + /// + /// The temp name carries the pid: several app instances may save concurrently + /// (SPEC-50 D1), and a shared `foo.tmp` would race — the loser's `renameSync` + /// throwing a `FileSystemException` out of `save()`. + void writeAtomic(String path, String contents) { + final target = File(path); + final dir = target.parent; + dir.createSync(recursive: true); + _chmod(dir.path, homeMode); + final tmp = File('$path.$pid.tmp'); + // Clean up the temp file if the write, chmod or rename throws, so a failed + // save does not litter ~/.makit with orphaned `*.tmp` files. + try { + tmp.writeAsStringSync(contents, flush: true); + _chmod(tmp.path, fileMode); + tmp.renameSync(path); + } catch (_) { + try { + if (tmp.existsSync()) tmp.deleteSync(); + } on FileSystemException { + // Best effort. + } + rethrow; + } + } + + /// Best-effort `chmod`. POSIX-only; a failure must not stop the app from + /// recording its profiles. + static void _chmod(String path, int mode) { + if (Platform.isWindows) return; + try { + Process.runSync('/bin/chmod', [ + mode.toRadixString(8).padLeft(3, '0'), + path, + ]); + } on ProcessException { + // Non-fatal: the write itself succeeded. + } + } +} diff --git a/app/lib/desktop/daemon/profile_runtime.dart b/app/lib/desktop/daemon/profile_runtime.dart new file mode 100644 index 00000000..288a789f --- /dev/null +++ b/app/lib/desktop/daemon/profile_runtime.dart @@ -0,0 +1,223 @@ +/// Everything that belongs to ONE profile, behind one disposable object. +/// +/// Exists so a second one can be built at runtime: switching profiles inside a +/// running window (SPEC-50 D10) means standing up a whole parallel set of +/// per-profile objects — control client, daemon controller, scoped preference +/// controllers, lifecycle and deleter — and tearing the old set down. Leaving +/// that list inline in `runDesktopApp` made it impossible to have two. +/// +/// The [overrides] getter returns a list *literal* rather than a typed field on +/// purpose: Riverpod does not export the `Override` base type, so naming it does +/// not compile. Inference handles it. +library; + +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../control/control_client.dart'; +import '../../control/reconnecting_control_client.dart'; +import '../../store/prefs/profile_scoped_prefs.dart'; +import '../chat/groups/groups_controller.dart'; +import '../desktop_controller.dart'; +import '../settings/server_config.dart'; +import 'daemon_lifecycle.dart'; +import 'profile_deleter.dart'; +import 'profile_lifecycle.dart'; +import 'profile_registry.dart'; +import 'profiles_controller.dart'; +import 'server_profile.dart'; + +/// Confirms [target] is reachable, and only then calls [handOver]. +/// +/// The order is the entire safety property of a profile switch (SPEC-50 D10): +/// the target is started and confirmed *answering* while the current profile is +/// still live, so a target that cannot come up leaves the window exactly as it +/// was. [handOver] performs the irreversible part — build the new runtime, swap +/// it in, dispose the old — and is called at most once, never on a failure. +/// +/// Extracted from the widget that owns the `ProviderScope` so this sequence can +/// be tested without a widget tree; that scope swap is untestable in a unit test, +/// but the decision of *whether* to swap is the part that can go wrong. +Future verifyThenHandOver({ + required ServerProfile target, + required ProfileLifecycle lifecycle, + required Future Function() handOver, +}) async { + if (!await lifecycle.isRunning(target)) { + final started = await lifecycle.start(target); + if (!started.ok) { + // Always name the profile. The CLI's own message can be as bare as + // "makit start exited 1", and this string is sometimes surfaced on its own + // (folded into the switch-away-and-delete report), where an unnamed + // failure tells the user nothing. + final why = started.message; + return (why == null || why.trim().isEmpty) + ? 'could not start “${target.name}”' + : 'could not start “${target.name}”: ${why.trim()}'; + } + if (!await lifecycle.isRunning(target)) { + return '“${target.name}” started but is not answering on its control ' + 'socket'; + } + } + await handOver(); + return null; +} + +/// The per-profile half of the app's object graph. +class ProfileRuntime { + ProfileRuntime._({ + required this.profile, + required this.registry, + required this.client, + required this.controller, + required this.configController, + required this.groupsController, + required this.profileLifecycle, + required this.profileDeleter, + required this.profilesController, + }); + + /// Builds the runtime for [profile]. + /// + /// Synchronous by design: every dependency is constructed, none is awaited, so + /// a switch cannot leave the app half-built while a future settles. + factory ProfileRuntime.create({ + required ServerProfile profile, + required ProfileRegistry registry, + required SharedPreferences prefs, + }) { + final socketPath = profile.controlSocketPath; + final client = ReconnectingControlClient( + create: () => MakitControlClient(socketPath: socketPath), + connect: (c) => (c as MakitControlClient).connect(), + dispose: (c) => (c as MakitControlClient).dispose(), + ); + + // Namespace only SERVER-BOUND preferences per profile (SPEC-50 D11): server + // config, groups, and the pane layouts groups persist. Appearance, + // shortcuts, recent models and cached commands are user-level and stay + // SHARED — the old blanket `SharedPreferences.setPrefix` is why a worktree + // build opened with a default theme and empty shortcuts. Because the plugin + // composes keys by plain concatenation, `prefsKeyPrefix` lands on the + // byte-identical key `setPrefix` produced, so there is no migration. + final scoped = ProfileScopedPrefs(prefs, profile.prefsKeyPrefix); + final configController = ServerConfigController( + scoped, + ServerConfigController.load(scoped, defaultPort: profile.port), + defaultPort: profile.port, + ); + + final controller = DesktopController( + client: client, + lifecycle: DaemonLifecycle( + resolver: MakitCliResolver( + // Read live, so a settings change takes effect without a restart. + overridePath: () => configController.current.cliPath, + ), + // MAKIT_HOME so the spawned daemon writes its socket/pid/db under this + // profile's home — matching the control socket the client connects to. + environment: profile.environment, + ), + serveArgs: () => configController.current.serveArgs(), + ); + + // Lifecycle actions may target ANY profile, not just this one, so the CLI + // path and endpoint arguments are resolved per target from that profile's + // own scoped config. Using the active profile's values started a target on + // the wrong binary and on the CLI's default port instead of its allocated + // one (colliding with the legacy daemon). + ServerConfig configFor(ServerProfile target) => identical(target, profile) + ? configController.current + : ServerConfigController.load( + ProfileScopedPrefs(prefs, target.prefsKeyPrefix), + defaultPort: target.port, + ); + + final profileLifecycle = ProfileLifecycle( + resolver: MakitCliResolver( + overridePath: () => configController.current.cliPath, + ), + cliPathFor: (target) => configFor(target).cliPath, + serveArgsFor: (target) => configFor(target).serveArgs(), + ); + // All three share ONE registry instance: the deleter removes the entry + // directly and the controller repaints from the same list, so two copies + // would show a profile that no longer exists. + final profileDeleter = ProfileDeleter( + registry: registry, + lifecycle: profileLifecycle, + activeProfileId: profile.id, + // Store (3): a NON-active profile's keys are reachable now that prefs are + // scoped by key prefix rather than the global setPrefix, so the deleter can + // actually purge them instead of always reporting them skipped. + purgePrefs: (target) => + ProfileScopedPrefs(prefs, target.prefsKeyPrefix).clearScope(), + ); + + return ProfileRuntime._( + profile: profile, + registry: registry, + client: client, + controller: controller, + configController: configController, + groupsController: GroupsController.load(scoped), + profileLifecycle: profileLifecycle, + profileDeleter: profileDeleter, + profilesController: ProfilesController( + registry: registry, + activeProfileId: profile.id, + isRunning: profileLifecycle.isRunning, + diskUsage: profileDeleter.diskUsage, + ), + ); + } + + /// The profile this runtime serves. + final ServerProfile profile; + + /// The shared registry (not per-profile; held for convenience). + final ProfileRegistry registry; + + /// This profile's control-socket client. + final ReconnectingControlClient client; + + /// This profile's daemon controller (owns the poll timer). + final DesktopController controller; + + /// Server config, read from this profile's scoped preferences. + final ServerConfigController configController; + + /// Groups + pane layouts, from this profile's scoped preferences. + final GroupsController groupsController; + + /// Starts/stops any profile's daemon. + final ProfileLifecycle profileLifecycle; + + /// Erases a profile across its four stores. + final ProfileDeleter profileDeleter; + + /// Observable state for the Profiles list. + final ProfilesController profilesController; + + /// Begins polling the daemon so state stays fresh whether it was started by + /// the app, the CLI, or died underneath us. + void startPolling() => controller.startPolling(); + + /// Tears the runtime down. + /// + /// Order matters: the controller's poll timer is cancelled *before* the client + /// closes, because a poll firing against a closed client throws. + /// + /// [profilesController] is disposed here because it is injected via + /// `overrideWithValue` (see `desktop_app.dart`), which Riverpod does **not** + /// dispose — without this it leaks one listened-to controller per profile + /// switch. `configController` and `groupsController` use `overrideWith`, whose + /// created value Riverpod does dispose, so they are not touched here. + Future dispose() async { + controller.dispose(); + profilesController.dispose(); + await client.close(); + } +} diff --git a/app/lib/desktop/daemon/profiles_controller.dart b/app/lib/desktop/daemon/profiles_controller.dart new file mode 100644 index 00000000..67e53232 --- /dev/null +++ b/app/lib/desktop/daemon/profiles_controller.dart @@ -0,0 +1,211 @@ +/// Observable state around [ProfileRegistry] for the Profiles UI. +/// +/// The registry itself is a plain, synchronous value object — deliberately, so it +/// can be unit-tested without Flutter. This controller is the thin observable +/// layer over it: it persists after every mutation (a profile the user named but +/// which vanished on relaunch would be worse than no profiles at all) and +/// notifies listeners so the list repaints. +/// +/// It also owns the per-profile *runtime* facts the registry cannot know — +/// whether a daemon is up, and how much disk the profile occupies — because those +/// are observations of the world, not persisted configuration. +library; + +import 'package:flutter/foundation.dart'; + +import 'profile_registry.dart'; +import 'server_profile.dart'; + +/// A profile plus the live facts the UI shows beside it. +@immutable +class ProfileStatus { + /// Creates a status row. + const ProfileStatus({ + required this.profile, + required this.running, + this.diskBytes, + this.stale = false, + }); + + /// The profile this describes. + final ServerProfile profile; + + /// Whether its daemon is currently up. + final bool running; + + /// Recursive size of its `MAKIT_HOME`, or `null` while unmeasured. + /// + /// Nullable rather than `0` so the UI can say "measuring" instead of lying + /// about an empty profile. + final int? diskBytes; + + /// Whether this is a dev profile whose origin folder has gone (SPEC-50 D9). + final bool stale; + + @override + bool operator ==(Object other) => + other is ProfileStatus && + other.profile == profile && + other.running == running && + other.diskBytes == diskBytes && + other.stale == stale; + + @override + int get hashCode => Object.hash(profile, running, diskBytes, stale); +} + +/// Reads whether a profile's daemon is up. Injected so tests spawn nothing. +typedef RunningProbe = Future Function(ServerProfile profile); + +/// Measures a profile's on-disk size. Injected so tests touch no filesystem. +typedef DiskProbe = Future Function(ServerProfile profile); + +/// Drives the Profiles settings section. +class ProfilesController extends ChangeNotifier { + /// Creates a controller over [registry], reporting [activeProfileId] as active. + ProfilesController({ + required ProfileRegistry registry, + required this.activeProfileId, + RunningProbe? isRunning, + DiskProbe? diskUsage, + bool Function(String path)? dirExists, + }) : _registry = registry, + _isRunning = isRunning, + _diskUsage = diskUsage, + _dirExists = dirExists; + + final ProfileRegistry _registry; + final RunningProbe? _isRunning; + final DiskProbe? _diskUsage; + final bool Function(String path)? _dirExists; + + /// The id of the profile this window is currently connected to. + final String activeProfileId; + + final Map _running = {}; + final Map _disk = {}; + + /// The registry behind this controller, for callers that need the raw model. + ProfileRegistry get registry => _registry; + + /// The profile this window runs against, or `null` if the registry lost it. + ServerProfile? get active => _registry.byId(activeProfileId); + + /// Every profile with its live status, active profile first, then user + /// profiles, then dev ones — the order the user thinks in. + List get rows { + final stale = { + for (final p in _registry.staleProfiles(dirExists: _dirExists)) p.id, + }; + final list = [ + for (final p in _registry.profiles) + ProfileStatus( + profile: p, + running: _running[p.id] ?? false, + diskBytes: _disk[p.id], + stale: stale.contains(p.id), + ), + ]; + list.sort((a, b) { + if (a.profile.id == activeProfileId) return -1; + if (b.profile.id == activeProfileId) return 1; + final byKind = a.profile.kind.index.compareTo(b.profile.kind.index); + if (byKind != 0) return byKind; + return a.profile.name.toLowerCase().compareTo( + b.profile.name.toLowerCase(), + ); + }); + return list; + } + + /// The stale dev profiles, and their combined measured size. + /// + /// The count is the honest headline, not the bytes: each stale profile still + /// holds a device pairing and a TLS keypair. + /// + /// The **active** profile is never listed here even when stale: the reclaim + /// sheet's deleter refuses the active profile, so it belongs in the main list + /// (kept there by the section) where its menu offers switch-away-&-delete. + ({List rows, int bytes}) get staleSummary { + final stale = rows + .where((r) => r.stale && r.profile.id != activeProfileId) + .toList(); + var bytes = 0; + for (final r in stale) { + bytes += r.diskBytes ?? 0; + } + return (rows: stale, bytes: bytes); + } + + /// Refreshes running state and disk usage for every profile. + /// + /// Both probes read the live world and can throw. A throw must not abort the + /// remaining profiles nor skip [notifyListeners] (three call sites treat a + /// throw here as a failed delete), so each probe is guarded individually and + /// listeners are always notified. + Future refresh() async { + final probeRunning = _isRunning; + final probeDisk = _diskUsage; + for (final p in _registry.profiles) { + if (probeRunning != null) { + try { + _running[p.id] = await probeRunning(p); + } catch (_) { + // Leave the last-known running state rather than dropping the row. + } + } + if (probeDisk != null) { + try { + _disk[p.id] = await probeDisk(p); + } catch (_) { + // Leave the last-known size rather than aborting the whole refresh. + } + } + } + notifyListeners(); + } + + /// Creates a profile named [name] and persists it. + /// + /// Returns the new profile, or `null` when [name] is blank — the caller shows + /// the validation message rather than this throwing into a button callback. + Future create(String name) async { + if (name.trim().isEmpty) return null; + final created = await _registry.createUserProfile(name: name); + _registry.save(); + notifyListeners(); + return created; + } + + /// Renames [id], persisting on success. + bool rename(String id, String name) { + if (!_registry.rename(id, name)) return false; + _registry.save(); + notifyListeners(); + return true; + } + + /// Drops [id] from the registry and persists. + /// + /// Erasing the on-disk stores is the deleter's job; this is the last step of + /// that sequence, exposed here so the list repaints. + bool forget(String id) { + if (!_registry.remove(id)) return false; + _registry.save(); + notifyListeners(); + return true; + } + + /// Records an observed running state without a full [refresh]. + void noteRunning(String id, {required bool running}) { + _running[id] = running; + notifyListeners(); + } + + /// Repaints the list after the registry changed underneath it. + /// + /// Needed because a delete performed elsewhere (the host, after a + /// switch-away-and-delete) mutates the shared registry directly, and + /// `notifyListeners` is protected to subclasses. + void notifyRegistryChanged() => notifyListeners(); +} diff --git a/app/lib/desktop/daemon/server_profile.dart b/app/lib/desktop/daemon/server_profile.dart index fde24195..56a44def 100644 --- a/app/lib/desktop/daemon/server_profile.dart +++ b/app/lib/desktop/daemon/server_profile.dart @@ -1,121 +1,257 @@ -/// The isolated server "profile" a desktop app instance runs against. +/// The isolated server **profile** an app instance runs against. /// -/// A single Mac can run several makit desktop builds at once — e.g. one built -/// from `main` and one from a feature worktree. Without isolation they collide: -/// they share `~/.makit` (control socket, pid, db), the default port, and the -/// `NSUserDefaults` prefs domain, so one window's "restart server" hijacks the -/// other's daemon. See `docs/DEVELOPMENT.md`. +/// A profile owns a whole server instance: its own `MAKIT_HOME` (and therefore +/// its own daemon, database, media, pairings and projects), its own port, and +/// its own slice of app preferences. Several may run at once — a `Work` profile +/// and a feature worktree's dev profile coexist on different ports without +/// seeing each other. See `docs/specs/2026-08-10-SPEC-50-profiles.md`. /// -/// A [ServerProfile] gives each build its own `MAKIT_HOME`, port, prefs prefix, -/// and window label — derived deterministically from the running `.app`'s path, -/// so two builds never step on each other and each is stable across rebuilds in -/// the same location. No user configuration required. -/// -/// The **default** profile (an installed app, e.g. in `/Applications`, whose -/// path is not a Flutter dev-build path) keeps the historical `~/.makit` + port -/// 7777 + `flutter.` prefs prefix, so shipped users are unaffected. +/// Identity is **persisted, not derived** (SPEC-50 D3). `ProfileRegistry` mints +/// [id] once into `~/.makit/profiles.json`; path-hashing survives only as the +/// bootstrap for a dev build the registry has never seen. Deriving the id from +/// the filesystem path — as this class used to — silently orphaned a profile's +/// home, pairings and prefs whenever a worktree moved. library; -import 'dart:io'; +import 'dart:io' show Platform; -import '../settings/server_config.dart' show kDefaultServerPort; +import 'server_profile_paths.dart'; -/// A per-build server profile. Immutable; derived by [ServerProfile.resolve]. -class ServerProfile { - /// Creates a profile. Prefer [ServerProfile.resolve]. - const ServerProfile({ - required this.id, - required this.label, - required this.isDefault, - required this.makitHome, - required this.port, - }); +String _resolvedExecutable() => Platform.resolvedExecutable; +String _homeDir() => Platform.environment['HOME'] ?? ''; - /// Stable, filesystem/prefs-safe key fragment. `'default'` for the installed - /// app; an 8-char hex hash of the repo root for a dev build. - final String id; +/// How a profile came into existence. +enum ProfileKind { + /// Created deliberately by the user (e.g. `Work`, `Personal`). Never + /// auto-removed, and never considered stale. + user, - /// Human label used in the window title and the in-app badge — the repo/ - /// worktree folder name for a dev build, or `'makit'` for the default. - final String label; - - /// True for the installed app: uses the historical `~/.makit`, port 7777, and - /// the legacy `flutter.` prefs prefix (backward compatible). - final bool isDefault; + /// Auto-created for a Flutter dev build so a worktree cannot collide with the + /// installed app. Carries an [ServerProfile.origin] and can go stale. + dev, +} - /// Absolute `MAKIT_HOME` this instance's daemon and control socket live under. - final String makitHome; +/// Which on-disk key layout a profile's preferences and secrets use. +/// +/// This is a **compatibility** fact, frozen at creation — deliberately separate +/// from [ServerProfile.name], which is a UI fact the user may change at will +/// (SPEC-50 D2). Fusing the two into one `isDefault` boolean was what made the +/// installed profile un-renameable. +enum ProfileStorage { + /// The shipped layout: unprefixed preference keys (so the effective + /// `NSUserDefaults` key stays `flutter.`) and the unsuffixed secure-store + /// file. **At most one profile may use this**, and it is implicitly protected: + /// it is the profile holding `AuthKey_*.p8`, `ota/`, `push.json` and + /// `host.json`. + legacy, - /// The default bind port seeded into this instance's [ServerConfig] (the user - /// can still override it in Settings; that override is stored per profile). - final int port; + /// Keys and secrets namespaced by [ServerProfile.id]. + namespaced, +} - /// Where this instance's daemon exposes its control socket. The app's control - /// client connects here; the spawned CLI (with `MAKIT_HOME=[makitHome]`) - /// creates it here. - String get controlSocketPath => '$makitHome/control.sock'; +/// Matches an id safe to interpolate into a path or a preference key. +final RegExp _safeProfileId = RegExp(r'^[a-z0-9][a-z0-9-]*$'); - /// The `SharedPreferences` key prefix that namespaces this instance's - /// settings. The default profile keeps `flutter.` so existing prefs survive. - String get prefsPrefix => isDefault ? 'flutter.' : 'flutter.$id.'; +/// Whether [id] is safe to use in a filesystem path and a preference key. +/// +/// Lowercase alphanumerics and `-` only, and never empty. Rejects `.`, `/`, `..` +/// and every separator, which is what keeps a hand-edited `profiles.json` from +/// steering a file operation out of its directory. +bool isSafeProfileId(String id) => + id.isNotEmpty && id.length <= 64 && _safeProfileId.hasMatch(id); - /// The native window title, so builds are distinguishable in Cmd-Tab / the - /// Window menu / Mission Control. - String get windowTitle => isDefault ? 'Makit' : 'Makit — $label'; +/// A persisted server profile. Immutable; mutate via [copyWith]. +class ServerProfile { + /// Creates a profile. Prefer `ProfileRegistry` over constructing directly. + const ServerProfile({ + required this.id, + required this.name, + required this.kind, + required this.home, + required this.port, + required this.storage, + this.origin, + }); - /// The `MAKIT_HOME` environment override passed to the spawned `makit` CLI. - Map get environment => {'MAKIT_HOME': makitHome}; + /// Reads a profile from its `profiles.json` object, tolerating unknown and + /// missing fields so a newer registry never hard-fails an older build. + /// + /// Returns `null` when the entry lacks the fields that have no safe default + /// ([id], [home]) — the caller drops it rather than inventing an identity — or + /// when [id] is not a safe slug. + /// + /// The charset check is a containment guard, not tidiness: `id` is interpolated + /// into a filesystem path (the secure-store namespace file) and into preference + /// keys. `profiles.json` is a plain user-writable file, so a hand-edited id of + /// `../../../../tmp/x` would otherwise steer a delete outside the app-support + /// directory. Anything the registry itself mints already satisfies this. + static ServerProfile? fromJson(Map json) { + final id = json['id']; + final home = json['home']; + if (id is! String || !isSafeProfileId(id)) return null; + if (home is! String || !home.startsWith('/')) return null; + final port = json['port']; + final name = json['name']; + final origin = json['origin']; + return ServerProfile( + id: id, + name: (name is String && name.isNotEmpty) ? name : id, + kind: json['kind'] == 'dev' ? ProfileKind.dev : ProfileKind.user, + home: home, + // Reject out-of-range ports the same way missing/non-positive ones are + // rejected: a hand-edited `{"port": 70000}` cannot be bound, so falling + // back keeps the profile startable instead of silently wedging it. Mirrors + // ProfileRegistry.setPort's own `> 65535` guard. + port: (port is int && port > 0 && port <= 65535) + ? port + : kFallbackServerPort, + storage: json['storage'] == 'legacy' + ? ProfileStorage.legacy + : ProfileStorage.namespaced, + origin: (origin is String && origin.isNotEmpty) ? origin : null, + ); + } - /// Matches a macOS Flutter dev-build executable path and captures the repo - /// root in group 1: - /// `/app/build/macos/Build/Products//.app/Contents/MacOS/` - static final RegExp _devBuildPath = RegExp( - r'^(.*)/app/build/macos/Build/Products/[^/]+/[^/]+\.app/Contents/MacOS/[^/]+$', - ); + /// Serialises to its `profiles.json` object. `origin` is omitted when absent + /// so a user profile's entry stays free of null noise. + Map toJson() => { + 'id': id, + 'name': name, + 'kind': kind.name, + 'home': home, + 'port': port, + 'storage': storage.name, + if (origin != null) 'origin': origin, + }; - /// Derives the profile for the running instance. + /// A profile derived from [executablePath] alone, without consulting the + /// registry. /// - /// [executablePath] defaults to [Platform.resolvedExecutable] and [home] to - /// `$HOME`; both are injectable for tests. - static ServerProfile resolve({String? executablePath, String? home}) { - final exe = executablePath ?? Platform.resolvedExecutable; - final resolvedHome = home ?? Platform.environment['HOME'] ?? ''; - - final match = _devBuildPath.firstMatch(exe); - if (match == null) { + /// The fallback for contexts that have no registry: widget tests, and a safety + /// net should a future entry point forget to override `serverProfileProvider`. + /// It reproduces what `ProfileRegistry.resolveFor` mints for the same path — + /// same id, home and *guessed* port — but persists nothing and does **not** + /// probe the port, so two bootstrap profiles can collide. Production resolves + /// through the registry, which persists identity and probes (SPEC-50 D3/D4). + static ServerProfile bootstrap({String? executablePath, String? home}) { + final exe = executablePath ?? _resolvedExecutable(); + final resolvedHome = home ?? _homeDir(); + final repoRoot = devBuildRepoRoot(exe); + if (repoRoot == null) { return ServerProfile( id: 'default', - label: 'makit', - isDefault: true, - makitHome: '$resolvedHome/.makit', + name: 'Makit', + kind: ProfileKind.user, + home: '$resolvedHome/.makit', port: kDefaultServerPort, + storage: ProfileStorage.legacy, ); } - - final repoRoot = match.group(1)!; - final h = _fnv1a(repoRoot); - final id = h.toRadixString(16).padLeft(8, '0'); - final label = repoRoot.split('/').where((s) => s.isNotEmpty).last; - // 7800–7899: a stable, collision-unlikely dev range that avoids the 7777 - // default. A user can still override the port per profile in Settings. - final port = 7800 + (h % 100); + final id = devIdGuess(repoRoot); return ServerProfile( id: id, - label: label, - isDefault: false, - makitHome: '$resolvedHome/.makit-dev/$id', - port: port, + name: labelForRepoRoot(repoRoot), + kind: ProfileKind.dev, + home: '$resolvedHome/.makit-dev/$id', + port: devPortGuess(repoRoot), + storage: ProfileStorage.namespaced, + origin: repoRoot, ); } - /// 32-bit FNV-1a — a small, deterministic, cross-launch-stable string hash. - /// (Dart's `String.hashCode` is not guaranteed stable across runs.) - static int _fnv1a(String s) { - var hash = 0x811c9dc5; - for (final c in s.codeUnits) { - hash ^= c; - hash = (hash * 0x01000193) & 0xffffffff; - } - return hash; - } + /// Stable, filesystem- and prefs-safe key. Minted once and never re-derived. + final String id; + + /// What the user calls this profile. Editable, and shown in the window title, + /// the switcher badge and the Profiles list. + final String name; + + /// Whether the user created this profile or a dev build did. + final ProfileKind kind; + + /// Absolute `MAKIT_HOME` this profile's daemon and control socket live under. + final String home; + + /// The port this profile's daemon binds. Allocated once by probing and + /// persisted (SPEC-50 D4) — never recomputed from a hash. + final int port; + + /// Which on-disk key layout this profile uses. Frozen at creation. + final ProfileStorage storage; + + /// For [ProfileKind.dev]: the repo root this profile was created from. + /// + /// Two jobs, both cheap: re-bind a moved or rebuilt dev build to its existing + /// profile instead of forking a new one, and detect staleness with a plain + /// `existsSync` (SPEC-50 D3/D9) — no hashing, no guessing. + final String? origin; + + /// Where this profile's daemon exposes its control socket. + String get controlSocketPath => '$home/control.sock'; + + /// Where this profile's daemon records its OS process id, mirroring the + /// server's `pidFilePath()` (`$MAKIT_HOME/makit.pid`). Read *before* stopping + /// a daemon, because `makit stop` removes this file the instant it signals. + String get pidFilePath => '$home/makit.pid'; + + /// The prefix this profile's **own** preference keys carry. + /// + /// This is the mechanism in use (SPEC-50 D11): `ProfileRuntime.create` wraps + /// the shared `SharedPreferences` in a `ProfileScopedPrefs` with this prefix. + /// Deliberately *not* `SharedPreferences.setPrefix`, which throws once + /// `getInstance()` has run and so makes in-place switching impossible. Because + /// the plugin composes keys by plain concatenation, `'flutter.' + '.key'` + /// is byte-identical to the key the old `setPrefix('flutter..')` produced, + /// so adopting it needed no migration (asserted in `profile_registry_test.dart`). + String get prefsKeyPrefix => storage == ProfileStorage.legacy ? '' : '$id.'; + + /// The secure-store namespace, or `null` for the legacy unsuffixed file. + String? get secureStoreNamespace => + storage == ProfileStorage.legacy ? null : id; + + /// True when this profile may never be deleted. Implied by + /// [ProfileStorage.legacy] rather than stored separately, so the two can never + /// drift apart. + bool get isProtected => storage == ProfileStorage.legacy; + + /// The native window title, so builds are distinguishable in Cmd-Tab. + String get windowTitle => 'Makit — $name'; + + /// The `MAKIT_HOME` override passed to this profile's spawned `makit` CLI. + Map get environment => {'MAKIT_HOME': home}; + + /// Returns a copy with the given overrides. [storage], [id] and [kind] are + /// deliberately absent: they are frozen at creation. + ServerProfile copyWith({ + String? name, + String? home, + int? port, + String? origin, + }) => ServerProfile( + id: id, + name: name ?? this.name, + kind: kind, + home: home ?? this.home, + port: port ?? this.port, + storage: storage, + origin: origin ?? this.origin, + ); + + @override + bool operator ==(Object other) => + other is ServerProfile && + other.id == id && + other.name == name && + other.kind == kind && + other.home == home && + other.port == port && + other.storage == storage && + other.origin == origin; + + @override + int get hashCode => Object.hash(id, name, kind, home, port, storage, origin); + + @override + String toString() => + 'ServerProfile($id, $name, ${kind.name}, $home, $port, ${storage.name})'; } diff --git a/app/lib/desktop/daemon/server_profile_paths.dart b/app/lib/desktop/daemon/server_profile_paths.dart new file mode 100644 index 00000000..6726d8a4 --- /dev/null +++ b/app/lib/desktop/daemon/server_profile_paths.dart @@ -0,0 +1,56 @@ +/// Path and identity derivation shared by [ServerProfile] and `ProfileRegistry`. +/// +/// Kept in its own library so the profile *model* stays free of filesystem +/// concerns and the registry can reuse the derivation without a circular import. +library; + +/// The port the installed (legacy) profile binds, matching the server's own +/// default in `serve.ts`. +const int kDefaultServerPort = 7777; + +/// Used when a persisted entry carries no usable port. Distinct from +/// [kDefaultServerPort] only in intent: this is a repair value, not a default. +const int kFallbackServerPort = kDefaultServerPort; + +/// The low end of the dev-profile port range. +const int kDevPortRangeStart = 7800; + +/// The number of ports in the dev range (7800–7899). +const int kDevPortRangeLength = 100; + +/// Matches a macOS Flutter dev-build executable path and captures the repo root +/// in group 1: +/// `/app/build/macos/Build/Products//.app/Contents/MacOS/` +final RegExp _devBuildPath = RegExp( + r'^(.*)/app/build/macos/Build/Products/[^/]+/[^/]+\.app/Contents/MacOS/[^/]+$', +); + +/// The repo root of a Flutter dev build, or `null` for an installed app. +String? devBuildRepoRoot(String executablePath) => + _devBuildPath.firstMatch(executablePath)?.group(1); + +/// A human label for a repo root: its last path segment (`feat-profiles`). +String labelForRepoRoot(String repoRoot) => + repoRoot.split('/').where((s) => s.isNotEmpty).lastOrNull ?? 'makit'; + +/// 32-bit FNV-1a — a small, deterministic, cross-launch-stable string hash. +/// (Dart's `String.hashCode` is not guaranteed stable across runs.) +int fnv1a(String s) { + var hash = 0x811c9dc5; + for (final c in s.codeUnits) { + hash ^= c; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash; +} + +/// The 8-hex-char id a dev build *starts* from. Only a first guess: the registry +/// resolves collisions, and once minted the id is persisted forever (SPEC-50 D3). +String devIdGuess(String repoRoot) => + fnv1a(repoRoot).toRadixString(16).padLeft(8, '0'); + +/// The port a dev build *starts* probing from. Only a first guess: 100 slots for +/// an unbounded number of worktrees means collisions are expected, so the +/// registry probes upward from here and persists the result (SPEC-50 D4). +int devPortGuess(String repoRoot) => + kDevPortRangeStart + (fnv1a(repoRoot) % kDevPortRangeLength); diff --git a/app/lib/desktop/daemon/server_profile_test.dart b/app/lib/desktop/daemon/server_profile_test.dart deleted file mode 100644 index 9e61edd0..00000000 --- a/app/lib/desktop/daemon/server_profile_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -// Unit tests for [ServerProfile] derivation. Co-located with the code under -// test (per SPEC-03 desktop layout). -// ignore_for_file: depend_on_referenced_packages -import 'package:flutter_test/flutter_test.dart'; -import 'package:makit/desktop/daemon/server_profile.dart'; -import 'package:makit/desktop/settings/server_config.dart' - show kDefaultServerPort; - -void main() { - group('ServerProfile.resolve', () { - const home = '/Users/dev'; - - ServerProfile devFrom(String repoRoot) => ServerProfile.resolve( - executablePath: - '$repoRoot/app/build/macos/Build/Products/Release/makit.app/Contents/MacOS/makit', - home: home, - ); - - test( - 'installed app (non dev-build path) → backward-compatible default', - () { - final p = ServerProfile.resolve( - executablePath: '/Applications/makit.app/Contents/MacOS/makit', - home: home, - ); - expect(p.isDefault, isTrue); - expect(p.id, 'default'); - expect(p.label, 'makit'); - expect(p.makitHome, '$home/.makit'); - expect(p.port, kDefaultServerPort); - expect(p.controlSocketPath, '$home/.makit/control.sock'); - expect(p.prefsPrefix, 'flutter.'); - expect(p.windowTitle, 'Makit'); - }, - ); - - test('dev build → isolated home, dev port, namespaced prefs', () { - final p = devFrom('/Users/dev/Work/makit'); - expect(p.isDefault, isFalse); - expect(p.label, 'makit'); - expect(p.makitHome, '$home/.makit-dev/${p.id}'); - expect(p.port, inInclusiveRange(7800, 7899)); - expect(p.controlSocketPath, '$home/.makit-dev/${p.id}/control.sock'); - expect(p.prefsPrefix, 'flutter.${p.id}.'); - expect(p.windowTitle, 'Makit — makit'); - expect(p.environment, {'MAKIT_HOME': p.makitHome}); - }); - - test('worktree build → label is the worktree folder name', () { - final p = devFrom('/Users/dev/.worktrees/makit/feature-x'); - expect(p.label, 'feature-x'); - expect(p.windowTitle, 'Makit — feature-x'); - }); - - test( - 'derivation is deterministic (stable id/port/home across launches)', - () { - final a = devFrom('/Users/dev/.worktrees/makit/feature-x'); - final b = devFrom('/Users/dev/.worktrees/makit/feature-x'); - expect(a.id, b.id); - expect(a.port, b.port); - expect(a.makitHome, b.makitHome); - }, - ); - - test('different repos get different homes (no collision)', () { - final main = devFrom('/Users/dev/Work/makit'); - final wt = devFrom('/Users/dev/.worktrees/makit/feature-x'); - expect(main.id, isNot(wt.id)); - expect(main.makitHome, isNot(wt.makitHome)); - }); - }); -} diff --git a/app/lib/desktop/desktop_app.dart b/app/lib/desktop/desktop_app.dart index 68ec4141..99c76927 100644 --- a/app/lib/desktop/desktop_app.dart +++ b/app/lib/desktop/desktop_app.dart @@ -23,7 +23,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../status/activity_badge.dart'; import '../status/status_toast.dart'; import '../app/theme.dart'; -import '../control/control_client.dart'; import '../control/reconnecting_control_client.dart'; import '../shortcuts/keymap_controller.dart'; import '../store/cached_commands.dart'; @@ -42,7 +41,11 @@ import 'chat/loopback_pairing.dart'; import 'chat/groups/groups_controller.dart'; import 'chat/sidebar_layout.dart'; import 'daemon/daemon_lifecycle.dart'; +import 'daemon/profile_deleter.dart'; +import 'daemon/profile_registry.dart'; +import 'daemon/profile_runtime.dart'; import 'daemon/server_profile.dart'; +import 'settings/sections/profiles_providers.dart'; import 'desktop_controller.dart'; import 'desktop_ports_route.dart'; import 'screens/providers.dart'; @@ -61,11 +64,19 @@ final desktopControllerProvider = Provider( (ref) => throw UnimplementedError('overridden in runDesktopApp'), ); -/// The isolated server profile this app instance runs against (per build). -/// Self-derives from the running executable by default (so widget tests need no -/// override); [runDesktopApp] overrides it with the already-derived profile. +/// The server profile this app instance runs against. +/// +/// Defaults to [ServerProfile.bootstrap] so widget tests need no override; +/// [runDesktopApp] replaces it with the profile [ProfileRegistry] resolved, +/// which is the one with a persisted identity and a probed port. final serverProfileProvider = Provider( - (ref) => ServerProfile.resolve(), + (ref) => ServerProfile.bootstrap(), +); + +/// The registry backing [serverProfileProvider]. Overridden alongside it so the +/// Profiles UI can list, create, rename and delete without re-reading the file. +final profileRegistryProvider = Provider( + (ref) => throw UnimplementedError('overridden in runDesktopApp'), ); /// Navigator for the chat window, so the sidebar can push the Settings/Server @@ -77,36 +88,31 @@ Future runDesktopApp() async { WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); - // Per-build isolation: a `main` build and a worktree build each get their own - // MAKIT_HOME, port, prefs namespace, and window label so two windows never - // collide. The installed app keeps the historical ~/.makit + 7777 defaults. - final profile = ServerProfile.resolve(); - - final socketPath = profile.controlSocketPath; - final client = ReconnectingControlClient( - create: () => MakitControlClient(socketPath: socketPath), - connect: (c) => (c as MakitControlClient).connect(), - dispose: (c) => (c as MakitControlClient).dispose(), + // Per-profile isolation: a `main` build and a worktree build each get their + // own MAKIT_HOME, port, prefs namespace and window label so two windows never + // collide. Identity is persisted in ~/.makit/profiles.json rather than derived + // from this executable's path, so moving or rebuilding a worktree re-binds to + // the same profile instead of orphaning it (SPEC-50 D3). + final resolvedHome = Platform.environment['HOME'] ?? ''; + final registry = ProfileRegistry.load(makitRoot: '$resolvedHome/.makit'); + final resolution = await registry.resolveFor( + executablePath: Platform.resolvedExecutable, + home: resolvedHome, ); - // Namespace SharedPreferences per profile (dev builds only) so a worktree - // window's settings don't overwrite main's. Must run before getInstance(). - if (!profile.isDefault) SharedPreferences.setPrefix(profile.prefsPrefix); + // Honour the profile the user last switched to, but only for the installed + // app: a dev build always opens its own profile (SPEC-50 D10). + final profile = registry.preferredFor(resolution.profile); + // Only write when the set actually changed: launching must not rewrite the + // registry (and bump its mtime) on every start. + if (resolution.created) registry.save(); + final prefs = await SharedPreferences.getInstance(); - final configController = ServerConfigController( - prefs, - ServerConfigController.load(prefs, defaultPort: profile.port), - defaultPort: profile.port, - ); - final lifecycle = DaemonLifecycle( - resolver: MakitCliResolver( - // Honor the user's optional CLI-path override (read live so a settings - // change takes effect without an app restart). - overridePath: () => configController.current.cliPath, - ), - // Pass MAKIT_HOME so the spawned daemon writes its socket/pid/db under this - // profile's home — matching the control socket the app connects to above. - environment: profile.environment, + final runtime = ProfileRuntime.create( + profile: profile, + registry: registry, + prefs: prefs, ); + final keymapController = KeymapController.load( prefs, cmdIsPrimary: cmdIsPrimaryModifier, @@ -116,17 +122,15 @@ Future runDesktopApp() async { // SPEC-45: the starter pane's slash palette, remembered across restarts — // otherwise every relaunch shows it empty until a session has run. final cachedCommandsController = CachedCommandsController.load(prefs); - final groupsController = GroupsController.load(prefs); - final controller = DesktopController( - client: client, - lifecycle: lifecycle, - serveArgs: () => configController.current.serveArgs(), - ); - + // The tray must read the runtime through a holder, not close over one + // DesktopController: after a profile switch the old controller is disposed, and + // a menubar still pointing at it would drive a dead object. + final host = _ProfileHostState.holder; + host.runtime = runtime; final tray = TrayController( - stateAccessor: () => controller.summary, - onStart: () => controller.start(), - onStop: () => controller.stop(), + stateAccessor: () => host.runtime.controller.summary, + onStart: () => host.runtime.controller.start(), + onStop: () => host.runtime.controller.stop(), onOpenDashboard: _showWindow, onOpenQr: _showWindow, // SPEC-42 D15: the menubar's one ports action. The desktop shell is not a @@ -139,17 +143,19 @@ Future runDesktopApp() async { // Real quit: cancel the poll timer, then terminate the process — which also // removes the tray icon. (windowManager.destroy alone left it running.) onQuit: () { - controller.dispose(); + host.runtime.controller.dispose(); exit(0); }, ); await tray.init(); - // Keep the tray menu/tooltip in sync as the controller refreshes. - controller.addListener(() => unawaited(tray.update(controller.summary))); + host.tray = tray; + // Keep the tray menu/tooltip in sync as the active controller refreshes. The + // listener is re-attached on every switch (see _ProfileHostState._adopt). + host.attachTray(); // Poll the daemon so state stays fresh whether it is started from the app, // the CLI, or crashes underneath us. - controller.startPolling(); + runtime.startPolling(); final options = WindowOptions( size: const Size(1120, 760), @@ -171,40 +177,14 @@ Future runDesktopApp() async { ); runApp( - ProviderScope( - observers: const [SidebarLayoutPrefsObserver()], - overrides: [ - controlClientProvider.overrideWithValue(client), - desktopControllerProvider.overrideWithValue(controller), - serverProfileProvider.overrideWithValue(profile), - // Per-profile pairing bearer so main and worktree builds never clobber - // each other's stored server (a shared bearer wedged the app into - // permanent "Reconnecting"). - secureStorageProvider.overrideWithValue( - defaultSecureStore(namespace: profile.isDefault ? null : profile.id), - ), - serverConfigProvider.overrideWith((ref) => configController), - keymapProvider.overrideWith((ref) => keymapController), - preferencesControllerProvider.overrideWith( - (ref) => preferencesController, - ), - recentModelsControllerProvider.overrideWith( - (ref) => recentModelsController, - ), - cachedCommandsControllerProvider.overrideWith( - (ref) => cachedCommandsController, - ), - groupsControllerProvider.overrideWith((ref) => groupsController), - // SPEC-34: hand the stored rail on/off + options to the shared - // transcript providers (shared `ui/` cannot read `desktop/` prefs). - messageNavigatorStyleProvider.overrideWith( - (ref) => ref.watch(desktopNavigatorStyleProvider), - ), - railOptionsProvider.overrideWith( - (ref) => ref.watch(desktopRailOptionsProvider), - ), - ], - child: _DesktopApp(tray: tray), + _ProfileHost( + prefs: prefs, + registry: registry, + keymapController: keymapController, + preferencesController: preferencesController, + recentModelsController: recentModelsController, + cachedCommandsController: cachedCommandsController, + tray: tray, ), ); } @@ -388,3 +368,226 @@ class _NoScrollbarBehavior extends MaterialScrollBehavior { /// The documented one-line installer for the makit CLI (see issue #13 / README). const String makitInstallCommand = 'curl -fsSL https://raw.githubusercontent.com/leduckhc/makit/main/install.sh | bash'; + +/// A mutable holder for the active [ProfileRuntime]. +/// +/// The tray and the poll loop are created before the widget tree exists and must +/// survive a profile switch, so they read the runtime through this rather than +/// closing over one instance (SPEC-50 D10). +class _RuntimeHolder { + late ProfileRuntime runtime; + TrayController? tray; + VoidCallback? _trayListener; + DesktopController? _trayTarget; + + /// (Re)attaches the tray's sync listener to the current runtime's controller. + void attachTray() { + final t = tray; + if (t == null) return; + final previous = _trayListener; + final previousTarget = _trayTarget; + // Detach from the EXACT controller the listener was added to. The caller has + // already swapped `runtime` to the new one, so `runtime.controller` is the + // wrong instance — removing from it is a no-op and leaks the listener on the + // old controller. + if (previous != null && previousTarget != null) { + // Best effort: the old controller may already be disposed. + try { + previousTarget.removeListener(previous); + } on FlutterError { + /* already disposed */ + } + } + void listener() => unawaited(t.update(runtime.controller.summary)); + _trayListener = listener; + _trayTarget = runtime.controller; + runtime.controller.addListener(listener); + unawaited(t.update(runtime.controller.summary)); + } +} + +/// Hosts the app and swaps the whole per-profile object graph on a switch. +/// +/// The switch is a **key change on the `ProviderScope`**: Riverpod then disposes +/// the entire old container deterministically, so there is no hand-written +/// teardown list to forget an entry as the app grows. +class _ProfileHost extends StatefulWidget { + const _ProfileHost({ + required this.prefs, + required this.registry, + required this.keymapController, + required this.preferencesController, + required this.recentModelsController, + required this.cachedCommandsController, + required this.tray, + }); + + final SharedPreferences prefs; + final ProfileRegistry registry; + final KeymapController keymapController; + final PreferencesController preferencesController; + final RecentModelsController recentModelsController; + final CachedCommandsController cachedCommandsController; + final TrayController? tray; + + @override + State<_ProfileHost> createState() => _ProfileHostState(); +} + +class _ProfileHostState extends State<_ProfileHost> { + /// Shared with `runDesktopApp`, which builds the first runtime and the tray + /// before any widget exists. + static final _RuntimeHolder holder = _RuntimeHolder(); + + ProfileRuntime get _runtime => holder.runtime; + + /// True while a [switchTo] is mid-flight, so a second switch cannot start + /// before the first resolves. Both the title-bar badge and the Profiles + /// section call `switchTo` through `profileSwitcherProvider`, so guarding here + /// serialises every entry point: two interleaved switches could otherwise each + /// replace the shared runtime and write `lastActive`, leaving the window on + /// one profile while persistence and the title name another. + bool _switching = false; + + /// Switches to [target], verifying it is reachable BEFORE tearing anything + /// down (SPEC-50 D10 step 2). + /// + /// Returns `null` on success, or a human-readable reason on failure — in which + /// case nothing has changed and the caller reports it. The order is the whole + /// point: start and confirm the target while the current profile is still + /// live, so a target that cannot come up leaves the window exactly as it was. + /// Switches to [target], verifying it is reachable BEFORE tearing anything + /// down, and optionally deleting [deleteAfter] once the switch has landed. + /// + /// Returns `null` on success, or a human-readable reason on failure — in which + /// case nothing has changed and the caller reports it. + /// + /// [deleteAfter] exists because `ProfileDeleter` refuses the *active* profile + /// by design (D8), so "switch away and delete" cannot be done by the widget + /// that offers it: the ProviderScope it lives in is disposed by the switch. The + /// host survives that rebuild, so it runs the delete afterwards through the NEW + /// runtime's deleter, which correctly sees the old profile as inactive. + Future switchTo( + ServerProfile target, { + ServerProfile? deleteAfter, + }) async { + if (target.id == _runtime.profile.id) { + return (switchFailure: null, deleteFailure: null); + } + if (_switching) { + return ( + switchFailure: + 'a profile switch is already in progress — wait for it to finish', + deleteFailure: null, + ); + } + _switching = true; + try { + return await _switchTo(target, deleteAfter: deleteAfter); + } finally { + _switching = false; + } + } + + Future _switchTo( + ServerProfile target, { + ServerProfile? deleteAfter, + }) async { + if (target.id == _runtime.profile.id) { + return (switchFailure: null, deleteFailure: null); + } + + final failure = await verifyThenHandOver( + target: target, + lifecycle: _runtime.profileLifecycle, + handOver: () async { + final next = ProfileRuntime.create( + profile: target, + registry: widget.registry, + prefs: widget.prefs, + ); + final previous = _runtime; + holder.runtime = next; + next.startPolling(); + holder.attachTray(); + if (mounted) setState(() {}); + // Only now is the old graph safe to tear down. + await previous.dispose(); + }, + ); + if (failure != null) { + return (switchFailure: failure, deleteFailure: null); + } + + if (widget.registry.setLastActive(target.id)) widget.registry.save(); + await windowManager.setTitle(target.windowTitle); + + if (deleteAfter != null && deleteAfter.id != target.id) { + final result = await holder.runtime.profileDeleter.delete(deleteAfter); + if (result.outcome != ProfileDeletionOutcome.deleted) { + // The switch SUCCEEDED; only the follow-up delete failed. Report it as a + // separate fact so the caller does not show a "could not switch" error. + return ( + switchFailure: null, + deleteFailure: + 'could not delete ${deleteAfter.name}: ' + '${result.skipped.join('; ')}', + ); + } + holder.runtime.profilesController.notifyRegistryChanged(); + } + return (switchFailure: null, deleteFailure: null); + } + + @override + Widget build(BuildContext context) { + final profile = _runtime.profile; + return ProviderScope( + // Changing the key rebuilds the container, disposing every provider bound + // to the previous profile. + key: ValueKey('profile-${profile.id}'), + observers: const [SidebarLayoutPrefsObserver()], + overrides: [ + controlClientProvider.overrideWithValue(_runtime.client), + desktopControllerProvider.overrideWithValue(_runtime.controller), + serverProfileProvider.overrideWithValue(profile), + profileRegistryProvider.overrideWithValue(widget.registry), + profilesControllerProvider.overrideWithValue( + _runtime.profilesController, + ), + profileLifecycleProvider.overrideWithValue(_runtime.profileLifecycle), + profileDeleterProvider.overrideWithValue(_runtime.profileDeleter), + profileSwitcherProvider.overrideWithValue(switchTo), + switcherProfilesProvider.overrideWithValue(_runtime.profilesController), + // Per-profile pairing bearer so two profiles never clobber each other's + // stored server (a shared bearer wedged the app into "Reconnecting"). + secureStorageProvider.overrideWithValue( + defaultSecureStore(namespace: profile.secureStoreNamespace), + ), + serverConfigProvider.overrideWith((ref) => _runtime.configController), + groupsControllerProvider.overrideWith( + (ref) => _runtime.groupsController, + ), + keymapProvider.overrideWith((ref) => widget.keymapController), + preferencesControllerProvider.overrideWith( + (ref) => widget.preferencesController, + ), + recentModelsControllerProvider.overrideWith( + (ref) => widget.recentModelsController, + ), + cachedCommandsControllerProvider.overrideWith( + (ref) => widget.cachedCommandsController, + ), + // SPEC-34: hand the stored rail on/off + options to the shared + // transcript providers (shared `ui/` cannot read `desktop/` prefs). + messageNavigatorStyleProvider.overrideWith( + (ref) => ref.watch(desktopNavigatorStyleProvider), + ), + railOptionsProvider.overrideWith( + (ref) => ref.watch(desktopRailOptionsProvider), + ), + ], + child: _DesktopApp(tray: widget.tray), + ); + } +} diff --git a/app/lib/desktop/desktop_controller.dart b/app/lib/desktop/desktop_controller.dart index c87877b3..f0e51f7a 100644 --- a/app/lib/desktop/desktop_controller.dart +++ b/app/lib/desktop/desktop_controller.dart @@ -121,26 +121,50 @@ class DesktopController extends ChangeNotifier { Future Function() action, { DaemonState? transient, }) async { - if (transient != null) { - // Counts as newer than any refresh already in flight: an action's - // `starting`/`stopping` state has to survive until the action's own - // refresh replaces it, or a poll that left before the button was pressed - // repaints it as stopped mid-start. - ++_refreshGeneration; - _summary = DaemonSummary( - state: transient, - pid: _summary.pid, - pairedDevices: _summary.pairedDevices, - runningSessions: _summary.runningSessions, - ); - notifyListeners(); + // Serialize lifecycle actions. `start`/`stop`/`restart` share the daemon's + // PID file and control socket (and `restart` is a `stop` then `start`), so + // overlapping requests — from mashing Start/Stop/Restart or a reachability + // change racing a manual action — can remove a freshly written PID file, + // launch a second daemon, or stop one another action just started. Chaining + // each action after the previous one makes them strictly sequential. + final prior = _actionTail; + final done = Completer(); + _actionTail = done.future; + if (prior != null) { + try { + await prior; + } catch (_) { + // A prior action's failure must not cancel the ones queued behind it. + } + } + try { + if (transient != null) { + // Counts as newer than any refresh already in flight: an action's + // `starting`/`stopping` state has to survive until the action's own + // refresh replaces it, or a poll that left before the button was pressed + // repaints it as stopped mid-start. + ++_refreshGeneration; + _summary = DaemonSummary( + state: transient, + pid: _summary.pid, + pairedDevices: _summary.pairedDevices, + runningSessions: _summary.runningSessions, + ); + notifyListeners(); + } + final result = await action(); + _cliMissing = result.outcome == DaemonActionOutcome.cliNotFound; + await refresh(); + return result; + } finally { + if (identical(_actionTail, done.future)) _actionTail = null; + done.complete(); } - final result = await action(); - _cliMissing = result.outcome == DaemonActionOutcome.cliNotFound; - await refresh(); - return result; } + /// Tail of the serialized lifecycle-action chain, or null when idle. + Future? _actionTail; + /// Starts periodic polling: refreshes immediately, then every [interval] /// while the window is visible, dropping to [hiddenInterval] while it is not. /// Cancels any previous poll. Owned here so the app can stop it cleanly on diff --git a/app/lib/desktop/desktop_controller_test.dart b/app/lib/desktop/desktop_controller_test.dart index 1986a182..68963707 100644 --- a/app/lib/desktop/desktop_controller_test.dart +++ b/app/lib/desktop/desktop_controller_test.dart @@ -206,6 +206,53 @@ void main() { expect(calls.single, ['/x/makit', 'stop']); }); + test('lifecycle actions are serialized, never overlapping', () async { + // start/stop/restart share the PID file and control socket, so overlapping + // them can drop a fresh PID or launch a second daemon. They must run + // strictly one at a time. + var active = 0; + var maxActive = 0; + final gates = >[]; + final lifecycle = DaemonLifecycle( + resolver: MakitCliResolver( + candidatePaths: const ['/x/makit'], + exists: (_) => true, + shellLookup: () async => null, + ), + run: (exe, args) async { + active++; + if (active > maxActive) maxActive = active; + final gate = Completer(); + gates.add(gate); + await gate.future; + active--; + return ProcessResult(0, 0, '', ''); + }, + ); + final c = DesktopController( + client: _FakeControlClient(), + lifecycle: lifecycle, + ); + + // Fire two actions without awaiting them. + final f1 = c.start(); + final f2 = c.stop(); + await pumpEventQueue(); + + // Only the first action has begun; the second is queued behind it. + expect(gates.length, 1); + expect(maxActive, 1); + + gates[0].complete(); + await pumpEventQueue(); + // Now the second action runs — still alone. + expect(gates.length, 2); + gates[1].complete(); + await Future.wait([f1, f2]); + + expect(maxActive, 1, reason: 'lifecycle actions must never overlap'); + }); + test('notifies listeners on refresh', () async { var notes = 0; final c = DesktopController( diff --git a/app/lib/desktop/settings/registry/settings_registry.dart b/app/lib/desktop/settings/registry/settings_registry.dart index 4f61a08a..977a6da9 100644 --- a/app/lib/desktop/settings/registry/settings_registry.dart +++ b/app/lib/desktop/settings/registry/settings_registry.dart @@ -15,6 +15,7 @@ import '../sections/agents_chat_section.dart'; import '../sections/appearance_section.dart'; import '../sections/general_section.dart'; import '../sections/notifications_section.dart'; +import '../sections/profiles_section.dart'; import '../sections/server_devices_section.dart'; import '../sections/shortcuts_section.dart'; import '../../../store/models.dart'; @@ -202,6 +203,53 @@ final List kSettingsSections = [ ), ], ), + SettingsSection( + id: 'profiles', + title: 'Profiles', + icon: PhosphorIconsLight.cube, + builder: (_) => const ProfilesSection(), + items: const [ + SettingsItem( + id: 'profiles.list', + title: 'Profiles', + help: + 'Every server profile: name, home, size, running state, and ' + 'per-profile Start/Stop/Rename/Delete.', + keywords: [ + 'profile', + 'profiles', + 'work', + 'personal', + 'dev build', + 'server instance', + 'start', + 'stop', + 'rename', + ], + ), + SettingsItem( + id: 'profiles.new', + title: 'New profile', + help: 'Create a new named server profile with its own home and port.', + keywords: ['new profile', 'create profile', 'add profile'], + ), + SettingsItem( + id: 'profiles.delete', + title: 'Delete profile', + help: + 'Erase a profile across all four of its stores; your worktrees, ' + 'repos and other profiles are never touched.', + keywords: ['delete profile', 'remove profile', 'danger', 'erase'], + ), + SettingsItem( + id: 'profiles.reclaim', + title: 'Stale profiles', + help: + 'Review and bulk-delete dev profiles whose source folder is gone.', + keywords: ['stale', 'orphan', 'orphaned', 'reclaim', 'cleanup'], + ), + ], + ), SettingsSection( id: 'notifications', title: 'Notifications', diff --git a/app/lib/desktop/settings/sections/general_section.dart b/app/lib/desktop/settings/sections/general_section.dart index 1364800b..14728f82 100644 --- a/app/lib/desktop/settings/sections/general_section.dart +++ b/app/lib/desktop/settings/sections/general_section.dart @@ -1,16 +1,88 @@ -import 'package:flutter/widgets.dart'; +/// General section body (SPEC-13 migration map). +/// +/// Home for one-time, user-level actions. SPEC-50 D6 moves `Install CLI` here +/// from the Server section: installing the bundled `makit` binary is a one-time +/// action, and one-time actions belong with one-time actions. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; -import 'coming_soon.dart'; +import '../../../status/status_event.dart'; +import '../../../status/status_providers.dart'; +import '../../screens/providers.dart' + show bundledCliPathProvider, cliInstallerProvider; +import 'section_header.dart'; +import 'settings_group.dart'; /// General section body. -/// -/// Placeholder for Wave 1 — Wave 2 fleshes this out in place (see the migration -/// map in SPEC-13). The registry references this widget; do not edit the -/// aggregator to change this body. class GeneralSection extends StatelessWidget { /// Creates the General section body. const GeneralSection({super.key}); @override - Widget build(BuildContext context) => const ComingSoon(title: 'General'); + Widget build(BuildContext context) { + return ListView( + children: const [ + SettingsSectionHeader(title: 'General'), + SettingsSectionHeader(title: 'Command-line tool'), + SettingsGroup(children: [_InstallCliRow()]), + ], + ); + } +} + +/// One-time install of the app-bundled `makit` CLI into `~/.local/bin/makit`. +/// +/// Shown only when the running app actually bundles a CLI (dev builds run from +/// source do not). Moved here from the Server section (SPEC-50 D6). +class _InstallCliRow extends ConsumerWidget { + const _InstallCliRow(); + + Future _install(WidgetRef ref) async { + // Resolved before the first await: `ref` throws once its widget is + // unmounted, and the record must survive the thing that reported to it. + final status = ref.status; + final result = await ref.read(cliInstallerProvider).install(); + if (result.ok) { + status.success( + 'Installed makit CLI to ${result.installedPath}', + source: StatusSources.settings, + detail: + 'If your terminal can’t find `makit`, add ~/.local/bin to your PATH.', + ); + } else { + status.failure( + 'Could not install the makit CLI', + source: StatusSources.settings, + detail: result.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final bundled = ref.watch(bundledCliPathProvider) != null; + return ListTile( + leading: Icon(PhosphorIconsLight.terminalWindow, color: cs.outline), + title: const Text('Install CLI'), + subtitle: Text( + bundled + ? 'Install the bundled makit command to ~/.local/bin so you can ' + 'drive the server from a terminal.' + : 'This build has no bundled CLI to install.', + ), + trailing: bundled + ? OutlinedButton.icon( + onPressed: () => unawaited(_install(ref)), + icon: const Icon(PhosphorIconsLight.downloadSimple, size: 18), + label: const Text('Install CLI'), + ) + : null, + ); + } } diff --git a/app/lib/desktop/settings/sections/profile_delete_sheet.dart b/app/lib/desktop/settings/sections/profile_delete_sheet.dart new file mode 100644 index 00000000..910e4569 --- /dev/null +++ b/app/lib/desktop/settings/sections/profile_delete_sheet.dart @@ -0,0 +1,285 @@ +/// The profile delete confirmation sheet (SPEC-50 D8, mockup card 6). +/// +/// The most important widget in the Profiles section: it enumerates **what will +/// be deleted** and, just as prominently, **what will be kept**. The kept half +/// is what makes the destructive button usable — the word "delete" next to a +/// path that sits beside a worktree reads as "deletes my branch", and one line +/// removes that fear. +/// +/// The confirm wires to [ProfileDeleter], then reflects its +/// [ProfileDeletionResult] honestly: on refusal it surfaces the reason from +/// `skipped`; on success it reports the bytes freed **and** the stores that were +/// skipped (for example the secure-store file on a platform that has none), +/// never hiding them. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; + +import '../../../app/theme.dart'; +import '../../../status/status_event.dart'; +import '../../../status/status_providers.dart'; +import '../../daemon/profiles_controller.dart'; +import 'profiles_format.dart'; +import 'profiles_providers.dart'; + +/// Shows the delete sheet for [status], then performs and reports the deletion. +/// +/// Returns once the flow settles. Captures `ref.status` before the first await +/// (`ref` throws once unmounted) and repaints the list via +/// [ProfilesController.refresh] after a successful delete, because the deleter +/// removes the registry entry directly and the controller must be told to +/// re-read. +Future showProfileDeleteSheet( + BuildContext context, + WidgetRef ref, + ProfileStatus status, +) async { + final statusCenter = ref.status; + final deleter = ref.read(profileDeleterProvider); + final controller = ref.read(profilesControllerProvider); + final profile = status.profile; + + final confirmed = await showDialog( + context: context, + builder: (_) => _ProfileDeleteDialog( + name: profile.name, + prefsKeyPrefix: profile.prefsKeyPrefix, + diskBytes: status.diskBytes, + running: status.running, + ), + ); + if (confirmed != true) return; + + // `ProfileDeleter.delete` is best-effort and reports store failures in its + // result, but an unexpected throw must still surface an outcome rather than + // vanish into an unhandled async gap that leaves the list stale. + try { + final result = await deleter.delete(profile); + if (result.ok) { + await controller.refresh(); + final freed = formatProfileBytes(result.bytesFreed); + final skipped = result.skipped.isEmpty + ? null + : 'Freed $freed. Not purged: ${result.skipped.join(' — ')}'; + statusCenter.success( + 'Deleted ${profile.name}', + source: StatusSources.settings, + detail: skipped ?? 'Freed $freed.', + ); + } else { + statusCenter.failure( + 'Could not delete ${profile.name}', + source: StatusSources.settings, + detail: result.skipped.join(' — '), + ); + } + } catch (error) { + await controller.refresh(); + statusCenter.failure( + 'Could not delete ${profile.name}', + source: StatusSources.settings, + detail: error.toString(), + ); + } +} + +/// The sheet body. Pure UI: it returns `true` from the navigator only when the +/// user confirms, and knows nothing about the deleter. +class _ProfileDeleteDialog extends StatelessWidget { + const _ProfileDeleteDialog({ + required this.name, + required this.prefsKeyPrefix, + required this.diskBytes, + required this.running, + }); + + final String name; + final String prefsKeyPrefix; + final int? diskBytes; + final bool running; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return AlertDialog( + title: Text('Delete “$name”?'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('This removes makit’s state for this profile only.'), + const SizedBox(height: kSpace16), + _SectionLabel( + label: 'Will be deleted', + trailing: formatProfileBytes(diskBytes), + color: cs.error, + ), + const _DeletedItem('makit.db', 'sessions & transcripts'), + const _DeletedItem('media/', 'ingested images'), + const _DeletedItem('devices.json', 'paired devices'), + const _DeletedItem('projects.json', 'projects'), + const _DeletedItem( + 'server.crt / .key', + 'this profile’s TLS identity', + ), + const _DeletedItem('keychain / secure store', 'pairing bearer'), + // Genuinely deleted now: prefs are scoped by key prefix, so the + // deleter can purge another profile's keys (the sheet used to carry + // a caveat here, from when the global setPrefix made them + // unreachable). + _DeletedItem('prefs', 'flutter.$prefsKeyPrefix* keys'), + const _DeletedItem('profiles.json', 'registry entry'), + const SizedBox(height: kSpace16), + _SectionLabel(label: 'Will be kept', color: cs.primary), + const _KeptItem( + 'your code', + 'worktrees and repos are never touched', + ), + const _KeptItem( + 'other profiles', + 'every other profile is unaffected', + ), + if (running) ...[ + const SizedBox(height: kSpace12), + Text( + 'The daemon is running and will be stopped before removal.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.outline), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton.icon( + style: FilledButton.styleFrom(backgroundColor: cs.error), + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(PhosphorIconsLight.trash, size: 18), + label: const Text('Delete profile'), + ), + ], + ); + } +} + +/// A "Will be deleted" / "Will be kept" heading, optionally with a trailing size. +class _SectionLabel extends StatelessWidget { + const _SectionLabel({ + required this.label, + required this.color, + this.trailing, + }); + + final String label; + final Color color; + final String? trailing; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.labelMedium?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ); + return Padding( + padding: const EdgeInsets.only(bottom: kSpace8), + child: Row( + children: [ + Expanded(child: Text(label.toUpperCase(), style: style)), + if (trailing != null) + Text( + trailing!, + style: style?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} + +class _DeletedItem extends StatelessWidget { + const _DeletedItem(this.key_, this.detail); + + final String key_; + final String detail; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: kSpace2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + key_, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12.5), + ), + ), + Expanded( + child: Text( + detail, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.outline), + ), + ), + ], + ), + ); + } +} + +class _KeptItem extends StatelessWidget { + const _KeptItem(this.key_, this.detail); + + final String key_; + final String detail; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: kSpace2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(PhosphorIconsLight.check, size: 15, color: cs.primary), + const SizedBox(width: kSpace8), + SizedBox( + width: 110, + child: Text( + key_, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12.5, + ), + ), + ), + Expanded( + child: Text( + detail, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.outline), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/desktop/settings/sections/profile_reclaim_sheet.dart b/app/lib/desktop/settings/sections/profile_reclaim_sheet.dart new file mode 100644 index 00000000..e1960ac7 --- /dev/null +++ b/app/lib/desktop/settings/sections/profile_reclaim_sheet.dart @@ -0,0 +1,191 @@ +/// The stale-profile reclaim sheet (SPEC-50 D9, mockup card 7). +/// +/// Orphans are **offered, never reaped** (D9): a dev profile whose origin folder +/// is gone is listed with its size, selectable, and deletable in bulk — but a +/// human always presses the button. Auto-deletion is rejected because it would +/// have destroyed transcripts the first time a worktree moved. +/// +/// The bulk delete runs [ProfileDeleter] once per selected profile and reports a +/// single combined outcome (count deleted, bytes freed, and any refusals). +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; + +import '../../../app/theme.dart'; +import '../../../status/status_event.dart'; +import '../../../status/status_providers.dart'; +import '../../daemon/profiles_controller.dart'; +import '../../daemon/server_profile.dart'; +import 'profiles_format.dart'; +import 'profiles_providers.dart'; + +/// Shows the reclaim sheet over [staleRows], then deletes the chosen profiles. +Future showProfileReclaimSheet( + BuildContext context, + WidgetRef ref, + List staleRows, +) async { + final statusCenter = ref.status; + final deleter = ref.read(profileDeleterProvider); + final controller = ref.read(profilesControllerProvider); + + final chosen = await showDialog>( + context: context, + builder: (_) => _ReclaimDialog(rows: staleRows), + ); + if (chosen == null || chosen.isEmpty) return; + + var deleted = 0; + var bytesFreed = 0; + final refusals = []; + for (final profile in chosen) { + // Guard each deletion: one profile's filesystem/registry failure must not + // abort the loop and leave the remaining selected profiles unprocessed with + // no combined outcome reported. + try { + final result = await deleter.delete(profile); + if (result.ok) { + deleted++; + bytesFreed += result.bytesFreed; + } else { + refusals.add('${profile.name}: ${result.skipped.join(' — ')}'); + } + } catch (error) { + refusals.add('${profile.name}: $error'); + } + } + await controller.refresh(); + + final freed = formatProfileBytes(bytesFreed); + if (refusals.isEmpty) { + statusCenter.success( + 'Deleted $deleted stale ${deleted == 1 ? 'profile' : 'profiles'}', + source: StatusSources.settings, + detail: 'Freed $freed.', + ); + } else { + statusCenter.failure( + 'Deleted $deleted of ${chosen.length} stale profiles', + source: StatusSources.settings, + detail: 'Freed $freed. Refused: ${refusals.join('; ')}', + ); + } +} + +/// The checkbox list. Returns the selected profiles from the navigator. +class _ReclaimDialog extends StatefulWidget { + const _ReclaimDialog({required this.rows}); + + final List rows; + + @override + State<_ReclaimDialog> createState() => _ReclaimDialogState(); +} + +class _ReclaimDialogState extends State<_ReclaimDialog> { + late final Set _selected = { + for (final r in widget.rows) r.profile.id, + }; + + int get _selectedBytes { + var total = 0; + for (final r in widget.rows) { + if (_selected.contains(r.profile.id)) total += r.diskBytes ?? 0; + } + return total; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final count = _selected.length; + return AlertDialog( + title: const Text('Stale profiles'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'These dev profiles were created from folders that no longer ' + 'exist.', + ), + const SizedBox(height: kSpace12), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final r in widget.rows) + CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: _selected.contains(r.profile.id), + onChanged: (on) => setState(() { + if (on ?? false) { + _selected.add(r.profile.id); + } else { + _selected.remove(r.profile.id); + } + }), + title: Text( + r.profile.name, + style: const TextStyle(fontFamily: 'monospace'), + ), + subtitle: const Text('folder gone'), + secondary: Text( + formatProfileBytes(r.diskBytes), + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ), + ), + const Divider(), + Row( + children: [ + Expanded( + child: Text( + '$count ${count == 1 ? 'profile' : 'profiles'} selected', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Text( + formatProfileBytes(_selectedBytes), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop([]), + child: const Text('Keep all'), + ), + FilledButton.icon( + style: FilledButton.styleFrom(backgroundColor: cs.error), + onPressed: count == 0 + ? null + : () => Navigator.of(context).pop([ + for (final r in widget.rows) + if (_selected.contains(r.profile.id)) r.profile, + ]), + icon: const Icon(PhosphorIconsLight.trash, size: 18), + label: Text('Delete $count ${count == 1 ? 'profile' : 'profiles'}'), + ), + ], + ); + } +} diff --git a/app/lib/desktop/settings/sections/profile_switch_sheet.dart b/app/lib/desktop/settings/sections/profile_switch_sheet.dart new file mode 100644 index 00000000..f27c50c1 --- /dev/null +++ b/app/lib/desktop/settings/sections/profile_switch_sheet.dart @@ -0,0 +1,228 @@ +/// The confirm sheet for switching the window to another profile (SPEC-50 D10). +/// +/// Its shape is deliberate: the **"keeps running"** half is what makes the button +/// usable. Switching sounds like it might stop your work, and the honest answer +/// is that it does not — the profile you leave keeps its server up, its agents +/// running and its phones paired. Saying so removes the hesitation. +library; + +import 'package:flutter/material.dart'; + +import '../../../app/theme.dart'; +import '../../daemon/server_profile.dart'; +import '../../chat/server_profile_badge.dart' show hueForProfileId; + +/// Asks whether to switch from [from] to [to]. +/// +/// Returns true when the user confirms. [targetRunning] tailors the copy: if the +/// target's daemon is already up there is nothing to start, and promising to +/// start it would be a small lie. +Future confirmProfileSwitch( + BuildContext context, { + required ServerProfile from, + required ServerProfile to, + required bool targetRunning, +}) async { + final result = await showDialog( + context: context, + builder: (context) => + _SwitchSheet(from: from, to: to, targetRunning: targetRunning), + ); + return result ?? false; +} + +class _SwitchSheet extends StatelessWidget { + const _SwitchSheet({ + required this.from, + required this.to, + required this.targetRunning, + }); + + final ServerProfile from; + final ServerProfile to; + final bool targetRunning; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final text = Theme.of(context).textTheme; + return AlertDialog( + title: Row( + children: [ + Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: hueForProfileId(to.id), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: kSpace8), + Expanded(child: Text('Switch to “${to.name}”?')), + ], + ), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'This window will reconnect to ${to.name}’s server.', + style: text.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + const SizedBox(height: kSpace16), + _Block( + label: 'What happens here', + tone: cs.error, + lines: [ + if (!targetRunning) '${to.name}’s server starts', + 'this window reloads — panes and scroll reset', + 'unsent composer drafts in this window are discarded', + ], + ), + const SizedBox(height: kSpace12), + _Block( + label: 'What keeps running', + tone: cs.primary, + lines: [ + '${from.name}’s server stays up', + '${from.name}’s agents are not interrupted', + 'devices paired to ${from.name} stay paired', + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text('Switch to ${to.name}'), + ), + ], + ); + } +} + +/// Asks whether to switch away from [victim] to [target] **and delete** [victim]. +/// +/// One sheet rather than two, because it is one intent. It has to carry both +/// consequences honestly: the window moves, and a profile's data is erased. The +/// “kept” line matters most — people read “delete” next to a path beside their +/// worktree as “deletes my branch”. +Future confirmSwitchAwayAndDelete( + BuildContext context, { + required ServerProfile victim, + required ServerProfile target, +}) async { + final result = await showDialog( + context: context, + builder: (context) { + final cs = Theme.of(context).colorScheme; + final text = Theme.of(context).textTheme; + return AlertDialog( + title: Text('Delete “${victim.name}”?'), + content: SizedBox( + width: 430, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '“${victim.name}” is the profile this window is using, so the ' + 'window will switch to “${target.name}” first.', + style: text.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + const SizedBox(height: kSpace16), + _Block( + label: 'What happens', + tone: cs.error, + lines: [ + 'this window switches to “${target.name}”', + '“${victim.name}” is stopped, then its server state is erased', + 'its sessions, transcripts, pairings and TLS identity go', + ], + ), + const SizedBox(height: kSpace12), + _Block( + label: 'What is kept', + tone: cs.primary, + lines: const [ + 'your worktrees and repos are never touched', + 'every other profile is unaffected', + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: cs.error), + onPressed: () => Navigator.of(context).pop(true), + child: Text('Switch & delete “${victim.name}”'), + ), + ], + ); + }, + ); + return result ?? false; +} + +class _Block extends StatelessWidget { + const _Block({required this.label, required this.tone, required this.lines}); + + final String label; + final Color tone; + final List lines; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final text = Theme.of(context).textTheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: text.labelSmall?.copyWith( + color: tone, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: kSpace6), + for (final line in lines) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '· ', + style: text.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + Expanded( + child: Text( + line, + style: text.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/desktop/settings/sections/profiles_format.dart b/app/lib/desktop/settings/sections/profiles_format.dart new file mode 100644 index 00000000..35073c56 --- /dev/null +++ b/app/lib/desktop/settings/sections/profiles_format.dart @@ -0,0 +1,22 @@ +/// Shared formatting helpers for the Profiles section. +library; + +/// Formats a byte count the way the mockup does: `4.4 MB`, `412 KB`, `938 B`. +/// +/// `null` (unmeasured) renders as an em dash rather than `0 B`, so the UI never +/// claims a profile is empty while its size is still being measured. +String formatProfileBytes(int? bytes) { + if (bytes == null) return '—'; + if (bytes < 1024) return '$bytes B'; + const units = ['KB', 'MB', 'GB', 'TB']; + var value = bytes / 1024; + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + final rounded = value >= 100 + ? value.toStringAsFixed(0) + : value.toStringAsFixed(1); + return '$rounded ${units[unit]}'; +} diff --git a/app/lib/desktop/settings/sections/profiles_providers.dart b/app/lib/desktop/settings/sections/profiles_providers.dart new file mode 100644 index 00000000..868266f9 --- /dev/null +++ b/app/lib/desktop/settings/sections/profiles_providers.dart @@ -0,0 +1,66 @@ +/// Riverpod wiring for the Profiles settings section (SPEC-50 D7/D8/D9). +/// +/// These three providers are the section's only dependencies. Each throws +/// [UnimplementedError] by default — following the `serverConfigProvider` / +/// `desktopControllerProvider` pattern — and is overridden in `runDesktopApp` +/// (with the process's real registry, home and active profile id) and in tests +/// (with fakes that touch no filesystem or daemon). +/// +/// The wiring MUST share **one** [ProfileRegistry] instance across all three: +/// [ProfileDeleter] removes the registry entry directly, and the section then +/// calls [ProfilesController.refresh] to repaint — which only reflects the +/// removal if both read the same registry. +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../daemon/profile_deleter.dart'; +import '../../daemon/profile_lifecycle.dart'; +import '../../daemon/profiles_controller.dart'; +import '../../daemon/server_profile.dart'; + +/// The observable Profiles controller (list, create, rename, forget, refresh). +final profilesControllerProvider = Provider( + (ref) => throw UnimplementedError('overridden in runDesktopApp / tests'), +); + +/// Starts and stops the daemon of an arbitrary profile (SPEC-50 D7). +final profileLifecycleProvider = Provider( + (ref) => throw UnimplementedError('overridden in runDesktopApp / tests'), +); + +/// Erases a profile across its four stores, or refuses with a reason (D8). +final profileDeleterProvider = Provider( + (ref) => throw UnimplementedError('overridden in runDesktopApp / tests'), +); + +/// Switches the window to another profile, verifying the target is reachable +/// before anything is torn down (SPEC-50 D10). +/// +/// The outcome of a profile switch: [switchFailure] is a reason the switch +/// itself failed (nothing changed), and [deleteFailure] is a reason an +/// *optional* follow-up delete of `deleteAfter` failed **after** the switch +/// already succeeded. They are separate facts so a caller never reports a +/// completed switch as a failure just because the delete could not finish. +typedef ProfileSwitchResult = ({String? switchFailure, String? deleteFailure}); + +/// Switches to `target`, optionally deleting `deleteAfter` once the switch has +/// landed. Overridden in `runDesktopApp`; tests supply a fake. +typedef ProfileSwitcher = + Future Function( + ServerProfile target, { + ServerProfile? deleteAfter, + }); + +/// The active [ProfileSwitcher], or `null` where switching is not wired. +/// +/// Nullable with a `null` default on purpose: the title-bar badge is mounted by +/// many surfaces (and by most widget tests) that have no profile wiring at all. +/// A throwing default would turn those into crashes; instead the badge degrades +/// to a plain, calm label — which is also the honest UI when there is nothing to +/// switch to. +final profileSwitcherProvider = Provider((ref) => null); + +/// The controller the title-bar switcher lists profiles from, or `null` where +/// profiles are not wired. Same reasoning as [profileSwitcherProvider]. +final switcherProfilesProvider = Provider((ref) => null); diff --git a/app/lib/desktop/settings/sections/profiles_section.dart b/app/lib/desktop/settings/sections/profiles_section.dart new file mode 100644 index 00000000..8ee6f217 --- /dev/null +++ b/app/lib/desktop/settings/sections/profiles_section.dart @@ -0,0 +1,793 @@ +/// The Profiles settings section (SPEC-50 D7/D8/D9, mockup cards 4 & 5). +/// +/// A list of every server profile with its live status, an inline detail view +/// per profile, and a "Stale — source folder is gone" group that only appears +/// when the registry holds orphaned dev profiles. Lifecycle (Start/Stop) and +/// deletion attach to a profile here, never to "the server" (D7): stopping the +/// server this window talks to is self-defeating, but stopping a profile you are +/// not using is an ordinary task. +/// +/// The section owns no state the model does not: it reads a [ProfilesController] +/// (itself a thin observable over [ProfileRegistry]) and repaints on its +/// notifications. Colour comes from [hueForProfileId] so a profile's hue is +/// identical here, in the badge and in the switcher. +library; + +import 'dart:io' show Platform, Process, ProcessException; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; + +import '../../../app/theme.dart'; +import '../../../status/status_event.dart'; +import '../../../status/status_providers.dart'; +import '../../chat/server_profile_badge.dart' show hueForProfileId; +import '../../daemon/profiles_controller.dart'; +import '../../daemon/server_profile.dart'; +import '../settings_item_anchor.dart'; +import 'profile_delete_sheet.dart'; +import 'profile_reclaim_sheet.dart'; +import 'profiles_format.dart'; +import 'profile_switch_sheet.dart'; +import 'profiles_providers.dart'; + +/// The Profiles section body. +class ProfilesSection extends ConsumerStatefulWidget { + /// Creates the Profiles section body. + const ProfilesSection({super.key}); + + @override + ConsumerState createState() => _ProfilesSectionState(); +} + +class _ProfilesSectionState extends ConsumerState { + /// The id of the profile whose inline detail is expanded, or null. + String? _expanded; + + @override + void initState() { + super.initState(); + // Populate running-state and disk-size for the rows once on mount; the + // controller notifies when the probes land. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(profilesControllerProvider).refresh(); + }); + } + + @override + Widget build(BuildContext context) { + final controller = ref.watch(profilesControllerProvider); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final stale = controller.staleSummary; + // Stale profiles are represented by the stale group below, so they are + // deliberately excluded here. Listing them in both places showed each + // one twice, and on a real machine (27 orphans measured) the dead + // profiles crowded the live ones off the screen. + // + // The one exception is the ACTIVE profile: if it is stale it must stay + // in the main list, because the reclaim group's deleter refuses the + // active profile — only the main row's switch-away-&-delete can remove + // it. `staleSummary` already omits the active profile to match. + final rows = controller.rows + .where( + (r) => !r.stale || r.profile.id == controller.activeProfileId, + ) + .toList(); + return ListView( + children: [ + const SettingsSectionHeaderLike(title: 'Profiles'), + // Anchors so a settings-search hit (profiles.list / .new / .delete) + // scrolls to and highlights the list rather than merely opening the + // section. Deletion has no standalone control — it lives in each + // row's menu — so `profiles.delete` reveals the list it acts on. + SettingsItemAnchor( + itemId: 'profiles.delete', + child: SettingsItemAnchor( + itemId: 'profiles.list', + child: _ProfilesGroup( + children: [ + for (final row in rows) + _ProfileRow( + status: row, + activeProfileId: controller.activeProfileId, + expanded: _expanded == row.profile.id, + onToggle: () => setState( + () => _expanded = _expanded == row.profile.id + ? null + : row.profile.id, + ), + ), + const SettingsItemAnchor( + itemId: 'profiles.new', + child: _NewProfileRow(), + ), + ], + ), + ), + ), + if (stale.rows.isNotEmpty) + SettingsItemAnchor( + itemId: 'profiles.reclaim', + child: _StaleGroup(rows: stale.rows, bytes: stale.bytes), + ), + ], + ); + }, + ); + } +} + +/// A local re-implementation of the section header idiom so the file needs no +/// dependency on the (concurrently edited) section_header.dart. Matches its +/// visual contract: UPPERCASE, accent-coloured, letter-spaced. +class SettingsSectionHeaderLike extends StatelessWidget { + /// Creates a header for [title]. + const SettingsSectionHeaderLike({required this.title, super.key}); + + /// The header text; rendered uppercase. + final String title; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + title.toUpperCase(), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ); + } +} + +/// A rounded, filled card boxing profile rows (the grouped-list idiom). +class _ProfilesGroup extends StatelessWidget { + const _ProfilesGroup({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(kRadius12), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const Divider(height: 1), + children[i], + ], + ], + ), + ), + ); + } +} + +/// One profile row (mockup card 4) with its inline detail (card 5). +class _ProfileRow extends ConsumerWidget { + const _ProfileRow({ + required this.status, + required this.activeProfileId, + required this.expanded, + required this.onToggle, + }); + + final ProfileStatus status; + final String activeProfileId; + final bool expanded; + final VoidCallback onToggle; + + bool get _isActive => status.profile.id == activeProfileId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final profile = status.profile; + final hue = hueForProfileId(profile.id); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + onTap: onToggle, + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: hue, shape: BoxShape.circle), + ), + title: Row( + children: [ + Flexible(child: Text(profile.name)), + if (_isActive) const _Pill(label: 'active'), + if (profile.kind == ProfileKind.dev) + const _Pill(label: 'dev build'), + ], + ), + subtitle: Text( + _tildeHome(profile.home), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatProfileBytes(status.diskBytes), + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: kSpace12), + Icon( + PhosphorIconsFill.circle, + size: 10, + color: status.running ? cs.primary : cs.outline, + ), + const SizedBox(width: kSpace6), + Text( + status.running ? 'Running' : 'Stopped', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.outline), + ), + _ProfileMenu( + status: status, + isActive: _isActive, + onDetail: onToggle, + ), + ], + ), + ), + if (expanded) ...[ + const Divider(height: 1), + _ProfileDetail(status: status, isActive: _isActive), + ], + ], + ); + } +} + +/// The overflow (⋯) menu. Delete is **absent** for a protected profile. For the +/// active profile it reads "Switch away & delete…" and runs that flow, because +/// [ProfileDeleter] refuses to delete the profile the window is using (D8). +class _ProfileMenu extends ConsumerWidget { + const _ProfileMenu({ + required this.status, + required this.isActive, + required this.onDetail, + }); + + final ProfileStatus status; + final bool isActive; + final VoidCallback onDetail; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profile = status.profile; + return PopupMenuButton( + tooltip: 'Profile actions', + icon: const Icon(PhosphorIconsLight.dotsThree, size: 20), + itemBuilder: (context) => [ + const PopupMenuItem(value: 'rename', child: Text('Rename…')), + // Omitted for the active profile: stopping the daemon this window is + // connected to would disconnect the UI (the file's own D7 contract), + // and starting it is meaningless — it is already running. + if (!isActive) + PopupMenuItem( + value: 'toggle', + child: Text(status.running ? 'Stop' : 'Start'), + ), + const PopupMenuItem(value: 'reveal', child: Text('Reveal in Finder')), + if (!profile.isProtected) + // The active profile is refused by ProfileDeleter by design (D8), so + // deleting it means switching away first. That is now one action + // rather than a dead menu item. + PopupMenuItem( + value: 'delete', + child: Text(isActive ? 'Switch away & delete…' : 'Delete…'), + ), + ], + onSelected: (value) => _onSelected(context, ref, value), + ); + } + + Future _onSelected( + BuildContext context, + WidgetRef ref, + String value, + ) async { + switch (value) { + case 'rename': + await promptRenameProfile(context, ref, status.profile); + case 'toggle': + await toggleProfileRunning(ref, status); + case 'reveal': + await revealInFinder(status.profile.home); + case 'delete': + if (!context.mounted) return; + // ProfileDeleter refuses the ACTIVE profile by design (D8), so deleting + // the one you are using means switching away first. Offering a menu item + // that could only ever fail would be worse than offering none. + if (isActive) { + await switchAwayAndDelete(context, ref, status.profile); + } else { + await showProfileDeleteSheet(context, ref, status); + } + } + } +} + +/// The inline detail (mockup card 5): editable name, status + Start/Stop, data +/// path with total size, origin for dev profiles, and a danger zone. +class _ProfileDetail extends ConsumerWidget { + const _ProfileDetail({required this.status, required this.isActive}); + + final ProfileStatus status; + final bool isActive; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final profile = status.profile; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: Icon( + PhosphorIconsFill.circle, + size: 12, + color: status.running ? cs.primary : cs.outline, + ), + title: Text(status.running ? 'Running' : 'Stopped'), + subtitle: Text( + 'port ${profile.port}', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + trailing: OutlinedButton.icon( + // Disabled for the active profile: you never stop the server this + // window talks to (D7), and it is already running. + onPressed: isActive + ? null + : () => toggleProfileRunning(ref, status), + icon: Icon( + status.running + ? PhosphorIconsLight.stop + : PhosphorIconsLight.play, + size: 18, + ), + label: Text(status.running ? 'Stop' : 'Start'), + ), + ), + const Divider(height: 1), + ListTile( + leading: Icon(PhosphorIconsLight.folder, color: cs.outline), + title: const Text('Data'), + subtitle: Text( + '${_tildeHome(profile.home)} · ${formatProfileBytes(status.diskBytes)}', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + trailing: TextButton( + onPressed: () => revealInFinder(profile.home), + child: const Text('Reveal'), + ), + ), + if (profile.kind == ProfileKind.dev && profile.origin != null) ...[ + const Divider(height: 1), + ListTile( + leading: Icon(PhosphorIconsLight.cube, color: cs.outline), + title: const Text('Created from'), + subtitle: Text( + _tildeHome(profile.origin!), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ], + if (!profile.isProtected) + _DangerZone(status: status, isActive: isActive), + ], + ); + } +} + +/// The delete row. Present only for a non-protected profile. For the active +/// profile the button reads “Switch away & delete…” and runs that flow (a +/// profile cannot be deleted from under itself), matching the overflow menu. +class _DangerZone extends ConsumerWidget { + const _DangerZone({required this.status, required this.isActive}); + + final ProfileStatus status; + final bool isActive; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final deleteButton = OutlinedButton( + style: OutlinedButton.styleFrom(foregroundColor: cs.error), + onPressed: isActive + ? () => switchAwayAndDelete(context, ref, status.profile) + : () => showProfileDeleteSheet(context, ref, status), + child: Text(isActive ? 'Switch away & delete…' : 'Delete…'), + ); + return Container( + color: cs.errorContainer.withValues(alpha: 0.25), + child: ListTile( + leading: Icon(PhosphorIconsLight.trash, color: cs.error), + title: Text('Delete profile', style: TextStyle(color: cs.error)), + subtitle: const Text( + 'Removes this profile’s server state. Your code is untouched.', + ), + trailing: isActive + ? Tooltip( + message: + 'Deleting the active profile switches away from it first, ' + 'because a profile cannot be deleted from under itself.', + child: deleteButton, + ) + : deleteButton, + ), + ); + } +} + +/// A small pill (active / dev build), matching the badge idiom. +class _Pill extends StatelessWidget { + const _Pill({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.only(left: kSpace6), + padding: const EdgeInsets.symmetric(horizontal: kSpace6, vertical: 1), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(kRadius6), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelXs?.copyWith(color: cs.outline), + ), + ); + } +} + +/// The "New profile" action row: prompts for a name and calls `create`. +class _NewProfileRow extends ConsumerWidget { + const _NewProfileRow(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + return ListTile( + leading: Icon(PhosphorIconsLight.plus, color: cs.primary), + title: Text('New profile', style: TextStyle(color: cs.primary)), + onTap: () => promptCreateProfile(context, ref), + ); + } +} + +/// The stale-profile group (mockup card 4, lower). Only rendered when something +/// is stale; the count is the headline, the total size the subtext. +class _StaleGroup extends ConsumerWidget { + const _StaleGroup({required this.rows, required this.bytes}); + + final List rows; + final int bytes; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final count = rows.length; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SettingsSectionHeaderLike(title: 'Stale — source folder is gone'), + _ProfilesGroup( + children: [ + ListTile( + leading: Icon(PhosphorIconsLight.warning, color: cs.error), + title: Text( + '$count orphaned dev ${count == 1 ? 'profile' : 'profiles'}', + ), + subtitle: Text( + 'Their worktrees no longer exist. ${formatProfileBytes(bytes)}.', + ), + trailing: OutlinedButton( + onPressed: () => showProfileReclaimSheet(context, ref, rows), + child: const Text('Review…'), + ), + ), + ], + ), + ], + ); + } +} + +/// Prompts for a new profile name and creates it, reporting the outcome. +Future promptCreateProfile(BuildContext context, WidgetRef ref) async { + final statusCenter = ref.status; + final controller = ref.read(profilesControllerProvider); + final name = await _promptForName(context, title: 'New profile'); + if (name == null) return; + // `create` persists, so it can throw on an unwritable registry (or a failed + // port allocation). Without this the promise to report an outcome is broken: + // the user sees nothing and the error escapes the async gap. + ServerProfile? created; + try { + created = await controller.create(name); + } catch (error) { + statusCenter.failure( + 'Could not create profile', + source: StatusSources.settings, + detail: error.toString(), + ); + return; + } + if (created == null) { + statusCenter.failure( + 'Could not create profile', + source: StatusSources.settings, + detail: 'A profile needs a non-blank name.', + ); + } else { + statusCenter.success( + 'Created ${created.name}', + source: StatusSources.settings, + ); + } +} + +/// Prompts for a new name and renames [profile], reporting the outcome. +Future promptRenameProfile( + BuildContext context, + WidgetRef ref, + ServerProfile profile, +) async { + final statusCenter = ref.status; + final controller = ref.read(profilesControllerProvider); + final name = await _promptForName( + context, + title: 'Rename profile', + initial: profile.name, + ); + if (name == null) return; + // `rename` mutates the registry and then persists. If the save throws (an + // unwritable registry), the in-memory list already carries the new name, so + // without this the row would show the rename for the rest of the session while + // the change was silently lost on restart. Report it and refresh so the row + // reverts to what is actually persisted. + try { + if (!controller.rename(profile.id, name)) { + statusCenter.failure( + 'Could not rename profile', + source: StatusSources.settings, + detail: 'A profile needs a non-blank name.', + ); + } + } catch (error) { + // The registry's in-memory list already carries the new name, so revert it + // (without saving) and repaint: the row must show what is actually persisted, + // not a rename that failed to reach disk. + controller.registry.rename(profile.id, profile.name); + controller.notifyRegistryChanged(); + statusCenter.failure( + 'Could not rename profile', + source: StatusSources.settings, + detail: error.toString(), + ); + } +} + +/// Starts or stops [status]'s daemon and reports the outcome, updating the row. +Future toggleProfileRunning(WidgetRef ref, ProfileStatus status) async { + final statusCenter = ref.status; + final controller = ref.read(profilesControllerProvider); + final lifecycle = ref.read(profileLifecycleProvider); + final profile = status.profile; + final result = status.running + ? await lifecycle.stop(profile) + : await lifecycle.start(profile); + if (result.ok) { + controller.noteRunning(profile.id, running: !status.running); + statusCenter.success( + status.running ? 'Stopped ${profile.name}' : 'Started ${profile.name}', + source: StatusSources.settings, + ); + } else { + statusCenter.failure( + status.running + ? 'Could not stop ${profile.name}' + : 'Could not start ${profile.name}', + source: StatusSources.settings, + detail: result.message, + ); + } +} + +/// Opens [home] in the macOS Finder, best effort. A no-op off macOS, and it +/// swallows a spawn failure rather than surfacing an error for a convenience. +Future revealInFinder(String home) async { + if (!Platform.isMacOS) return; + try { + await Process.run('open', [home]); + } on ProcessException { + // Best effort: revealing a folder is not worth an error banner. + } +} + +/// A minimal single-field name prompt. Returns the trimmed name, or null on +/// cancel / blank. +Future _promptForName( + BuildContext context, { + required String title, + String? initial, +}) async { + final result = await showDialog( + context: context, + builder: (context) => _NamePromptDialog(title: title, initial: initial), + ); + if (result == null || result.isEmpty) return null; + return result; +} + +/// The name prompt body. Owns its [TextEditingController] so it is disposed only +/// after the dialog's exit animation, never while the field is still rebuilding. +class _NamePromptDialog extends StatefulWidget { + const _NamePromptDialog({required this.title, this.initial}); + + final String title; + final String? initial; + + @override + State<_NamePromptDialog> createState() => _NamePromptDialogState(); +} + +class _NamePromptDialogState extends State<_NamePromptDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initial ?? '', + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() => Navigator.of(context).pop(_controller.text.trim()); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + isDense: true, + ), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('Save')), + ], + ); + } +} + +/// Abbreviates a leading `$HOME` to `~`, matching the mockup's `~/.makit`. +String _tildeHome(String path) { + final home = Platform.environment['HOME']; + if (home != null && home.isNotEmpty && path.startsWith(home)) { + return '~${path.substring(home.length)}'; + } + return path; +} + +/// Switches away from [victim] and then deletes it. +/// +/// `ProfileDeleter` refuses the active profile (SPEC-50 D8) — a profile cannot be +/// deleted from under the window using it — so this picks another profile, hands +/// the window over, and lets the host delete the old one from the *new* runtime, +/// where it is no longer active. Both steps are confirmed: the switch sheet +/// first, then the delete sheet's own consequences are folded into it, because +/// two modal sheets in a row for one intent is worse UX than one honest one. +Future switchAwayAndDelete( + BuildContext context, + WidgetRef ref, + ServerProfile victim, +) async { + final controller = ref.read(profilesControllerProvider); + final switcher = ref.read(profileSwitcherProvider); + final status = ref.status; + + if (switcher == null) { + status.failure( + 'Cannot delete the active profile', + source: StatusSources.settings, + detail: 'Profile switching is not available on this surface.', + ); + return; + } + + // Prefer the protected/installed profile as the place to land: it always + // exists and can never itself be deleted. + final candidates = + controller.rows + .where((r) => r.profile.id != victim.id && !r.stale) + .toList() + // A total order: protected profiles first, then stable by name. A + // comparator returning -1 for `isProtected` on both sides breaks the + // contract and leaves the order unspecified. + ..sort((a, b) { + final byProtected = (a.profile.isProtected ? 0 : 1).compareTo( + b.profile.isProtected ? 0 : 1, + ); + if (byProtected != 0) return byProtected; + return a.profile.name.toLowerCase().compareTo( + b.profile.name.toLowerCase(), + ); + }); + if (candidates.isEmpty) { + status.failure( + 'Cannot delete the only profile', + source: StatusSources.settings, + detail: 'Create another profile first, then switch to it.', + ); + return; + } + final target = candidates.first.profile; + + final ok = await confirmSwitchAwayAndDelete( + context, + victim: victim, + target: target, + ); + if (!ok) return; + + final result = await switcher(target, deleteAfter: victim); + if (result.switchFailure != null) { + // The switch itself failed, so nothing changed and the victim is untouched. + status.failure( + 'Could not switch to ${target.name}', + source: StatusSources.settings, + detail: result.switchFailure, + ); + } else if (result.deleteFailure != null) { + // The switch succeeded; only the delete failed — report that honestly rather + // than claiming the whole operation failed. + status.failure( + 'Switched to ${target.name}, but could not delete ${victim.name}', + source: StatusSources.settings, + detail: result.deleteFailure, + ); + } else { + status.success( + 'Deleted ${victim.name} and switched to ${target.name}', + source: StatusSources.settings, + ); + } +} diff --git a/app/lib/desktop/settings/sections/server_devices_section.dart b/app/lib/desktop/settings/sections/server_devices_section.dart index ac2488e9..3cf9b161 100644 --- a/app/lib/desktop/settings/sections/server_devices_section.dart +++ b/app/lib/desktop/settings/sections/server_devices_section.dart @@ -1,9 +1,10 @@ -/// Server & Devices section body (SPEC-13 migration map). +/// Server & Devices section body (SPEC-13 migration map, SPEC-50 D5/D6). /// -/// Consolidates the desktop control surfaces — server endpoint, daemon -/// lifecycle, CLI, paired devices, pairing QR, running sessions, and the TLS -/// fingerprint — into one immediate-effect settings section. Existing widgets -/// and providers are reused (embedded), not rewritten. +/// The Server group is exactly four rows: the active profile, the one +/// reachability question, a pair-a-phone row, and a single collapsed +/// Diagnostics disclosure holding everything that is not a decision (lifecycle, +/// CLI, TLS fingerprint, log path, and an Advanced escape hatch). Existing +/// widgets and providers are reused (embedded), not rewritten. library; import 'dart:async'; @@ -14,23 +15,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; import '../../../app/theme.dart'; +import '../../../status/status_event.dart'; +import '../../../status/status_providers.dart'; import '../../../store/connection.dart' show connectionControllerProvider, connectionProvider; +import '../../daemon/daemon_lifecycle.dart' show DaemonActionResult; import '../../desktop_app.dart' - show desktopControllerProvider, makitInstallCommand; + show desktopControllerProvider, makitInstallCommand, serverProfileProvider; import '../../screens/devices_screen.dart'; -import '../../screens/providers.dart' - show bundledCliPathProvider, cliInstallerProvider; import '../../screens/qr_screen.dart'; import '../../screens/sessions_screen.dart'; import '../../tray/tray_controller.dart' show DaemonState; -import '../../../status/status_event.dart'; -import '../../../status/status_providers.dart'; import '../server_config.dart'; import '../settings_item_anchor.dart'; import 'section_header.dart'; import 'settings_group.dart'; -import 'settings_reset_button.dart'; /// Lowest valid TCP port (inclusive). const int _kMinPort = 1; @@ -38,39 +37,100 @@ const int _kMinPort = 1; /// Highest valid TCP port (inclusive). const int _kMaxPort = 65535; +/// Restarts the daemon so a committed config change takes effect, reporting a +/// non-`ok` outcome through the status center (the immediate-effect contract +/// that replaced the old "Save & restart server" two-phase commit). +Future _restartAndReport(WidgetRef ref) async { + // Resolved before the await: `ref` throws once the widget is unmounted, and + // the record must outlive the thing reporting to it. + final status = ref.status; + final result = await ref.read(desktopControllerProvider).restart(); + if (!result.ok) { + status.failure( + 'Could not restart the server', + source: StatusSources.settings, + detail: result.message, + ); + } +} + /// Server & Devices section body: the single home for endpoint config, daemon /// lifecycle, the CLI, device pairing/management, sessions, and TLS trust. -class ServerDevicesSection extends StatefulWidget { +class ServerDevicesSection extends ConsumerStatefulWidget { /// Creates the Server & Devices section body. const ServerDevicesSection({super.key}); @override - State createState() => _ServerDevicesSectionState(); + ConsumerState createState() => + _ServerDevicesSectionState(); } -class _ServerDevicesSectionState extends State { +class _ServerDevicesSectionState extends ConsumerState { /// Id of the single expanded disclosure row, or null when all are collapsed. /// Kept here (not per-row) so the rows behave as an accordion. String? _openRow; + /// The deep-link target this section has already auto-expanded for, so a + /// rebuild while still targeted does not fight the user re-collapsing the row. + String? _autoExpandedForTarget; + void _toggle(String id) => setState(() => _openRow = _openRow == id ? null : id); + /// The disclosure row that must be open for [target]'s anchor to exist in the + /// tree, or null when the target lives at the top level. `_ExpandableRow` + /// mounts its child only when expanded, so a deep-link to a Diagnostics row + /// would otherwise never resolve. + static String? _disclosureOwning(String? target) => switch (target) { + 'server_devices.lifecycle' || + 'server_devices.cli' || + 'server_devices.fingerprint' => 'diagnostics', + _ => null, + }; + @override Widget build(BuildContext context) { + final target = ref.watch(settingsTargetItemProvider); + if (target != _autoExpandedForTarget) { + _autoExpandedForTarget = target; + final owner = _disclosureOwning(target); + if (owner != null && _openRow != owner) { + // Defer past this build: the anchor's own reveal runs in a post-frame + // callback, so the row must be mounted by then. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _openRow = owner); + }); + } + } return ListView( children: [ const SettingsSectionHeader(title: 'Server & Devices'), const SettingsSectionHeader(title: 'Server'), - const SettingsGroup( + SettingsGroup( children: [ - SettingsItemAnchor( + const _ActiveProfileRow(), + const SettingsItemAnchor( itemId: 'server_devices.endpoint', - child: _EndpointRow(), + child: _ReachabilityRow(), + ), + _ExpandableRow( + icon: PhosphorIconsLight.qrCode, + title: 'Pair a phone', + help: 'Show a QR code to pair a new device over Tailscale.', + expanded: _openRow == 'pair_server', + onToggle: () => _toggle('pair_server'), + child: const QrScreen(), + ), + _ExpandableRow( + icon: PhosphorIconsLight.wrench, + title: 'Diagnostics', + help: + 'Lifecycle, CLI, TLS fingerprint, log path and advanced ' + 'bind options.', + expanded: _openRow == 'diagnostics', + onToggle: () => _toggle('diagnostics'), + child: const _Diagnostics(), ), - _LifecycleRow(), - _CliRow(), - _FingerprintRow(), ], ), const SettingsSectionHeader(title: 'Devices'), @@ -114,184 +174,209 @@ class _ServerDevicesSectionState extends State { } } -/// Endpoint: how the local daemon binds (bind mode + optional custom host) and -/// on which port. Applies on commit; a "Save & restart server" action pushes -/// changes to a running daemon. The desktop app's own client always connects -/// over loopback, so these settings only affect reachability from other -/// devices (e.g. a paired phone). -class _EndpointRow extends ConsumerStatefulWidget { - const _EndpointRow(); - - @override - ConsumerState<_EndpointRow> createState() => _EndpointRowState(); -} - -class _EndpointRowState extends ConsumerState<_EndpointRow> { - late final TextEditingController _customHost; - late final TextEditingController _port; - String? _portError; +/// Active-profile row: the profile this window runs against, with a live +/// running/stopped dot. There is deliberately no switcher here (the badge owns +/// that) and no Stop (you never stop the server you are talking to — D7). +class _ActiveProfileRow extends ConsumerWidget { + const _ActiveProfileRow(); @override - void initState() { - super.initState(); - final cfg = ref.read(serverConfigProvider); - _customHost = TextEditingController(text: cfg.customHost); - _port = TextEditingController(text: '${cfg.port}'); + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final profile = ref.watch(serverProfileProvider); + final controller = ref.watch(desktopControllerProvider); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final running = controller.summary.state == DaemonState.running; + return ListTile( + leading: Icon(PhosphorIconsLight.cube, color: cs.outline), + title: Text(profile.name), + subtitle: const Text( + 'Projects, agents, devices and sessions are separate per profile.', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + PhosphorIconsFill.circle, + size: 10, + color: running ? cs.primary : cs.outline, + ), + const SizedBox(width: kSpace8), + Text( + running ? 'Running' : 'Stopped', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.outline), + ), + ], + ), + ); + }, + ); } +} - @override - void dispose() { - _customHost.dispose(); - _port.dispose(); - super.dispose(); - } +/// The one reachability question (SPEC-50 D5): two radios that each state their +/// *consequence*, an "allow plain Wi-Fi" fallback checkbox nested under "My +/// devices", and a read-only current-address line. Committing a change applies +/// immediately and restarts the daemon. +class _ReachabilityRow extends ConsumerWidget { + const _ReachabilityRow(); - /// Validates and applies the port. Rejects out-of-range values (keeping the - /// stored config unchanged) and surfaces an inline error. - void _applyPort(String value) { - final port = int.tryParse(value.trim()); - if (port == null || port < _kMinPort || port > _kMaxPort) { - setState(() { - _portError = 'Port must be a number between $_kMinPort and $_kMaxPort.'; - }); - return; - } - setState(() => _portError = null); - unawaited(ref.read(serverConfigProvider.notifier).setPort(port)); + Future _setReachability(WidgetRef ref, Reachability value) async { + await ref.read(serverConfigProvider.notifier).setReachability(value); + await _restartAndReport(ref); } - Future _restart() async { - final notifier = ref.read(serverConfigProvider.notifier); - if (ref.read(serverConfigProvider).bindMode == ServerBindMode.custom) { - unawaited(notifier.setCustomHost(_customHost.text)); - } - _applyPort(_port.text); - if (_portError != null) return; - await ref.read(desktopControllerProvider).restart(); + Future _setFallback(WidgetRef ref, bool allow) async { + await ref.read(serverConfigProvider.notifier).setAllowLanFallback(allow); + await _restartAndReport(ref); } - void _reset() { - _customHost.text = ''; - _port.text = '$kDefaultServerPort'; - setState(() => _portError = null); - final notifier = ref.read(serverConfigProvider.notifier); - unawaited(notifier.setBindMode(ServerBindMode.auto)); - unawaited(notifier.setCustomHost('')); - unawaited(notifier.setPort(kDefaultServerPort)); + /// The current bind address, read-only. The detected-address dropdown is + /// deferred (SPEC-50 "does not do"); this renders the *effective* bind the + /// daemon is configured for. + /// + /// Deliberately derived from [ServerConfig], not from the client connection: + /// the desktop app always talks to its own daemon over loopback, so + /// `connectionProvider.server.host` is `127.0.0.1` whenever connected — which + /// would misreport a server reachable over Tailscale/LAN as loopback exactly + /// when it is running. + String _currentAddress(ServerConfig cfg) { + final host = cfg.customHost.trim(); + if (host.isNotEmpty) return host; + return switch (cfg.reachability) { + Reachability.thisMacOnly => '127.0.0.1', + Reachability.myDevices => 'Your devices via Tailscale', + }; } - static String _modeSubtitle(ServerBindMode mode) => switch (mode) { - ServerBindMode.auto => - 'Auto: Tailscale if available, else loopback. Reachable by your other ' - 'devices over Tailscale.', - ServerBindMode.lan => - 'LAN: allow access over the local network when Tailscale is off. ' - 'Tailscale still takes precedence when available; use Custom to force ' - 'a specific host. Only use on trusted Wi-Fi.', - ServerBindMode.loopback => - 'Loopback: this Mac only — not reachable from other devices.', - ServerBindMode.custom => - 'Custom: bind an explicit host (e.g. 0.0.0.0 for every interface).', - }; - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final cfg = ref.watch(serverConfigProvider); - final modified = - cfg.bindMode != ServerBindMode.auto || - cfg.port != kDefaultServerPort || - cfg.customHost.isNotEmpty; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - title: const Text('Endpoint'), - subtitle: Text(_modeSubtitle(cfg.bindMode)), - trailing: modified - ? SettingsResetButton(visible: true, onPressed: _reset) - : null, - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: SegmentedButton( - segments: const [ - ButtonSegment(value: ServerBindMode.auto, label: Text('Auto')), - ButtonSegment(value: ServerBindMode.lan, label: Text('LAN')), - ButtonSegment( - value: ServerBindMode.loopback, - label: Text('Loopback'), - ), - ButtonSegment( - value: ServerBindMode.custom, - label: Text('Custom'), - ), - ], - selected: {cfg.bindMode}, - showSelectedIcon: false, - onSelectionChanged: (s) => unawaited( - ref.read(serverConfigProvider.notifier).setBindMode(s.first), + // Transparent Material so the nested RadioListTiles paint their ink on it + // rather than on the deep-link highlight's tinted DecoratedBox (which would + // trip ListTile's "background may be invisible" assertion). + return Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + 'Who can reach this server?', + style: TextStyle(fontWeight: FontWeight.w600), ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (cfg.bindMode == ServerBindMode.custom) ...[ - Expanded( - child: TextField( - controller: _customHost, - decoration: const InputDecoration( - labelText: 'Host', - hintText: '0.0.0.0', - border: OutlineInputBorder(), - isDense: true, - ), - onSubmitted: (v) => unawaited( - ref.read(serverConfigProvider.notifier).setCustomHost(v), - ), + RadioGroup( + groupValue: cfg.reachability, + onChanged: (v) => + unawaited(_setReachability(ref, v ?? cfg.reachability)), + child: const Column( + children: [ + RadioListTile( + value: Reachability.thisMacOnly, + title: Text('Just this Mac'), + subtitle: Text('Nothing else can connect.'), + ), + RadioListTile( + value: Reachability.myDevices, + title: Text('My devices'), + subtitle: Text( + 'Reachable from your phone over Tailscale. ' + 'No account needed.', ), ), - const SizedBox(width: kSpace12), ], - SizedBox( - width: 140, - child: TextField( - controller: _port, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: 'Port', - hintText: '$kDefaultServerPort', - border: const OutlineInputBorder(), - isDense: true, - errorText: _portError, + ), + ), + if (cfg.reachability == Reachability.myDevices) + Padding( + padding: const EdgeInsets.fromLTRB(56, 0, 24, 4), + child: Row( + children: [ + Checkbox( + value: cfg.allowLanFallback, + onChanged: (v) => unawaited(_setFallback(ref, v ?? false)), ), - onSubmitted: _applyPort, - ), + const Expanded( + child: Text('Also allow plain Wi-Fi when Tailscale is off'), + ), + ], ), - ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 12), + child: Row( + children: [ + Text( + 'Address', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.outline), + ), + const SizedBox(width: kSpace12), + Expanded( + child: Text( + _currentAddress(cfg), + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), ), + ], + ), + ); + } +} + +/// The Diagnostics disclosure body: everything that is not a decision, one +/// read-only/advanced block. Each moved row keeps its retired +/// [SettingsItemAnchor] id so deep links and settings search still resolve. +class _Diagnostics extends ConsumerWidget { + const _Diagnostics(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; + final profile = ref.watch(serverProfileProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SettingsItemAnchor( + itemId: 'server_devices.lifecycle', + child: _LifecycleRow(), ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: OutlinedButton.icon( - onPressed: _restart, - icon: const Icon(PhosphorIconsLight.arrowClockwise, size: 18), - label: const Text('Save & restart server'), - ), + const Divider(height: 1), + const SettingsItemAnchor( + itemId: 'server_devices.cli', + child: _CliRow(), + ), + const Divider(height: 1), + const SettingsItemAnchor( + itemId: 'server_devices.fingerprint', + child: _FingerprintRow(), ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'A running server keeps its current settings until restarted.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.outline), + const Divider(height: 1), + ListTile( + leading: Icon(PhosphorIconsLight.fileText, color: cs.outline), + title: const Text('Log'), + subtitle: Text( + '${profile.home}/makit.log', + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), ), ), + const Divider(height: 1), + const _AdvancedRow(), ], ); } @@ -302,6 +387,27 @@ class _EndpointRowState extends ConsumerState<_EndpointRow> { class _LifecycleRow extends ConsumerWidget { const _LifecycleRow(); + /// Runs a lifecycle [action] and reports a non-`ok` outcome. + /// + /// These results were previously discarded, so a daemon that refused to start + /// -- most often because its port is already held by another build -- failed + /// with no feedback whatsoever. + static Future _report( + WidgetRef ref, + String title, + Future Function() action, + ) async { + // Captured before the await: `ref` throws once its widget is unmounted. + final status = ref.status; + final result = await action(); + if (result.ok) return; + status.failure( + title, + source: StatusSources.settings, + detail: result.message, + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; @@ -329,19 +435,35 @@ class _LifecycleRow extends ConsumerWidget { children: [ if (!running) FilledButton.icon( - onPressed: starting ? null : () => controller.start(), + onPressed: starting + ? null + : () => unawaited( + _report( + ref, + 'Could not start the server', + controller.start, + ), + ), icon: const Icon(PhosphorIconsLight.play, size: 18), label: const Text('Start'), ), if (running) ...[ OutlinedButton.icon( - onPressed: () => controller.restart(), + onPressed: () => unawaited( + _report( + ref, + 'Could not restart the server', + controller.restart, + ), + ), icon: const Icon(PhosphorIconsLight.arrowClockwise, size: 18), label: const Text('Restart'), ), const SizedBox(width: kSpace8), OutlinedButton.icon( - onPressed: () => controller.stop(), + onPressed: () => unawaited( + _report(ref, 'Could not stop the server', controller.stop), + ), icon: const Icon(PhosphorIconsLight.stop, size: 18), label: const Text('Stop'), ), @@ -355,7 +477,9 @@ class _LifecycleRow extends ConsumerWidget { } /// CLI: shows the resolved `makit` path (or a missing state) and offers a -/// "Copy install command" action (reuses [makitInstallCommand]). +/// "Copy install command" action (reuses [makitInstallCommand]). The one-time +/// `Install CLI` action lives in General; here the CLI is read-only diagnostics +/// plus an optional path override. class _CliRow extends ConsumerStatefulWidget { const _CliRow(); @@ -405,34 +529,6 @@ class _CliRowState extends ConsumerState<_CliRow> { ); } - /// One-click install of the app-bundled CLI into `~/.local/bin/makit`, - /// then re-resolve so the CLI row reflects the newly installed binary. - Future _installCli() async { - // Resolved before the first await: `ref` throws once its widget is - // unmounted, and the record must survive the thing that reported to it. - final status = ref.status; - final result = await ref.read(cliInstallerProvider).install(); - if (result.ok) { - status.success( - 'Installed makit CLI to ${result.installedPath}', - source: StatusSources.settings, - detail: - 'If your terminal can’t find `makit`, add ~/.local/bin to your PATH.', - ); - } else { - status.failure( - 'Could not install the makit CLI', - source: StatusSources.settings, - detail: result.error, - ); - } - if (result.ok && mounted) { - setState(() { - _resolved = _refreshResolved(); - }); - } - } - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -444,8 +540,8 @@ class _CliRowState extends ConsumerState<_CliRow> { final subtitle = resolving ? 'Locating the makit CLI…' : (path ?? - 'The makit CLI was not found. Install it to control ' - 'the server from here.'); + 'The makit CLI was not found. Install it from General to ' + 'control the server from here.'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -462,15 +558,6 @@ class _CliRowState extends ConsumerState<_CliRow> { onPressed: _copyInstallCommand, ), ), - if (ref.read(bundledCliPathProvider) != null) - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: OutlinedButton.icon( - onPressed: () => unawaited(_installCli()), - icon: const Icon(PhosphorIconsLight.downloadSimple, size: 18), - label: const Text('Install CLI'), - ), - ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: TextField( @@ -535,6 +622,97 @@ class _FingerprintRow extends ConsumerWidget { : '${fingerprint.substring(0, 24)}…'; } +/// Advanced (Diagnostics → Advanced): the custom-host escape hatch and the +/// port. Committing either applies immediately and restarts the daemon. Kept +/// collapsed so it is not the fourth thing a new user reads. +class _AdvancedRow extends ConsumerStatefulWidget { + const _AdvancedRow(); + + @override + ConsumerState<_AdvancedRow> createState() => _AdvancedRowState(); +} + +class _AdvancedRowState extends ConsumerState<_AdvancedRow> { + late final TextEditingController _customHost; + late final TextEditingController _port; + String? _portError; + + @override + void initState() { + super.initState(); + final cfg = ref.read(serverConfigProvider); + _customHost = TextEditingController(text: cfg.customHost); + _port = TextEditingController(text: '${cfg.port}'); + } + + @override + void dispose() { + _customHost.dispose(); + _port.dispose(); + super.dispose(); + } + + Future _applyHost(String value) async { + await ref.read(serverConfigProvider.notifier).setCustomHost(value); + await _restartAndReport(ref); + } + + /// Validates and applies the port. Rejects out-of-range values (keeping the + /// stored config unchanged) and surfaces an inline error. + Future _applyPort(String value) async { + final port = int.tryParse(value.trim()); + if (port == null || port < _kMinPort || port > _kMaxPort) { + setState(() { + _portError = 'Port must be a number between $_kMinPort and $_kMaxPort.'; + }); + return; + } + setState(() => _portError = null); + await ref.read(serverConfigProvider.notifier).setPort(port); + await _restartAndReport(ref); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ExpansionTile( + leading: Icon(PhosphorIconsLight.slidersHorizontal, color: cs.outline), + title: const Text('Advanced'), + subtitle: const Text('Custom bind host and port.'), + childrenPadding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + children: [ + TextField( + controller: _customHost, + decoration: const InputDecoration( + labelText: 'Host', + hintText: '0.0.0.0', + helperText: 'Overrides the reachability choice above.', + border: OutlineInputBorder(), + isDense: true, + ), + onSubmitted: (v) => unawaited(_applyHost(v)), + ), + const SizedBox(height: kSpace12), + SizedBox( + width: 160, + child: TextField( + controller: _port, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Port', + hintText: '$kDefaultServerPort', + border: const OutlineInputBorder(), + isDense: true, + errorText: _portError, + ), + onSubmitted: (v) => unawaited(_applyPort(v)), + ), + ), + ], + ); + } +} + /// A settings row that discloses [child] *inline* when tapped, rather than /// pushing a new page. The toggle is instant (no expand animation) by design. /// It is *controlled* — [expanded]/[onToggle] are owned by the section so the diff --git a/app/lib/desktop/settings/server_config.dart b/app/lib/desktop/settings/server_config.dart index 414f535c..2c4d93c4 100644 --- a/app/lib/desktop/settings/server_config.dart +++ b/app/lib/desktop/settings/server_config.dart @@ -5,53 +5,54 @@ /// drives it (see [MakitCliResolver]), and the loopback endpoint its own chat /// client self-pairs against (always `127.0.0.1`, see [LoopbackPairing]). /// -/// Bind behaviour is expressed as a [ServerBindMode] that mirrors the server's -/// own secure-by-default decision (`chooseBindHost` in `server/src/pairing`): +/// Reachability is one question with two answers plus a fallback (SPEC-50 D5): /// -/// - [ServerBindMode.auto] (default) → let the server decide: Tailscale IP if -/// online, else loopback. Other devices can reach it over Tailscale. -/// - [ServerBindMode.lan] → expose on the local network (`--lan`). -/// - [ServerBindMode.loopback] → `127.0.0.1` only; unreachable from other +/// - [Reachability.myDevices] (default) → let the server run its secure-default +/// decision (`chooseBindHost` in `server/src/pairing`): Tailscale IP if +/// online, else loopback. Other devices reach it over Tailscale. +/// - [Reachability.thisMacOnly] → `127.0.0.1` only; unreachable from other /// devices. -/// - [ServerBindMode.custom] → an explicit host (escape hatch, e.g. `0.0.0.0`). +/// - [allowLanFallback] → adds `--lan`, a documented *fallback* for when +/// Tailscale is down. It is a preference, not a mode: the server still +/// prefers Tailscale when it is up. +/// - [ServerConfig.customHost] → an explicit host that bypasses the decision +/// entirely (escape hatch, e.g. `0.0.0.0`); lives behind Diagnostics → +/// Advanced. library; import 'package:flutter_riverpod/legacy.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import '../../store/prefs/profile_scoped_prefs.dart'; /// The default bind port. Matches the server's own default (`serve.ts`). const int kDefaultServerPort = 7777; -const String _kBindModeKey = 'desktop_server_bind_mode'; +const String _kReachabilityKey = 'desktop_server_reachability'; +const String _kAllowLanFallbackKey = 'desktop_server_allow_lan_fallback'; const String _kCustomHostKey = 'desktop_server_custom_host'; const String _kPortKey = 'desktop_server_port'; const String _kCliPathKey = 'desktop_server_cli_path'; +/// Legacy key: the pre-SPEC-50 four-way bind mode. Migrated on load. +const String _kLegacyBindModeKey = 'desktop_server_bind_mode'; + /// Legacy key: the pre-unification single host string. Migrated on load. const String _kLegacyHostKey = 'desktop_server_host'; -/// How the local daemon should choose its bind host. -enum ServerBindMode { - /// Server decides: Tailscale if online, else loopback (secure default). - auto, - - /// Expose on the local network (`--lan`). Note the server still prefers - /// Tailscale when it is up (secure default); use [custom] to force a host. - lan, +/// Who can reach this server — one decision, two answers (SPEC-50 D5). +enum Reachability { + /// Loopback only (`127.0.0.1`) — nothing else can connect. + thisMacOnly, - /// Loopback only (`127.0.0.1`) — not reachable from other devices. - loopback, - - /// An explicit host supplied via [ServerConfig.customHost]. - custom, + /// Reachable from the user's other devices (Tailscale, secure default), with + /// [ServerConfig.allowLanFallback] adding plain-Wi-Fi fallback. + myDevices, } -/// Parses a persisted bind-mode string, defaulting to [ServerBindMode.auto]. -ServerBindMode _parseBindMode(String? value) => switch (value) { - 'lan' => ServerBindMode.lan, - 'loopback' => ServerBindMode.loopback, - 'custom' => ServerBindMode.custom, - _ => ServerBindMode.auto, +/// Parses a persisted reachability string, defaulting to [Reachability.myDevices]. +Reachability _parseReachability(String? value) => switch (value) { + 'thisMacOnly' => Reachability.thisMacOnly, + _ => Reachability.myDevices, }; bool _isLoopbackHost(String host) { @@ -63,16 +64,21 @@ bool _isLoopbackHost(String host) { class ServerConfig { /// Creates a config. const ServerConfig({ - this.bindMode = ServerBindMode.auto, + this.reachability = Reachability.myDevices, + this.allowLanFallback = false, this.customHost = '', this.port = kDefaultServerPort, this.cliPath = '', }); - /// How the daemon chooses its bind host. - final ServerBindMode bindMode; + /// Who can reach this server. + final Reachability reachability; + + /// When [reachability] is [Reachability.myDevices], also allow plain-Wi-Fi + /// access (`--lan`) as a fallback for when Tailscale is off. + final bool allowLanFallback; - /// The explicit host used only when [bindMode] is [ServerBindMode.custom]. + /// An explicit host that overrides [reachability] entirely (escape hatch). final String customHost; /// The port the daemon binds to. @@ -83,39 +89,41 @@ class ServerConfig { /// Returns a copy with the given overrides. ServerConfig copyWith({ - ServerBindMode? bindMode, + Reachability? reachability, + bool? allowLanFallback, String? customHost, int? port, String? cliPath, }) => ServerConfig( - bindMode: bindMode ?? this.bindMode, + reachability: reachability ?? this.reachability, + allowLanFallback: allowLanFallback ?? this.allowLanFallback, customHost: customHost ?? this.customHost, port: port ?? this.port, cliPath: cliPath ?? this.cliPath, ); - /// The `makit start`/`restart` arguments (excluding the verb) for this - /// config. [ServerBindMode.auto] passes no `--host`/`--lan`, letting the - /// server run its secure-by-default decision. A blank custom host also falls - /// back to auto. + /// The `makit start`/`restart` arguments (excluding the verb) for this config. + /// + /// A non-empty [customHost] wins — it is the explicit escape hatch. Otherwise + /// [Reachability.thisMacOnly] pins loopback, [Reachability.myDevices] passes + /// no host flag (letting the server run its secure default) and only adds + /// `--lan` when [allowLanFallback] is set. List serveArgs() { final args = []; - switch (bindMode) { - case ServerBindMode.auto: - break; - case ServerBindMode.lan: - args.add('--lan'); - case ServerBindMode.loopback: - args - ..add('--host') - ..add('127.0.0.1'); - case ServerBindMode.custom: - final h = customHost.trim(); - if (h.isNotEmpty) { + final host = customHost.trim(); + if (host.isNotEmpty) { + args + ..add('--host') + ..add(host); + } else { + switch (reachability) { + case Reachability.thisMacOnly: args ..add('--host') - ..add(h); - } + ..add('127.0.0.1'); + case Reachability.myDevices: + if (allowLanFallback) args.add('--lan'); + } } args ..add('--port') @@ -126,16 +134,24 @@ class ServerConfig { @override bool operator ==(Object other) => other is ServerConfig && - other.bindMode == bindMode && + other.reachability == reachability && + other.allowLanFallback == allowLanFallback && other.customHost == customHost && other.port == port && other.cliPath == cliPath; @override - int get hashCode => Object.hash(bindMode, customHost, port, cliPath); + int get hashCode => + Object.hash(reachability, allowLanFallback, customHost, port, cliPath); } -/// Reads + persists the [ServerConfig] via [SharedPreferences]. +/// Reads + persists the [ServerConfig] via a profile-scoped [ScopedPrefs]. +/// +/// Server config is **server-bound** (SPEC-50 D11): each profile's port and +/// reachability belong to that profile alone, so writes go through a +/// [ScopedPrefs] whose key prefix is the profile's own. Switching profiles +/// rebuilds this controller against the target's scope, so the window never +/// reads the previous profile's port and talks to the wrong server. class ServerConfigController extends StateNotifier { /// Creates a controller seeded from [initial]; writes go through [_prefs]. /// @@ -146,7 +162,7 @@ class ServerConfigController extends StateNotifier { : _defaultPort = defaultPort ?? kDefaultServerPort, super(initial); - final SharedPreferences _prefs; + final ScopedPrefs _prefs; final int _defaultPort; /// The current config. Public accessor so non-widget composition code (the @@ -154,33 +170,61 @@ class ServerConfigController extends StateNotifier { /// the protected `state`. ServerConfig get current => state; - /// Loads the persisted config. + /// Loads the persisted config, migrating any older schema in place. /// - /// New installs (and users who never changed the pre-unification host) - /// default to [ServerBindMode.auto]. A legacy host that was deliberately set - /// to a non-loopback value migrates to [ServerBindMode.custom] so those - /// users keep reaching their configured endpoint. - static ServerConfig load(SharedPreferences prefs, {int? defaultPort}) { + /// Three layers, newest first: the SPEC-50 [Reachability] keys; the + /// pre-SPEC-50 four-way `desktop_server_bind_mode`; and the pre-unification + /// single `desktop_server_host`. Each older layer is only consulted when the + /// newer ones are absent, so an explicit choice always wins over stale data + /// and no user silently loses a configured endpoint. + static ServerConfig load(ScopedPrefs prefs, {int? defaultPort}) { final fallbackPort = defaultPort ?? kDefaultServerPort; final port = prefs.getInt(_kPortKey); final cliPath = prefs.getString(_kCliPathKey) ?? ''; final resolvedPort = (port == null || port <= 0) ? fallbackPort : port; + final storedHost = prefs.getString(_kCustomHostKey) ?? ''; - final modeStr = prefs.getString(_kBindModeKey); - if (modeStr != null) { + // Layer 1: the current SPEC-50 schema. + final reachStr = prefs.getString(_kReachabilityKey); + if (reachStr != null) { return ServerConfig( - bindMode: _parseBindMode(modeStr), - customHost: prefs.getString(_kCustomHostKey) ?? '', + reachability: _parseReachability(reachStr), + allowLanFallback: prefs.getBool(_kAllowLanFallbackKey) ?? false, + customHost: storedHost, port: resolvedPort, cliPath: cliPath, ); } - // Migrate the legacy single-host preference. + // Layer 2: the pre-SPEC-50 four-way bind mode. + final bindMode = prefs.getString(_kLegacyBindModeKey); + if (bindMode != null) { + return switch (bindMode) { + 'loopback' => ServerConfig( + reachability: Reachability.thisMacOnly, + port: resolvedPort, + cliPath: cliPath, + ), + 'lan' => ServerConfig( + allowLanFallback: true, + port: resolvedPort, + cliPath: cliPath, + ), + // `custom` keeps its explicit host; `auto` drops any stale host so its + // "let the server decide" behaviour is preserved exactly. + 'custom' => ServerConfig( + customHost: storedHost, + port: resolvedPort, + cliPath: cliPath, + ), + _ => ServerConfig(port: resolvedPort, cliPath: cliPath), + }; + } + + // Layer 3: the pre-unification single-host preference. final legacy = prefs.getString(_kLegacyHostKey)?.trim() ?? ''; if (legacy.isNotEmpty && !_isLoopbackHost(legacy)) { return ServerConfig( - bindMode: ServerBindMode.custom, customHost: legacy, port: resolvedPort, cliPath: cliPath, @@ -189,13 +233,27 @@ class ServerConfigController extends StateNotifier { return ServerConfig(port: resolvedPort, cliPath: cliPath); } - /// Persists a new bind mode and updates state. - Future setBindMode(ServerBindMode mode) async { - state = state.copyWith(bindMode: mode); - await _prefs.setString(_kBindModeKey, mode.name); + /// Persists the reachability answer and updates state. + /// + /// Clears any custom host at the same time: [ServerConfig.serveArgs] gives a + /// non-empty [ServerConfig.customHost] precedence over the reachability + /// choice, so leaving a stale `0.0.0.0` in place would keep the server + /// network-reachable after the user explicitly picked “Just this Mac” — the + /// UI would say “nothing else can connect” while the bind said otherwise. An + /// explicit reachability choice is the newer intent and wins. + Future setReachability(Reachability reachability) async { + state = state.copyWith(reachability: reachability, customHost: ''); + await _prefs.setString(_kReachabilityKey, reachability.name); + await _prefs.setString(_kCustomHostKey, ''); + } + + /// Persists the LAN-fallback preference and updates state. + Future setAllowLanFallback(bool allow) async { + state = state.copyWith(allowLanFallback: allow); + await _prefs.setBool(_kAllowLanFallbackKey, allow); } - /// Persists the custom host (used when [ServerBindMode.custom] is active). + /// Persists the custom host (the Advanced escape hatch). Future setCustomHost(String host) async { final h = host.trim(); state = state.copyWith(customHost: h); @@ -219,7 +277,7 @@ class ServerConfigController extends StateNotifier { } /// The active server config. Overridden at the app root (`runDesktopApp`) with -/// a controller backed by real [SharedPreferences]; tests override it too. +/// a controller backed by a profile-scoped [ScopedPrefs]; tests override it too. final serverConfigProvider = StateNotifierProvider( (ref) => throw UnimplementedError('overridden in runDesktopApp'), diff --git a/app/lib/store/prefs/profile_scoped_prefs.dart b/app/lib/store/prefs/profile_scoped_prefs.dart new file mode 100644 index 00000000..998e8c22 --- /dev/null +++ b/app/lib/store/prefs/profile_scoped_prefs.dart @@ -0,0 +1,159 @@ +/// A profile-scoped view over [SharedPreferences]. +/// +/// Exists because `SharedPreferences.setPrefix` — the mechanism makit uses today +/// to namespace a worktree build's settings — **throws** once `getInstance()` has +/// run: +/// +/// ``` +/// StateError('setPrefix cannot be called after getInstance') +/// ``` +/// +/// That single line makes switching profiles inside a running window impossible, +/// and `resetStatic()` is `@visibleForTesting` (it also drops a cache other +/// controllers still hold references through), so it is not an option. Moving the +/// profile segment out of the plugin's global prefix and into keys we compose +/// ourselves removes the global mutable state entirely (SPEC-50 D11). +/// +/// **The migration is a no-op.** `shared_preferences` composes stored keys by +/// plain concatenation, `'$_prefix$key'`, so with the plugin left at its default +/// `flutter.` prefix, `'.desktop_server_port'` lands on exactly the byte +/// sequence `setPrefix('flutter..')` + `'desktop_server_port'` produced. The +/// legacy profile carries an empty segment, so its keys are untouched. Both +/// equivalences are asserted in `profile_registry_test.dart`. +/// +/// Only **server-bound** preferences are scoped: server config, groups and pane +/// layouts. Appearance, shortcuts, recent models and cached commands are +/// user-level and deliberately stay shared — the old blanket prefix is why a +/// worktree build opened with a default theme and empty shortcuts. +library; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// The narrow read/write surface the scoped controllers need. +/// +/// Deliberately an interface rather than a subclass: `SharedPreferences` has a +/// private constructor and static state, so it cannot be subclassed cleanly, and +/// depending on the small surface instead of the whole plugin is what lets a +/// controller be tested without any plugin at all. +abstract interface class ScopedPrefs { + /// Reads a string, or `null`. + String? getString(String key); + + /// Writes a string. + Future setString(String key, String value); + + /// Reads an int, or `null`. + int? getInt(String key); + + /// Writes an int. + Future setInt(String key, int value); + + /// Reads a bool, or `null`. + bool? getBool(String key); + + /// Writes a bool. + Future setBool(String key, bool value); + + /// Reads a string list, or `null`. + List? getStringList(String key); + + /// Writes a string list. + Future setStringList(String key, List value); + + /// Removes a key. + Future remove(String key); + + /// Whether a key is present. + bool containsKey(String key); + + /// Every key visible through this scope, with the scope prefix stripped. + Set keys(); +} + +/// A [ScopedPrefs] that prefixes every key with a profile's segment. +class ProfileScopedPrefs implements ScopedPrefs { + /// Wraps [prefs], prefixing every key with [prefix]. + /// + /// [prefix] is `ServerProfile.prefsKeyPrefix`: `''` for the legacy profile and + /// `'.'` for every other. An empty prefix is the identity scope, which is + /// exactly what the legacy profile needs. + const ProfileScopedPrefs(this._prefs, this.prefix); + + /// A scope over the whole store, used where a preference is user-level rather + /// than profile-bound (appearance, shortcuts, recent models). + const ProfileScopedPrefs.unscoped(SharedPreferences prefs) : this(prefs, ''); + + final SharedPreferences _prefs; + + /// The key segment this scope prepends. `''` means "no scoping". + final String prefix; + + String _k(String key) => '$prefix$key'; + + @override + String? getString(String key) => _prefs.getString(_k(key)); + + @override + Future setString(String key, String value) => + _prefs.setString(_k(key), value); + + @override + int? getInt(String key) => _prefs.getInt(_k(key)); + + @override + Future setInt(String key, int value) => _prefs.setInt(_k(key), value); + + @override + bool? getBool(String key) => _prefs.getBool(_k(key)); + + @override + Future setBool(String key, bool value) => + _prefs.setBool(_k(key), value); + + @override + List? getStringList(String key) => _prefs.getStringList(_k(key)); + + @override + Future setStringList(String key, List value) => + _prefs.setStringList(_k(key), value); + + @override + Future remove(String key) => _prefs.remove(_k(key)); + + @override + bool containsKey(String key) => _prefs.containsKey(_k(key)); + + /// Keys belonging to this scope only, with [prefix] stripped. + /// + /// Filtering matters: with the plugin left unscoped, `_prefs.getKeys()` returns + /// **every** profile's keys, so an unfiltered caller would see and could delete + /// another profile's settings. + @override + Set keys() { + final all = _prefs.getKeys(); + if (prefix.isEmpty) { + // The legacy scope owns the unprefixed keys. Anything containing a '.' + // segment before a known key belongs to a namespaced profile, but we + // cannot distinguish "id.key" from a legitimately dotted key, so the + // legacy scope reports everything and callers must ask for known keys. + return all; + } + return { + for (final k in all) + if (k.startsWith(prefix)) k.substring(prefix.length), + }; + } + + /// Removes every key in this scope. Refuses to run on an unscoped view, where + /// it could not tell this profile's keys from another's. + /// + /// Returns the number of keys removed, or `-1` when refused. + Future clearScope() async { + if (prefix.isEmpty) return -1; + final mine = _prefs.getKeys().where((k) => k.startsWith(prefix)).toList(); + for (final k in mine) { + await _prefs.remove(k); + } + return mine.length; + } +} diff --git a/app/test/desktop/chat/groups/groups_controller_test.dart b/app/test/desktop/chat/groups/groups_controller_test.dart index ec0df02e..66e8d3c5 100644 --- a/app/test/desktop/chat/groups/groups_controller_test.dart +++ b/app/test/desktop/chat/groups/groups_controller_test.dart @@ -13,6 +13,7 @@ import 'package:makit/desktop/chat/panes/split_node.dart'; import 'package:makit/desktop/chat/panes/workspace_controller.dart'; import 'package:makit/desktop/chat/selected_worktree.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; WorkspaceState _treeWith(List sessionIds) { // A split always has at least one tab, so an "empty" tree is one empty tab. @@ -528,7 +529,7 @@ void main() { test('mutations survive a reload, versioned', () async { final prefs = await SharedPreferences.getInstance(); - final c = GroupsController.load(prefs); + final c = GroupsController.load(ProfileScopedPrefs.unscoped(prefs)); final id = c.newBoard(label: 'Review'); c.addMember(id, 's1', location: _loc('/tmp/wt/main')); // Writes are coalesced to the end of the microtask queue (a divider drag @@ -539,7 +540,9 @@ void main() { final raw = jsonDecode(prefs.getString(kGroupsPrefsKey)!) as Map; expect(raw['v'], 1, reason: 'the payload is versioned from day one'); - final reloaded = GroupsController.load(prefs); + final reloaded = GroupsController.load( + ProfileScopedPrefs.unscoped(prefs), + ); expect(reloaded.state, c.state); expect(reloaded.groupById(id)!.members, ['s1']); }); @@ -547,7 +550,7 @@ void main() { test('corrupt JSON falls back to the fresh-launch state', () async { SharedPreferences.setMockInitialValues({kGroupsPrefsKey: '{not json'}); final prefs = await SharedPreferences.getInstance(); - final c = GroupsController.load(prefs); + final c = GroupsController.load(ProfileScopedPrefs.unscoped(prefs)); expect(c.state.groups, hasLength(1)); expect(c.state.groups.single.kind, GroupKind.board); expect(c.state.groups.single.label, 'Board 1'); @@ -565,7 +568,9 @@ void main() { }); final prefs = await SharedPreferences.getInstance(); expect( - GroupsController.load(prefs).state.groups.single.label, + GroupsController.load( + ProfileScopedPrefs.unscoped(prefs), + ).state.groups.single.label, 'Board 1', ); }, @@ -584,7 +589,7 @@ void main() { }), }); final prefs = await SharedPreferences.getInstance(); - final c = GroupsController.load(prefs); + final c = GroupsController.load(ProfileScopedPrefs.unscoped(prefs)); expect(c.state.groups.map((g) => g.id), ['b1']); }); @@ -592,7 +597,7 @@ void main() { 'the preview pointer survives a reload (SPEC-51 decision 10)', () async { final prefs = await SharedPreferences.getInstance(); - final c = GroupsController.load(prefs); + final c = GroupsController.load(ProfileScopedPrefs.unscoped(prefs)); final id = c.openWorktreeGroup( projectId: 'p1', worktreePath: '/tmp/wt/a', @@ -601,7 +606,9 @@ void main() { ); await pumpEventQueue(); - final reloaded = GroupsController.load(prefs); + final reloaded = GroupsController.load( + ProfileScopedPrefs.unscoped(prefs), + ); expect(reloaded.state.previewGroupId, id); expect(reloaded.state, c.state); }, @@ -617,7 +624,12 @@ void main() { }), }); final prefs = await SharedPreferences.getInstance(); - expect(GroupsController.load(prefs).state.previewGroupId, isNull); + expect( + GroupsController.load( + ProfileScopedPrefs.unscoped(prefs), + ).state.previewGroupId, + isNull, + ); }); test('a stale previewGroupId degrades to no preview', () async { @@ -631,7 +643,12 @@ void main() { }), }); final prefs = await SharedPreferences.getInstance(); - expect(GroupsController.load(prefs).state.previewGroupId, isNull); + expect( + GroupsController.load( + ProfileScopedPrefs.unscoped(prefs), + ).state.previewGroupId, + isNull, + ); }); test('a previewGroupId that matches a board decodes to no preview — the ' @@ -649,7 +666,9 @@ void main() { }), }); final prefs = await SharedPreferences.getInstance(); - final reloaded = GroupsController.load(prefs); + final reloaded = GroupsController.load( + ProfileScopedPrefs.unscoped(prefs), + ); expect(reloaded.state.previewGroupId, isNull); expect(reloaded.state.groups.map((g) => g.id), ['b1']); }); @@ -664,7 +683,12 @@ void main() { }), }); final prefs = await SharedPreferences.getInstance(); - expect(GroupsController.load(prefs).state.activeGroupId, 'b1'); + expect( + GroupsController.load( + ProfileScopedPrefs.unscoped(prefs), + ).state.activeGroupId, + 'b1', + ); }); }); @@ -705,7 +729,7 @@ void main() { }); final prefs = await SharedPreferences.getInstance(); resetNodeIds(); - final c = GroupsController.load(prefs); + final c = GroupsController.load(ProfileScopedPrefs.unscoped(prefs)); // Every id in the restored closed board must be below the counter, so a // freshly minted id can never collide with it. @@ -758,7 +782,7 @@ void main() { kWorkspacePrefsKey: jsonEncode(legacy.toJson()), }); final prefs = await SharedPreferences.getInstance(); - final c = GroupsController.load(prefs); + final c = GroupsController.load(ProfileScopedPrefs.unscoped(prefs)); expect(c.state.groups, hasLength(1)); final board = c.state.groups.single; @@ -773,7 +797,7 @@ void main() { test('a corrupt legacy blob still yields a usable fresh state', () async { SharedPreferences.setMockInitialValues({kWorkspacePrefsKey: '{not json'}); final prefs = await SharedPreferences.getInstance(); - final c = GroupsController.load(prefs); + final c = GroupsController.load(ProfileScopedPrefs.unscoped(prefs)); expect(c.state.groups.single.label, 'Board 1'); expect(c.state.groups.single.members, isEmpty); }); diff --git a/app/test/desktop/chat/server_profile_badge_test.dart b/app/test/desktop/chat/server_profile_badge_test.dart new file mode 100644 index 00000000..62a4f8ad --- /dev/null +++ b/app/test/desktop/chat/server_profile_badge_test.dart @@ -0,0 +1,213 @@ +// Tests for the profile switcher UI (SPEC-50 D10). +// +// The switch itself lives in `_ProfileHostState.switchTo` in desktop_app.dart, +// which owns the ProviderScope and cannot be built in a widget test. What IS +// testable — and what actually protects the user — is the contract around it: +// the badge must confirm first, must not switch when the user declines, and must +// report a failure instead of pretending it worked. +// ignore_for_file: depend_on_referenced_packages +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/app/theme.dart'; +import 'package:makit/desktop/chat/server_profile_badge.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/profiles_controller.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; +import 'package:makit/desktop/desktop_app.dart' show serverProfileProvider; +import 'package:makit/desktop/settings/sections/profiles_providers.dart'; +import 'package:makit/status/status_center.dart'; +import 'package:makit/status/status_event.dart'; +import 'package:makit/status/status_providers.dart'; + +const ServerProfile _work = ServerProfile( + id: 'work', + name: 'Work', + kind: ProfileKind.user, + home: '/h/.makit', + port: 7777, + storage: ProfileStorage.legacy, +); + +const ServerProfile _personal = ServerProfile( + id: 'personal', + name: 'Personal', + kind: ProfileKind.user, + home: '/h/.makit/profiles/personal', + port: 7805, + storage: ProfileStorage.namespaced, +); + +class _NoWriteFs extends FileSystemAdapter { + @override + String? readOrNull(String path) => null; + @override + void writeAtomic(String path, String contents) {} + // Without this, the base withLock creates `.lock` on the real disk. + @override + T withLock(String path, T Function() body) => body(); +} + +ProfileLifecycle _lifecycle({required bool targetRunning}) => ProfileLifecycle( + resolver: MakitCliResolver( + candidatePaths: const [], + exists: (_) => false, + shellLookup: () async => '/usr/local/bin/makit', + ), + socketExists: (_) => targetRunning, + statusProbe: (_) async => targetRunning, + sleep: (_) async {}, +); + +Future<({List switched, StatusCenter center})> _pump( + WidgetTester tester, { + required Future Function(ServerProfile) switcher, + bool targetRunning = true, +}) async { + final switched = []; + final center = StatusCenter(); + addTearDown(center.dispose); + + final registry = ProfileRegistry( + makitRoot: '/h/.makit', + fs: _NoWriteFs(), + profiles: const [_work, _personal], + ); + final controller = ProfilesController( + registry: registry, + activeProfileId: 'work', + isRunning: (p) async => true, + dirExists: (_) => true, + ); + await controller.refresh(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + serverProfileProvider.overrideWithValue(_work), + profilesControllerProvider.overrideWithValue(controller), + switcherProfilesProvider.overrideWithValue(controller), + profileLifecycleProvider.overrideWithValue( + _lifecycle(targetRunning: targetRunning), + ), + statusCenterProvider.overrideWithValue(center), + profileSwitcherProvider.overrideWithValue(( + target, { + ServerProfile? deleteAfter, + }) async { + switched.add(target.id); + return (switchFailure: await switcher(target), deleteFailure: null); + }), + ], + child: MaterialApp( + theme: makitDarkTheme, + home: const Scaffold( + body: Align( + alignment: Alignment.topRight, + child: ServerProfileBadge(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return (switched: switched, center: center); +} + +void main() { + testWidgets('the pill names the active profile', (tester) async { + await _pump(tester, switcher: (_) async => null); + expect(find.text('Work'), findsOneWidget); + }); + + testWidgets('choosing another profile confirms before switching', ( + tester, + ) async { + final r = await _pump(tester, switcher: (_) async => null); + + await tester.tap(find.byType(ServerProfileBadge)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Personal').last); + await tester.pumpAndSettle(); + + // The sheet is up and nothing has switched yet. + expect(find.text('Switch to “Personal”?'), findsOneWidget); + expect(r.switched, isEmpty); + + // It states what survives — the half that makes the button usable. + expect(find.text('WHAT KEEPS RUNNING'), findsOneWidget); + expect(find.textContaining('Work’s server stays up'), findsOneWidget); + expect( + find.textContaining('Work’s agents are not interrupted'), + findsOneWidget, + ); + }); + + testWidgets('declining the sheet switches nothing', (tester) async { + final r = await _pump(tester, switcher: (_) async => null); + + await tester.tap(find.byType(ServerProfileBadge)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Personal').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(r.switched, isEmpty); + expect(r.center.events, isEmpty); + }); + + testWidgets('confirming switches and reports success', (tester) async { + final r = await _pump(tester, switcher: (_) async => null); + + await tester.tap(find.byType(ServerProfileBadge)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Personal').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Switch to Personal')); + await tester.pumpAndSettle(); + + expect(r.switched, ['personal']); + expect(r.center.events.last.severity, StatusSeverity.success); + }); + + // A switch that fails must say so. Reporting nothing would leave the user + // believing they had moved profile when the window had not. + testWidgets('a failed switch is reported with its reason', (tester) async { + final r = await _pump( + tester, + switcher: (_) async => 'Personal started but is not answering', + ); + + await tester.tap(find.byType(ServerProfileBadge)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Personal').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Switch to Personal')); + await tester.pumpAndSettle(); + + final event = r.center.events.last; + expect(event.severity, StatusSeverity.failure); + expect(event.title, contains('Could not switch to Personal')); + expect(event.detail, contains('not answering')); + }); + + testWidgets('a stopped target is offered, and the sheet says it will start', ( + tester, + ) async { + await _pump(tester, switcher: (_) async => null, targetRunning: false); + + await tester.tap(find.byType(ServerProfileBadge)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Personal').last); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Personal’s server starts'), + findsOneWidget, + reason: 'the sheet must say the target will be started', + ); + }); +} diff --git a/app/test/desktop/daemon/profile_deleter_live_test.dart b/app/test/desktop/daemon/profile_deleter_live_test.dart new file mode 100644 index 00000000..b40159d3 --- /dev/null +++ b/app/test/desktop/daemon/profile_deleter_live_test.dart @@ -0,0 +1,597 @@ +// Live, real-filesystem tests for [ProfileDeleter] (SPEC-50 D8). +// +// The unit tests inject a fake filesystem, which is right for covering branches +// but cannot prove the thing that actually matters: that a recursive delete +// removes what it should and *nothing else*, against the real OS. These tests +// build a genuine profile home in a temp directory — database, WAL, media, +// pairings, TLS keypair — delete it through the real code path, and inspect the +// disk afterwards. +// +// Every test scopes `homeDir` to its own temp root, so nothing here can touch +// the developer's real `~/.makit`. +// ignore_for_file: depend_on_referenced_packages +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart' + show MakitCliResolver; +import 'package:makit/desktop/daemon/profile_deleter.dart'; +import 'package:makit/desktop/daemon/profile_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; + +/// A lifecycle that reports the daemon already stopped, so these tests exercise +/// the filesystem path rather than process control (covered by its own tests). +class _StoppedLifecycle implements ProfileLifecycle { + @override + Future stopAndConfirm( + ServerProfile profile, { + Duration timeout = const Duration(seconds: 5), + }) async => true; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// A lifecycle that refuses to stop, to prove nothing is unlinked underneath it. +class _StuckLifecycle implements ProfileLifecycle { + @override + Future stopAndConfirm( + ServerProfile profile, { + Duration timeout = const Duration(seconds: 5), + }) async => false; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late Directory root; + + setUp(() => root = Directory.systemTemp.createTempSync('spec50-live-')); + tearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + /// Builds a realistic profile home and returns the profile describing it. + ServerProfile seedProfile({String id = 'dev1', String? homeOverride}) { + final home = homeOverride ?? '${root.path}/.makit-dev/$id'; + Directory('$home/media').createSync(recursive: true); + // The files a real home carries, taken from an actual ~/.makit-dev/. + File('$home/makit.db').writeAsStringSync('sqlite'); + File('$home/makit.db-wal').writeAsStringSync('wal'); + File('$home/makit.db-shm').writeAsStringSync('shm'); + File('$home/devices.json').writeAsStringSync('[{"id":"phone"}]'); + File('$home/projects.json').writeAsStringSync('[]'); + File('$home/server.crt').writeAsStringSync('cert'); + File('$home/server.key').writeAsStringSync('PRIVATE KEY'); + File('$home/makit.log').writeAsStringSync('log'); + File('$home/media/shot.png').writeAsStringSync('png'); + return ServerProfile( + id: id, + name: id, + kind: ProfileKind.dev, + home: home, + port: 7801, + storage: ProfileStorage.namespaced, + origin: '/gone', + ); + } + + ({ProfileDeleter deleter, ProfileRegistry registry}) build( + ServerProfile profile, { + String active = 'default', + ProfileLifecycle? lifecycle, + }) { + final registry = ProfileRegistry( + makitRoot: '${root.path}/.makit', + profiles: [ + const ServerProfile( + id: 'default', + name: 'Makit', + kind: ProfileKind.user, + home: '/anywhere', + port: 7777, + storage: ProfileStorage.legacy, + ), + profile, + ], + ); + return ( + deleter: ProfileDeleter( + registry: registry, + lifecycle: lifecycle ?? _StoppedLifecycle(), + activeProfileId: active, + homeDir: root.path, + ), + registry: registry, + ); + } + + test('erases a real profile home and drops the registry entry', () async { + final profile = seedProfile(); + final built = build(profile); + built.registry.save(); + + final result = await built.deleter.delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.deleted); + expect(Directory(profile.home).existsSync(), isFalse); + expect(built.registry.byId('dev1'), isNull); + // Persisted, not just in memory: the profile must not come back on relaunch. + // (The id survives only as a `deletedIds` tombstone, so a stale window can't + // resurrect it — assert on the reloaded registry, not a raw substring.) + final reloaded = ProfileRegistry.load(makitRoot: '${root.path}/.makit'); + expect(reloaded.byId('dev1'), isNull); + expect(reloaded.byId('default'), isNotNull); + // The database was the bulk of it, so bytes freed must be non-trivial. + expect(result.bytesFreed, greaterThan(0)); + }); + + test('leaves every sibling profile home untouched', () async { + final victim = seedProfile(id: 'victim'); + final bystander = seedProfile(id: 'bystander'); + final built = build(victim); + + await built.deleter.delete(victim); + + expect(Directory(victim.home).existsSync(), isFalse); + expect(Directory(bystander.home).existsSync(), isTrue); + expect(File('${bystander.home}/server.key').existsSync(), isTrue); + }); + + test( + 'refuses a home outside the makit namespace, and deletes nothing', + () async { + // The scenario that matters: a hand-edited or corrupted profiles.json + // pointing `home` at real user data. + final documents = Directory('${root.path}/Documents') + ..createSync(recursive: true); + File( + '${documents.path}/thesis.txt', + ).writeAsStringSync('do not delete me'); + final profile = seedProfile(homeOverride: documents.path); + + final result = await build(profile).deleter.delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect(File('${documents.path}/thesis.txt').existsSync(), isTrue); + expect(documents.existsSync(), isTrue); + }, + ); + + test('refuses the protected profile', () async { + final built = build(seedProfile()); + final legacy = built.registry.byId('default')!; + final result = await built.deleter.delete(legacy); + expect(result.outcome, ProfileDeletionOutcome.refusedProtected); + expect(built.registry.byId('default'), isNotNull); + }); + + test('refuses the active profile', () async { + final profile = seedProfile(); + final built = build(profile, active: profile.id); + final result = await built.deleter.delete(profile); + expect(result.outcome, ProfileDeletionOutcome.refusedActive); + expect(Directory(profile.home).existsSync(), isTrue); + }); + + test('unlinks nothing while the daemon refuses to stop', () async { + // Unlinking under a live daemon holding makit.db-wal is how a delete + // corrupts a database, so this must abort before touching the disk. + final profile = seedProfile(); + final built = build(profile, lifecycle: _StuckLifecycle()); + final result = await built.deleter.delete(profile); + expect(result.outcome, ProfileDeletionOutcome.refusedDaemonRunning); + expect(Directory(profile.home).existsSync(), isTrue); + expect(File('${profile.home}/makit.db').existsSync(), isTrue); + expect(built.registry.byId(profile.id), isNotNull); + }); + + test('diskUsage measures the real tree', () async { + final profile = seedProfile(); + final bytes = await build(profile).deleter.diskUsage(profile); + // 9 seeded files, all non-empty. + expect(bytes, greaterThan(20)); + }); + + test('reclaims an orphan whose crashed daemon left a stale socket', () async { + // The bug this guards: `makit stop` on an already-dead daemon removes the pid + // file but NOT control.sock (verified against the real binary -- after + // SIGKILL it prints "not running" and the socket remains). While + // stopAndConfirm polled the socket FILE, every crashed profile looked alive + // forever, so ProfileDeleter refused it and the orphans D9 exists to reclaim + // could never be deleted. + final profile = seedProfile(id: 'crashed'); + File(profile.controlSocketPath).writeAsStringSync(''); + expect(File(profile.controlSocketPath).existsSync(), isTrue); + + final registry = ProfileRegistry( + makitRoot: '${root.path}/.makit', + profiles: [profile], + ); + final deleter = ProfileDeleter( + registry: registry, + // A REAL lifecycle: only the CLI spawn is stubbed, so the socket check and + // the liveness probe are the production ones. + lifecycle: ProfileLifecycle( + resolver: MakitCliResolver( + candidatePaths: const [], + exists: (_) => false, + shellLookup: () async => '/usr/local/bin/makit', + ), + run: (exe, args, {environment}) async => ProcessResult(0, 0, '', ''), + sleep: (_) async {}, + ), + activeProfileId: 'someone-else', + homeDir: root.path, + ); + + final result = await deleter.delete(profile); + + expect( + result.outcome, + ProfileDeletionOutcome.deleted, + reason: 'a stale socket must not make an orphan undeletable', + ); + expect(Directory(profile.home).existsSync(), isFalse); + }); + + test('refuses a rogue entry that aims at the legacy home by path', () async { + // The protected guard keys on the entry's own `storage` flag, but + // profiles.json is a plain user-writable file. One hand-added line -- + // {"id":"x","storage":"namespaced","home":"~/.makit"} -- would otherwise be + // neither protected nor active, and ~/.makit passes a bare prefix check, so + // the delete would take the APNs key, the TLS keypair, devices.json, ota/, + // push.json and host.json with it. Rule 2 of `_unsafeHomeReason` refuses it: + // a home must sit strictly inside ~/.makit/ or ~/.makit-dev/ with at least + // one further segment, and bare ~/.makit has none. + final legacyHome = '${root.path}/.makit'; + Directory(legacyHome).createSync(recursive: true); + File('$legacyHome/AuthKey_ABCD1234.p8').writeAsStringSync('APNS SECRET'); + File('$legacyHome/server.key').writeAsStringSync('TLS SECRET'); + File('$legacyHome/devices.json').writeAsStringSync('[{"id":"phone"}]'); + + const legacy = ServerProfile( + id: 'default', + name: 'Makit', + kind: ProfileKind.user, + home: 'PLACEHOLDER', + port: 7777, + storage: ProfileStorage.legacy, + ); + final rogue = ServerProfile( + id: 'rogue', + name: 'rogue', + kind: ProfileKind.user, + home: legacyHome, + port: 7899, + storage: ProfileStorage.namespaced, + ); + final registry = ProfileRegistry( + makitRoot: legacyHome, + profiles: [ + // Registered somewhere else entirely, so the shared-home rule cannot + // fire and rule 2 (strictly-inside, one segment below ~/.makit/) is what + // refuses. An earlier version of this test registered both at the same + // home, and so stayed green even with rule 2 deleted. + legacy.copyWith(home: '/somewhere/else/entirely'), + rogue, + ], + ); + final deleter = ProfileDeleter( + registry: registry, + lifecycle: _StoppedLifecycle(), + activeProfileId: 'someone-else', + homeDir: root.path, + ); + + final result = await deleter.delete(rogue); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect( + File('$legacyHome/AuthKey_ABCD1234.p8').existsSync(), + isTrue, + reason: 'the APNs key was destroyed', + ); + expect(File('$legacyHome/server.key').existsSync(), isTrue); + expect(File('$legacyHome/devices.json').existsSync(), isTrue); + }); + + test('refuses a home shared with another registry entry', () async { + // Two entries pointing at one home: deleting either would erase the other's + // data behind its back. + final shared = seedProfile(id: 'twinA'); + final twin = ServerProfile( + id: 'twinB', + name: 'twinB', + kind: ProfileKind.dev, + home: shared.home, + port: 7803, + storage: ProfileStorage.namespaced, + ); + final registry = ProfileRegistry( + makitRoot: '${root.path}/.makit', + profiles: [shared, twin], + ); + final result = await ProfileDeleter( + registry: registry, + lifecycle: _StoppedLifecycle(), + activeProfileId: 'someone-else', + homeDir: root.path, + ).delete(twin); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect(Directory(shared.home).existsSync(), isTrue); + }); + + test('refuses a sibling directory that merely shares the prefix', () async { + // `startsWith('$home/.makit')` accepted ~/.makitEVIL and ~/.makitother/x. + for (final rogueHome in [ + '${root.path}/.makitEVIL', + '${root.path}/.makitother/x', + ]) { + Directory(rogueHome).createSync(recursive: true); + File('$rogueHome/keep.txt').writeAsStringSync('keep'); + final profile = ServerProfile( + id: 'sneaky', + name: 'sneaky', + kind: ProfileKind.dev, + home: rogueHome, + port: 7804, + storage: ProfileStorage.namespaced, + ); + final result = await ProfileDeleter( + registry: ProfileRegistry( + makitRoot: '${root.path}/.makit', + profiles: [profile], + ), + lifecycle: _StoppedLifecycle(), + activeProfileId: 'someone-else', + homeDir: root.path, + ).delete(profile); + + expect( + result.outcome, + ProfileDeletionOutcome.refusedUnsafePath, + reason: 'accepted $rogueHome', + ); + expect(File('$rogueHome/keep.txt').existsSync(), isTrue); + } + }); + + test('no spelling of ~/.makit reaches the delete', () async { + // A string-equality guard is defeated by one trailing slash: `~/.makit/` is + // not `==` to `~/.makit`, yet it satisfies a `startsWith('~/.makit/')` + // containment check. Every spelling below reached deleteDirectory in an + // earlier version and destroyed the APNs key. + // + // The legacy profile is deliberately NOT registered here, so the shared-home + // rule cannot backstop it: each spelling must be refused on its own merits, + // by canonicalisation (`//`, `/.`, `..`) or by containment requiring a child + // segment (`~/.makit`, `~/.makit/`). + final legacyHome = '${root.path}/.makit'; + for (final spelling in [ + legacyHome, + '$legacyHome/', + '$legacyHome//', + '$legacyHome/.', + '$legacyHome/./', + '${root.path}//.makit', + '${root.path}/.makit/../.makit', + '${root.path}/.makit-dev/../.makit', + ]) { + Directory(legacyHome).createSync(recursive: true); + final secret = File('$legacyHome/AuthKey_ABCD1234.p8') + ..writeAsStringSync('APNS SECRET'); + + final rogue = ServerProfile( + id: 'rogue', + name: 'rogue', + kind: ProfileKind.user, + home: spelling, + port: 7899, + storage: ProfileStorage.namespaced, + ); + final result = await ProfileDeleter( + registry: ProfileRegistry(makitRoot: legacyHome, profiles: [rogue]), + lifecycle: _StoppedLifecycle(), + activeProfileId: 'someone-else', + homeDir: root.path, + ).delete(rogue); + + expect( + result.outcome, + ProfileDeletionOutcome.refusedUnsafePath, + reason: 'accepted the spelling "$spelling"', + ); + expect( + secret.existsSync(), + isTrue, + reason: 'the APNs key was destroyed via "$spelling"', + ); + } + }); + + test( + "refuses the legacy profile's registered home wherever it points", + () async { + // Rule 3 of `_unsafeHomeReason` (shared-home refusal): the legacy entry + // may legitimately live somewhere other than ~/.makit, and a rogue aiming + // at *that* home is refused because the legacy entry claims it too. + final legacyHome = '${root.path}/.makit-dev/relocated-legacy'; + Directory(legacyHome).createSync(recursive: true); + File('$legacyHome/AuthKey_X.p8').writeAsStringSync('APNS'); + + final rogue = ServerProfile( + id: 'rogue2', + name: 'rogue2', + kind: ProfileKind.dev, + home: legacyHome, + port: 7898, + storage: ProfileStorage.namespaced, + ); + final result = await ProfileDeleter( + registry: ProfileRegistry( + makitRoot: '${root.path}/.makit', + profiles: [ + ServerProfile( + id: 'default', + name: 'Makit', + kind: ProfileKind.user, + home: legacyHome, + port: 7777, + storage: ProfileStorage.legacy, + ), + rogue, + ], + ), + lifecycle: _StoppedLifecycle(), + activeProfileId: 'someone-else', + homeDir: root.path, + ).delete(rogue); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect(File('$legacyHome/AuthKey_X.p8').existsSync(), isTrue); + }, + ); + + test('refuses a traversal that starts inside the namespace', () async { + // The one path-escape class with no coverage before: a home that begins + // legitimately under ~/.makit-dev/ and then climbs out with `..`. Containment + // alone would accept it, since it does start with the right prefix. + final documents = Directory('${root.path}/Documents') + ..createSync(recursive: true); + File('${documents.path}/thesis.txt').writeAsStringSync('years of work'); + + for (final rogueHome in [ + '${root.path}/.makit-dev/../Documents', + '${root.path}/.makit-dev/x/../../Documents', + '${root.path}/.makit/profiles/../../Documents', + '', + root.path, + '/', + 'relative/path', + ]) { + final profile = ServerProfile( + id: 'traversal', + name: 'traversal', + kind: ProfileKind.dev, + home: rogueHome, + port: 7897, + storage: ProfileStorage.namespaced, + ); + final result = await ProfileDeleter( + registry: ProfileRegistry( + makitRoot: '${root.path}/.makit', + profiles: [profile], + ), + lifecycle: _StoppedLifecycle(), + activeProfileId: 'someone-else', + homeDir: root.path, + ).delete(profile); + + expect( + result.outcome, + ProfileDeletionOutcome.refusedUnsafePath, + reason: 'accepted "$rogueHome"', + ); + expect( + File('${documents.path}/thesis.txt').existsSync(), + isTrue, + reason: 'user data destroyed via "$rogueHome"', + ); + } + }); + + test('refuses ~/.makit-dev itself, with no profile segment', () async { + final bare = '${root.path}/.makit-dev'; + Directory(bare).createSync(recursive: true); + File('$bare/other-profile-data').writeAsStringSync('x'); + final profile = ServerProfile( + id: 'bare', + name: 'bare', + kind: ProfileKind.dev, + home: bare, + port: 7805, + storage: ProfileStorage.namespaced, + ); + final result = await ProfileDeleter( + registry: ProfileRegistry( + makitRoot: '${root.path}/.makit', + profiles: [profile], + ), + lifecycle: _StoppedLifecycle(), + activeProfileId: 'someone-else', + homeDir: root.path, + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect(File('$bare/other-profile-data').existsSync(), isTrue); + }); + + test('a home that is a symlink out of the namespace is not followed', () async { + // If the recursive delete followed a symlink, a profile home pointing at + // real data would take it with it. + final outside = Directory('${root.path}/outside') + ..createSync(recursive: true); + File('${outside.path}/keepme.txt').writeAsStringSync('keep'); + + final home = '${root.path}/.makit-dev/linked'; + Directory('${root.path}/.makit-dev').createSync(recursive: true); + Link(home).createSync(outside.path); + + final profile = ServerProfile( + id: 'linked', + name: 'linked', + kind: ProfileKind.dev, + home: home, + port: 7802, + storage: ProfileStorage.namespaced, + ); + + final result = await build(profile).deleter.delete(profile); + + // The symlink resolves out of ~/.makit*, so the guard refuses it outright. + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + // Whatever happened to the link itself, the data it pointed at must survive. + expect( + File('${outside.path}/keepme.txt').existsSync(), + isTrue, + reason: 'recursive delete followed a symlink out of the namespace', + ); + }); + + test('an ancestor symlink out of the namespace is refused', () async { + // The Critical case: a *parent* component is a symlink, so a lexically- + // contained home (`~/.makit/profiles/victim`) resolves to an external dir. + // Directory.delete follows ancestor symlinks, so the guard must resolve + // them and re-check containment rather than trust the lexical path. + final external = Directory('${root.path}/outside/victim') + ..createSync(recursive: true); + File('${external.path}/keepme.txt').writeAsStringSync('keep'); + + // `~/.makit/profiles` -> `~/outside`, so `.../profiles/victim` is external. + Directory('${root.path}/.makit').createSync(recursive: true); + Link('${root.path}/.makit/profiles').createSync('${root.path}/outside'); + final home = '${root.path}/.makit/profiles/victim'; + + final profile = ServerProfile( + id: 'victim', + name: 'victim', + kind: ProfileKind.dev, + home: home, + port: 7803, + storage: ProfileStorage.namespaced, + ); + + final result = await build(profile).deleter.delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect( + File('${external.path}/keepme.txt').existsSync(), + isTrue, + reason: 'delete followed an ancestor symlink out of the namespace', + ); + }); +} diff --git a/app/test/desktop/daemon/profile_deleter_test.dart b/app/test/desktop/daemon/profile_deleter_test.dart new file mode 100644 index 00000000..bc901829 --- /dev/null +++ b/app/test/desktop/daemon/profile_deleter_test.dart @@ -0,0 +1,414 @@ +// Unit tests for [ProfileDeleter] (SPEC-50 P3, D8). +// Co-located with the code under test (per SPEC-03 desktop layout). +// ignore_for_file: depend_on_referenced_packages +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_deleter.dart'; +import 'package:makit/desktop/daemon/profile_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; + +const String _homeDir = '/Users/tester'; + +/// In-memory [ProfileFileSystem]: `paths` maps an existing path to its byte +/// size; every delete is recorded so tests can assert what was unlinked. +class _FakeFs implements ProfileFileSystem { + _FakeFs(this.paths, {this.files = const {}}); + final Map paths; + + /// Seeded paths that are regular FILES rather than directories. + final Set files; + final List deleted = []; + + @override + bool exists(String path) => paths.containsKey(path); + + @override + bool isDirectory(String path) => + paths.containsKey(path) && !files.contains(path); + + @override + Future sizeOf(String path) async => paths[path] ?? 0; + + @override + Future deleteDirectory(String path) async { + deleted.add(path); + paths.remove(path); + } + + @override + Future deleteFile(String path) async { + deleted.add(path); + paths.remove(path); + } + + @override + String? resolveRealPath(String path) => null; +} + +/// A [ProfileFileSystem] that throws a [FileSystemException] on a chosen store +/// operation, to prove [ProfileDeleter.delete] stays best-effort. +class _ThrowingFs implements ProfileFileSystem { + _ThrowingFs(this.paths, {this.throwOnDeleteDirectory = false}); + final Map paths; + final bool throwOnDeleteDirectory; + + @override + bool exists(String path) => paths.containsKey(path); + + @override + bool isDirectory(String path) => paths.containsKey(path); + + @override + Future sizeOf(String path) async => paths[path] ?? 0; + + @override + Future deleteDirectory(String path) async { + if (throwOnDeleteDirectory) { + throw const FileSystemException('permission denied'); + } + paths.remove(path); + } + + @override + Future deleteFile(String path) async => paths.remove(path); + + @override + String? resolveRealPath(String path) => null; +} + +MakitCliResolver _resolver() => MakitCliResolver( + candidatePaths: const [], + exists: (_) => false, + shellLookup: () async => '/usr/local/bin/makit', +); + +/// A lifecycle whose daemon is [running] or not; `stopAndConfirm` returns +/// `!running` without spawning anything real. +/// +/// Both the socket check and the liveness probe are driven by [running], because +/// "still running" now means *still answering* — a socket file left behind by a +/// SIGKILLed daemon must NOT count as running, or an orphaned profile could never +/// be deleted (see `stopAndConfirm`). +ProfileLifecycle _lifecycle({required bool running}) => ProfileLifecycle( + resolver: _resolver(), + run: (exe, args, {environment}) async => ProcessResult(0, 0, '', ''), + socketExists: (_) => running, + statusProbe: (_) async => running, + sleep: (_) async {}, +); + +ServerProfile _profile({ + String id = 'work', + String home = '$_homeDir/.makit/profiles/work', + ProfileStorage storage = ProfileStorage.namespaced, +}) => ServerProfile( + id: id, + name: 'Work', + kind: ProfileKind.user, + home: home, + port: 7801, + storage: storage, +); + +String _securePath(String namespace) => + '$_homeDir/Library/Application Support/dev.getmakit.app/' + 'secure_store.$namespace.json'; + +late Directory _tempRoot; + +ProfileRegistry _registry(List profiles) => + ProfileRegistry(makitRoot: _tempRoot.path, profiles: profiles); + +ProfileDeleter _deleter({ + required ProfileRegistry registry, + required ProfileLifecycle lifecycle, + required _FakeFs fs, + String activeProfileId = 'other', + Future Function(ServerProfile)? purgePrefs, +}) => ProfileDeleter( + registry: registry, + lifecycle: lifecycle, + activeProfileId: activeProfileId, + homeDir: _homeDir, + fs: fs, + isMacOS: true, + purgePrefs: purgePrefs, +); + +void main() { + setUp(() { + _tempRoot = Directory.systemTemp.createTempSync('profile_deleter_test'); + }); + tearDown(() { + if (_tempRoot.existsSync()) _tempRoot.deleteSync(recursive: true); + }); + + group('ProfileDeleter guards', () { + test('refuses the protected legacy profile', () async { + final profile = _profile(id: 'default', storage: ProfileStorage.legacy); + final fs = _FakeFs({profile.home: 100}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedProtected); + expect(fs.deleted, isEmpty); + }); + + test('refuses the currently active profile', () async { + final profile = _profile(); + final fs = _FakeFs({profile.home: 100}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + activeProfileId: 'work', + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedActive); + expect(fs.deleted, isEmpty); + }); + + test('refuses a home outside ~/.makit* — the disk-safety guard', () async { + final profile = _profile(id: 'corrupt', home: '/'); + final fs = _FakeFs({'/': 999999}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect(fs.deleted, isEmpty); + }); + + test('refuses a home outside ~/.makit* even under the home dir', () async { + final profile = _profile(id: 'evil', home: '$_homeDir/Documents'); + final fs = _FakeFs({'$_homeDir/Documents': 100}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedUnsafePath); + expect(fs.deleted, isEmpty); + }); + + test( + 'refuses when the daemon will not stop — never unlinks live', + () async { + final profile = _profile(); + final fs = _FakeFs({profile.home: 100}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: true), + fs: fs, + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.refusedDaemonRunning); + expect(fs.deleted, isEmpty); + }, + ); + }); + + group('ProfileDeleter.delete happy path', () { + test( + 'erases home + secure store + registry entry, reports bytes', + () async { + final profile = _profile(); + final securePath = _securePath('work'); + final fs = _FakeFs({profile.home: 4096, securePath: 32}); + final registry = _registry([profile]); + final deleter = _deleter( + registry: registry, + lifecycle: _lifecycle(running: false), + fs: fs, + ); + + final result = await deleter.delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.deleted); + expect(result.bytesFreed, 4096 + 32); + expect(fs.deleted, containsAll([profile.home, securePath])); + expect(registry.byId('work'), isNull); + expect(result.removed, contains('registry entry work')); + }, + ); + + test('purges the profile\'s preference keys (store 3)', () async { + // Prefs are reachable for a non-active profile now that scoping is by key + // prefix, so the deleter must actually purge them rather than always + // reporting the store skipped. + final profile = _profile(); + final fs = _FakeFs({profile.home: 10}); + final purged = []; + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + purgePrefs: (p) async { + purged.add(p.id); + return 4; + }, + ).delete(profile); + + expect(purged, ['work']); + expect( + result.removed.any((s) => s.contains('4 preference key(s)')), + isTrue, + reason: 'a successful purge must be reported as removed, not skipped', + ); + }); + + test('reports prefs as skipped when no prefs are wired', () async { + final profile = _profile(); + final fs = _FakeFs({profile.home: 10}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, // no purgePrefs + ).delete(profile); + + expect( + result.skipped.any((s) => s.contains('preference keys')), + isTrue, + reason: 'prefs must be reported, never silently dropped', + ); + }); + + test('a prefs purge failure is recorded, not thrown', () async { + final profile = _profile(); + final fs = _FakeFs({profile.home: 10}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + purgePrefs: (_) async => throw Exception('prefs boom'), + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.deleted); + expect(result.skipped.any((s) => s.contains('prefs boom')), isTrue); + }); + + test('a regular file at home is erased, not falsely reported', () async { + // fs.exists() is true for files too, so a recursive directory delete would + // silently no-op while the result still claimed the home was removed. + final profile = _profile(); + final fs = _FakeFs({profile.home: 10}, files: {profile.home}); + final result = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.deleted); + expect(fs.deleted, contains(profile.home)); + expect( + result.removed.any((s) => s.contains('was a file, not a directory')), + isTrue, + reason: 'the file case must be named honestly in the result', + ); + }); + + test('persists the registry removal to disk', () async { + final profile = _profile(); + final fs = _FakeFs({profile.home: 10}); + final registry = _registry([profile]); + await _deleter( + registry: registry, + lifecycle: _lifecycle(running: false), + fs: fs, + ).delete(profile); + + final reloaded = ProfileRegistry.load(makitRoot: _tempRoot.path); + expect(reloaded.byId('work'), isNull); + }); + }); + + group('ProfileDeleter.diskUsage', () { + test('returns the recursive byte sum of the profile home', () async { + final profile = _profile(); + final fs = _FakeFs({profile.home: 123456}); + final usage = await _deleter( + registry: _registry([profile]), + lifecycle: _lifecycle(running: false), + fs: fs, + ).diskUsage(profile); + + expect(usage, 123456); + }); + + test( + 'refuses to measure a home outside ~/.makit* (corrupt registry)', + () async { + // A hand-edited `home: "/"` must never trigger a recursive walk of the + // whole filesystem. + final rogue = _profile(home: '/'); + final fs = _FakeFs({'/': 999999999}); + final usage = await _deleter( + registry: _registry([rogue]), + lifecycle: _lifecycle(running: false), + fs: fs, + ).diskUsage(rogue); + + expect( + usage, + 0, + reason: 'the disk root is not a measurable profile home', + ); + }, + ); + + test('still measures the protected legacy home (~/.makit)', () async { + // The legacy home has no child segment so deletion refuses it, but its + // size must still show in the list. + final legacy = _profile( + id: 'default', + home: '$_homeDir/.makit', + storage: ProfileStorage.legacy, + ); + final fs = _FakeFs({legacy.home: 4242}); + final usage = await _deleter( + registry: _registry([legacy]), + lifecycle: _lifecycle(running: false), + fs: fs, + ).diskUsage(legacy); + + expect(usage, 4242); + }); + }); + + group('ProfileDeleter.delete is best-effort under filesystem failure', () { + test('records a store failure and still returns a result', () async { + // A filesystem error mid-delete must not throw out of the method (leaving + // the caller with no outcome and a half-deleted profile): the failure is + // recorded and the registry entry is still removed. + final profile = _profile(); + final fs = _ThrowingFs({profile.home: 10}, throwOnDeleteDirectory: true); + final registry = _registry([profile]); + final result = await ProfileDeleter( + registry: registry, + lifecycle: _lifecycle(running: false), + activeProfileId: 'other', + homeDir: _homeDir, + fs: fs, + isMacOS: true, + ).delete(profile); + + expect(result.outcome, ProfileDeletionOutcome.deleted); + expect( + result.skipped.any((s) => s.contains('MAKIT_HOME')), + isTrue, + reason: 'the home-delete failure must be reported, not swallowed', + ); + // Step (4) still ran: the registry entry is gone. + expect(registry.byId('work'), isNull); + }); + }); +} diff --git a/app/test/desktop/daemon/profile_lifecycle_test.dart b/app/test/desktop/daemon/profile_lifecycle_test.dart new file mode 100644 index 00000000..06e3d144 --- /dev/null +++ b/app/test/desktop/daemon/profile_lifecycle_test.dart @@ -0,0 +1,386 @@ +// Unit tests for [ProfileLifecycle] (SPEC-50 P3, D7/D8). +// Co-located with the code under test (per SPEC-03 desktop layout). +// ignore_for_file: depend_on_referenced_packages +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_lifecycle.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; + +ServerProfile _profile({ + String id = 'work', + String home = '/home/.makit/profiles/work', +}) => ServerProfile( + id: id, + name: 'Work', + kind: ProfileKind.user, + home: home, + port: 7801, + storage: ProfileStorage.namespaced, +); + +/// Records every spawn so tests can assert the verb and the environment. +class _RecordingRunner { + final List<({String exe, List args, Map? env})> + calls = []; + ProcessResult result = ProcessResult(0, 0, '', ''); + + Future run( + String exe, + List args, { + Map? environment, + }) async { + calls.add((exe: exe, args: args, env: environment)); + return result; + } +} + +MakitCliResolver _resolver({String? path = '/usr/local/bin/makit'}) => + MakitCliResolver( + candidatePaths: const [], + exists: (_) => false, + shellLookup: () async => path, + ); + +void main() { + group('ProfileLifecycle.start/stop', () { + test('start runs `makit start` with the profile MAKIT_HOME', () async { + final runner = _RecordingRunner(); + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: runner.run, + ); + + final result = await lifecycle.start(_profile()); + + expect(result.outcome, DaemonActionOutcome.started); + expect(runner.calls.single.exe, '/usr/local/bin/makit'); + expect(runner.calls.single.args, ['start']); + expect(runner.calls.single.env, { + 'MAKIT_HOME': '/home/.makit/profiles/work', + }); + }); + + test('stop runs `makit stop` with the profile MAKIT_HOME', () async { + final runner = _RecordingRunner(); + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: runner.run, + ); + + final result = await lifecycle.stop(_profile()); + + expect(result.outcome, DaemonActionOutcome.stopped); + expect(runner.calls.single.args, ['stop']); + expect(runner.calls.single.env, { + 'MAKIT_HOME': '/home/.makit/profiles/work', + }); + }); + + test('reports cliNotFound when the CLI cannot be resolved', () async { + final runner = _RecordingRunner(); + final lifecycle = ProfileLifecycle( + resolver: _resolver(path: null), + run: runner.run, + ); + + final result = await lifecycle.start(_profile()); + + expect(result.outcome, DaemonActionOutcome.cliNotFound); + expect(runner.calls, isEmpty); + }); + + test('start uses the TARGET profile\'s serve args and CLI path', () async { + // Lifecycle actions target arbitrary profiles. Using the active profile's + // config started a target on the wrong binary and on the CLI's default + // port instead of the port the registry allocated for it. + final runner = _RecordingRunner(); + final lifecycle = ProfileLifecycle( + resolver: MakitCliResolver( + candidatePaths: const ['/fallback/makit'], + exists: (_) => true, + shellLookup: () async => null, + overridePath: () => '/active/profile/makit', + ), + run: runner.run, + cliPathFor: (p) => '/target/${p.id}/makit', + serveArgsFor: (p) => ['--host', '127.0.0.1', '--port', '${p.port}'], + ); + + final result = await lifecycle.start(_profile()); + + expect(result.outcome, DaemonActionOutcome.started); + expect(runner.calls.single.exe, '/target/work/makit'); + expect(runner.calls.single.args, [ + 'start', + '--host', + '127.0.0.1', + '--port', + '7801', + ]); + }); + + test('stop passes no endpoint args', () async { + final runner = _RecordingRunner(); + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: runner.run, + serveArgsFor: (p) => ['--port', '${p.port}'], + ); + + await lifecycle.stop(_profile()); + + expect(runner.calls.single.args, ['stop']); + }); + + test('reports failed with both streams on a non-zero exit', () async { + final runner = _RecordingRunner() + ..result = ProcessResult(0, 1, 'port in use', 'bind failed'); + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: runner.run, + ); + + final result = await lifecycle.start(_profile()); + + expect(result.outcome, DaemonActionOutcome.failed); + expect(result.message, contains('bind failed')); + expect(result.message, contains('port in use')); + }); + }); + + group('ProfileLifecycle.isRunning', () { + test( + 'false when the control socket is absent (no probe attempted)', + () async { + var probed = false; + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: _RecordingRunner().run, + socketExists: (_) => false, + statusProbe: (_) async { + probed = true; + return true; + }, + ); + + expect(await lifecycle.isRunning(_profile()), isFalse); + expect(probed, isFalse); + }, + ); + + test( + 'false when the socket exists but the probe fails (stale socket)', + () async { + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: _RecordingRunner().run, + socketExists: (_) => true, + statusProbe: (_) async => false, + ); + + expect(await lifecycle.isRunning(_profile()), isFalse); + }, + ); + + test('true when the socket exists and the probe succeeds', () async { + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: _RecordingRunner().run, + socketExists: (_) => true, + statusProbe: (_) async => true, + ); + + expect(await lifecycle.isRunning(_profile()), isTrue); + }); + }); + + group('ProfileLifecycle.stopAndConfirm', () { + test('returns true once the socket disappears after stop', () async { + final runner = _RecordingRunner(); + var polls = 0; + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: runner.run, + // Present for the first two checks, then gone. + socketExists: (_) => polls++ < 2, + // A listener answers while the socket is present, so isRunning is truly + // driven by socketExists and the disappearance path is exercised (not + // short-circuited by the real default probe failing to connect). + statusProbe: (_) async => true, + // No pid file exists for this fake home, so confirmation rests on the + // socket going away — exactly what this test covers. + readPid: (_) => null, + sleep: (_) async {}, + ); + + final ok = await lifecycle.stopAndConfirm( + _profile(), + timeout: const Duration(seconds: 1), + ); + + expect(ok, isTrue); + expect(runner.calls.single.args, ['stop']); + expect(polls, greaterThanOrEqualTo(3), reason: 'must poll until gone'); + }); + + test( + 'returns false only while a daemon is genuinely still listening', + () async { + // Socket present AND the probe answers: a live daemon that ignored the + // stop. Nothing may be unlinked under it. + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: _RecordingRunner().run, + socketExists: (_) => true, + statusProbe: (_) async => true, + sleep: (_) async {}, + ); + + final ok = await lifecycle.stopAndConfirm( + _profile(), + timeout: const Duration(milliseconds: 100), + ); + + expect(ok, isFalse); + }, + ); + + // The orphan case, and the reason D9 exists at all. A daemon killed with + // SIGKILL never unlinks its control socket, and `makit stop` on an already + // dead daemon removes only the pid file -- verified against the real binary: + // after SIGKILL, `makit stop` prints "not running" and control.sock REMAINS. + // Polling the socket file alone therefore reported every crashed profile as + // still running, so ProfileDeleter refused it forever and the 27 orphans in + // the spec's evidence could never be reclaimed. + test('treats a stale socket with no listener as stopped', () async { + var probes = 0; + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: _RecordingRunner().run, + // The socket file never goes away... + socketExists: (_) => true, + // ...but nothing is listening on it. + statusProbe: (_) async { + probes++; + return false; + }, + sleep: (_) async {}, + ); + + final ok = await lifecycle.stopAndConfirm( + _profile(), + timeout: const Duration(seconds: 5), + ); + + expect(ok, isTrue, reason: 'a dead daemon must not block a delete'); + // And it must not have burned the whole timeout to work that out. + expect(probes, lessThanOrEqualTo(2)); + }); + + test( + 'waits for the daemon PID to exit, not just the control socket', + () async { + // The SIGTERM handler closes the socket ~100ms before the process + // actually exits. Confirming on the socket alone would greenlight a + // delete while the daemon is still alive and may still be writing + // makit.db-wal (SPEC-50 D8). + var aliveChecks = 0; + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: _RecordingRunner().run, + socketExists: (_) => false, // socket already gone + readPid: (_) => 4242, + // Alive for the first two polls, then the process exits. + processAlive: (pid) async { + expect(pid, 4242); + return aliveChecks++ < 2; + }, + sleep: (_) async {}, + ); + + final ok = await lifecycle.stopAndConfirm( + _profile(), + timeout: const Duration(seconds: 5), + ); + + expect(ok, isTrue); + expect( + aliveChecks, + greaterThanOrEqualTo(3), + reason: 'must poll the process, not stop at the socket', + ); + }, + ); + + test( + 'returns false when the process is still alive at the timeout', + () async { + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: _RecordingRunner().run, + socketExists: (_) => false, // socket gone... + readPid: (_) => 99, + processAlive: (_) async => true, // ...but the process never exits + sleep: (_) async {}, + ); + + final ok = await lifecycle.stopAndConfirm( + _profile(), + timeout: const Duration(milliseconds: 100), + ); + + expect(ok, isFalse, reason: 'must not unlink under a live process'); + }, + ); + + test('returns false when the stop command itself fails', () async { + // A failed `makit stop` (or a missing CLI) means no shutdown was issued. + // Combined with a stale/absent socket and no pid, the old code would have + // returned true and let the delete proceed under a live daemon. + final runner = _RecordingRunner() + ..result = ProcessResult(0, 1, 'boom', 'bind failed'); + var probed = false; + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: runner.run, + socketExists: (_) { + probed = true; + return false; + }, + readPid: (_) => null, + sleep: (_) async {}, + ); + + final ok = await lifecycle.stopAndConfirm( + _profile(), + timeout: const Duration(seconds: 5), + ); + + expect(ok, isFalse); + expect( + probed, + isFalse, + reason: 'must abort before probing on stop failure', + ); + }); + + test('returns false when the makit CLI cannot be found', () async { + final lifecycle = ProfileLifecycle( + resolver: _resolver(path: null), + run: _RecordingRunner().run, + socketExists: (_) => false, + readPid: (_) => null, + sleep: (_) async {}, + ); + + final ok = await lifecycle.stopAndConfirm( + _profile(), + timeout: const Duration(seconds: 5), + ); + + expect(ok, isFalse); + }); + }); +} diff --git a/app/test/desktop/daemon/profile_registry_perms_test.dart b/app/test/desktop/daemon/profile_registry_perms_test.dart new file mode 100644 index 00000000..867d0c5f --- /dev/null +++ b/app/test/desktop/daemon/profile_registry_perms_test.dart @@ -0,0 +1,73 @@ +// Live, real-filesystem tests for [FileSystemAdapter] permissions (SPEC-50). +// +// The server guarantees MAKIT_HOME is 0700 and its files 0600 +// (server/src/daemon/paths.ts) because that directory holds an APNs auth key and +// a TLS private key. Dart's defaults are 0755/0644, so an app that creates the +// directory first would silently downgrade the server's guarantee. Only a real +// filesystem can prove the modes, so these tests use one (in a temp dir). +// ignore_for_file: depend_on_referenced_packages +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; + +void main() { + late Directory root; + setUp(() => root = Directory.systemTemp.createTempSync('spec50-perm-')); + tearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + /// The octal permission bits (e.g. `700`) of [path]. Uses the Dart API so it + /// is portable — `stat -f %Lp` is BSD-only and fails on the Linux CI VM. + String modeOf(String path) => + (FileStat.statSync(path).mode & 0xFFF).toRadixString(8).padLeft(3, '0'); + + test('the registry file is 0600 and its directory 0700', () { + final home = '${root.path}/.makit'; + const FileSystemAdapter().writeAtomic('$home/profiles.json', '{}\n'); + + expect(File('$home/profiles.json').existsSync(), isTrue); + expect( + modeOf('$home/profiles.json'), + '600', + reason: 'profiles.json is readable by other local users', + ); + expect( + modeOf(home), + '700', + reason: 'the directory holding the APNs and TLS keys is traversable', + ); + }); + + test('rewriting an existing file keeps it 0600', () { + final path = '${root.path}/.makit/profiles.json'; + const fs = FileSystemAdapter(); + fs.writeAtomic(path, '{"a":1}\n'); + fs.writeAtomic(path, '{"a":2}\n'); + expect(File(path).readAsStringSync(), '{"a":2}\n'); + expect(modeOf(path), '600'); + }); + + test('a pre-existing loose directory is tightened, not left open', () { + // The realistic case: something else created ~/.makit first with 0755. + final home = Directory('${root.path}/.makit')..createSync(recursive: true); + Process.runSync('/bin/chmod', ['755', home.path]); + expect(modeOf(home.path), '755'); + + const FileSystemAdapter().writeAtomic('${home.path}/profiles.json', '{}\n'); + + expect(modeOf(home.path), '700'); + }); + + test('the temp file never lingers after a successful write', () { + final home = '${root.path}/.makit'; + const FileSystemAdapter().writeAtomic('$home/profiles.json', '{}\n'); + final leftovers = Directory(home) + .listSync() + .map((e) => e.path.split('/').last) + .where((n) => n.contains('.tmp')) + .toList(); + expect(leftovers, isEmpty); + }); +} diff --git a/app/test/desktop/daemon/profile_registry_test.dart b/app/test/desktop/daemon/profile_registry_test.dart new file mode 100644 index 00000000..c02db2c4 --- /dev/null +++ b/app/test/desktop/daemon/profile_registry_test.dart @@ -0,0 +1,921 @@ +// Unit tests for [ServerProfile] and [ProfileRegistry] (SPEC-50 P1). +// Co-located with the code under test (per SPEC-03 desktop layout). +// ignore_for_file: depend_on_referenced_packages +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; +import 'package:makit/desktop/daemon/server_profile_paths.dart'; + +/// An in-memory [FileSystemAdapter] so no test touches a real `profiles.json`. +class _MemoryFs extends FileSystemAdapter { + _MemoryFs([this.seed]); + final String? seed; + final Map written = {}; + + @override + String? readOrNull(String path) => written[path] ?? seed; + + @override + void writeAtomic(String path, String contents) => written[path] = contents; + @override + T withLock(String path, T Function() body) => body(); +} + +/// Every port is free. +Future _allFree(int port) async => true; + +/// Only ports in [free] are available. +PortProbe _onlyFree(Set free) => + (port) async => free.contains(port); + +const String kHome = '/Users/dev'; +const String kRoot = '$kHome/.makit'; +const String kDevRoot = '/Users/dev/work/feat-profiles'; +const String kDevExe = + '$kDevRoot/app/build/macos/Build/Products/Release/' + 'makit.app/Contents/MacOS/makit'; +const String kInstalledExe = '/Applications/makit.app/Contents/MacOS/makit'; + +ProfileRegistry _empty({PortProbe? probe}) => ProfileRegistry( + makitRoot: kRoot, + profiles: const [], + probe: probe ?? _allFree, +); + +const ServerProfile _legacy = ServerProfile( + id: 'default', + name: 'Makit', + kind: ProfileKind.user, + home: '$kHome/.makit', + port: kDefaultServerPort, + storage: ProfileStorage.legacy, +); + +const ServerProfile _dev = ServerProfile( + id: 'a1b2c3d4', + name: 'feat-profiles', + kind: ProfileKind.dev, + home: '$kHome/.makit-dev/a1b2c3d4', + port: 7813, + storage: ProfileStorage.namespaced, + origin: kDevRoot, +); + +void main() { + group('ServerProfile', () { + test('legacy storage keeps the shipped key layout', () { + // The whole point of D2: renameable, but its storage never moves. + expect(_legacy.prefsKeyPrefix, ''); + expect(_legacy.secureStoreNamespace, isNull); + expect(_legacy.isProtected, isTrue); + expect(_legacy.controlSocketPath, '$kHome/.makit/control.sock'); + expect(_legacy.environment, {'MAKIT_HOME': '$kHome/.makit'}); + }); + + test('namespaced storage prefixes keys and secrets by id', () { + expect(_dev.prefsKeyPrefix, 'a1b2c3d4.'); + expect(_dev.secureStoreNamespace, 'a1b2c3d4'); + expect(_dev.isProtected, isFalse); + }); + + test('the effective prefs key matches the pre-SPEC-50 layout exactly', () { + // D11's no-migration claim, asserted rather than argued in prose: + // shared_preferences composes '$_prefix$key', so with the default + // 'flutter.' prefix our key must reproduce the old + // setPrefix('flutter..') + 'desktop_server_port'. + expect( + 'flutter.${_dev.prefsKeyPrefix}desktop_server_port', + 'flutter.a1b2c3d4.desktop_server_port', + ); + expect( + 'flutter.${_legacy.prefsKeyPrefix}desktop_server_port', + 'flutter.desktop_server_port', + ); + }); + + test('every profile has a titled window, including the legacy one', () { + expect(_legacy.windowTitle, 'Makit — Makit'); + expect(_dev.windowTitle, 'Makit — feat-profiles'); + }); + + // The invariant that made the D11 switch-over a no-op rather than a + // migration: the effective stored key stays byte-for-byte what the old + // `SharedPreferences.setPrefix` mechanism produced (`flutter..`, + // or `flutter.` for the legacy profile). + test( + 'prefsKeyPrefix yields the historical setPrefix key for every profile', + () { + for (final key in ['desktop_server_port', 'groups.v2', 'a.b.c']) { + // Legacy: unprefixed, so the effective key is the shipped `flutter.`. + expect('flutter.${_legacy.prefsKeyPrefix}$key', 'flutter.$key'); + // Namespaced: `flutter..`, identical to setPrefix('flutter..'). + expect( + 'flutter.${_dev.prefsKeyPrefix}$key', + 'flutter.${_dev.id}.$key', + reason: 'key layout diverged for ${_dev.id}/$key', + ); + } + }, + ); + + test('json round-trips, and omits origin when absent', () { + expect(ServerProfile.fromJson(_dev.toJson()), _dev); + expect(ServerProfile.fromJson(_legacy.toJson()), _legacy); + expect(_legacy.toJson().keys.contains('origin'), isFalse); + expect(_dev.toJson()['origin'], kDevRoot); + }); + + test('fromJson drops an entry with no identity', () { + expect(ServerProfile.fromJson({'name': 'x', 'home': '/h'}), isNull); + expect(ServerProfile.fromJson({'id': 'x'}), isNull); + expect(ServerProfile.fromJson({'id': '', 'home': '/h'}), isNull); + }); + + test( + 'fromJson rejects a relative home (must be an absolute MAKIT_HOME)', + () { + // A relative home resolves against the spawned CLI's cwd, putting the + // daemon and its data in the wrong place. + expect(ServerProfile.fromJson({'id': 'x', 'home': '.makit'}), isNull); + expect( + ServerProfile.fromJson({'id': 'x', 'home': 'relative/path'}), + isNull, + ); + expect(ServerProfile.fromJson({'id': 'x', 'home': ''}), isNull); + // An absolute home is accepted. + expect( + ServerProfile.fromJson({'id': 'x', 'home': '/abs/home'}), + isNotNull, + ); + }, + ); + + test('fromJson repairs a missing name/port/storage', () { + final p = ServerProfile.fromJson({'id': 'x', 'home': '/h'})!; + expect(p.name, 'x'); + expect(p.port, kFallbackServerPort); + expect(p.storage, ProfileStorage.namespaced); + expect(p.kind, ProfileKind.user); + }); + + test('fromJson falls back for an out-of-range port', () { + // A hand-edited `{"port": 70000}` cannot be bound; it must fall back the + // same way a missing/non-positive port does, not wedge the profile. + final tooHigh = ServerProfile.fromJson({ + 'id': 'x', + 'home': '/h', + 'port': 70000, + })!; + expect(tooHigh.port, kFallbackServerPort); + final zero = ServerProfile.fromJson({ + 'id': 'x', + 'home': '/h', + 'port': 0, + })!; + expect(zero.port, kFallbackServerPort); + // A valid port is kept. + final ok = ServerProfile.fromJson({ + 'id': 'x', + 'home': '/h', + 'port': 8123, + })!; + expect(ok.port, 8123); + }); + + test('copyWith cannot change frozen identity fields', () { + final renamed = _dev.copyWith(name: 'other', port: 7999); + expect(renamed.id, _dev.id); + expect(renamed.kind, _dev.kind); + expect(renamed.storage, _dev.storage); + expect(renamed.name, 'other'); + expect(renamed.port, 7999); + }); + }); + + group('ProfileRegistry.resolveFor', () { + test('installed app bootstraps the legacy profile once', () async { + final reg = _empty(); + final first = await reg.resolveFor( + executablePath: kInstalledExe, + home: kHome, + ); + expect(first.created, isTrue); + expect(first.profile.storage, ProfileStorage.legacy); + expect(first.profile.home, '$kHome/.makit'); + expect(first.profile.port, kDefaultServerPort); + + final second = await reg.resolveFor( + executablePath: kInstalledExe, + home: kHome, + ); + expect(second.created, isFalse); + expect(second.profile, first.profile); + expect(reg.profiles.length, 1); + }); + + test('a dev build gets its own namespaced profile', () async { + final reg = _empty(); + final r = await reg.resolveFor(executablePath: kDevExe, home: kHome); + expect(r.created, isTrue); + expect(r.profile.kind, ProfileKind.dev); + expect(r.profile.origin, kDevRoot); + expect(r.profile.name, 'feat-profiles'); + expect(r.profile.home, '$kHome/.makit-dev/${r.profile.id}'); + expect(r.profile.storage, ProfileStorage.namespaced); + }); + + // The orphaning bug this class exists to kill: a *rebuilt* app at the same + // origin must re-bind, not fork. + test('a rebuilt dev app re-binds to its existing profile', () async { + final reg = _empty(); + final first = await reg.resolveFor(executablePath: kDevExe, home: kHome); + final again = await reg.resolveFor( + executablePath: + '$kDevRoot/app/build/macos/Build/Products/Debug/' + 'makit.app/Contents/MacOS/makit', + home: kHome, + ); + expect(again.created, isFalse); + expect(again.profile.id, first.profile.id); + expect(reg.profiles.length, 1); + }); + + test('two different worktrees get two different profiles', () async { + final reg = _empty(); + final a = await reg.resolveFor(executablePath: kDevExe, home: kHome); + final b = await reg.resolveFor( + executablePath: + '/Users/dev/work/other/app/build/macos/Build/Products/' + 'Release/makit.app/Contents/MacOS/makit', + home: kHome, + ); + expect(a.profile.id, isNot(b.profile.id)); + expect(a.profile.port, isNot(b.profile.port)); + expect(reg.profiles.length, 2); + }); + }); + + group('ProfileRegistry.allocatePort', () { + test('takes the guess when it is free', () async { + expect(await _empty().allocatePort(startingGuess: 7813), 7813); + }); + + // The collision bug: two worktrees whose hashes agree mod 100 used to get + // the same port, and the second daemon simply died. + test('skips a port already claimed by another profile', () async { + final reg = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + profiles: const [ + ServerProfile( + id: 'a', + name: 'a', + kind: ProfileKind.dev, + home: '/h/a', + port: 7813, + storage: ProfileStorage.namespaced, + ), + ], + ); + expect(await reg.allocatePort(startingGuess: 7813), 7814); + }); + + test('skips a port a foreign process holds', () async { + final reg = _empty(probe: _onlyFree({7815})); + expect(await reg.allocatePort(startingGuess: 7813), 7815); + }); + + test('wraps around the dev range', () async { + final reg = _empty(probe: _onlyFree({7801})); + expect(await reg.allocatePort(startingGuess: 7899), 7801); + }); + + test('falls back to the guess when the whole range is busy', () async { + // Better to hand back the guess and let the daemon report EADDRINUSE than + // to throw during launch. + final reg = _empty(probe: _onlyFree(const {})); + expect(await reg.allocatePort(startingGuess: 7842), 7842); + }); + + test('never returns a port below the dev range', () async { + expect( + await _empty().allocatePort(startingGuess: 80), + greaterThanOrEqualTo(kDevPortRangeStart), + ); + }); + }); + + group('ProfileRegistry persistence', () { + test('save writes a profiles.json the loader accepts', () { + final fs = _MemoryFs(); + final reg = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [ + ServerProfile( + id: 'default', + name: 'Work', + kind: ProfileKind.user, + home: '$kHome/.makit', + port: 7777, + storage: ProfileStorage.legacy, + ), + ], + ); + reg.save(); + final raw = fs.written['$kRoot/profiles.json']!; + expect(jsonDecode(raw), isA>()); + + final again = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(raw), + probe: _allFree, + ); + expect(again.profiles.single.name, 'Work'); + expect(again.profiles.single.storage, ProfileStorage.legacy); + }); + + test('a corrupt file yields an empty registry, not a crash', () { + final reg = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs('{not json'), + probe: _allFree, + ); + expect(reg.profiles, isEmpty); + }); + + test('a missing file yields an empty registry', () { + final reg = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(), + probe: _allFree, + ); + expect(reg.profiles, isEmpty); + }); + + test('a duplicate id is dropped, first-seen wins', () { + final raw = jsonEncode({ + 'profiles': [ + {'id': 'x', 'home': '/a', 'name': 'first'}, + {'id': 'x', 'home': '/b', 'name': 'second'}, + ], + }); + final reg = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(raw), + probe: _allFree, + ); + expect(reg.profiles.single.name, 'first'); + }); + + test('a bare JSON array is accepted too', () { + final raw = jsonEncode([ + {'id': 'x', 'home': '/a', 'name': 'first'}, + ]); + final reg = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(raw), + probe: _allFree, + ); + expect(reg.profiles.single.id, 'x'); + }); + + // SPEC-50 D1 runs several instances at once, each with its own in-memory + // list. A plain whole-file write loses whatever another window added: a + // `user` profile has no `origin`, so resolveFor could never re-bind it and + // its home/pairings/prefs would be orphaned -- the exact failure this class + // exists to prevent. + test('save merges in a profile another instance added', () async { + final fs = _MemoryFs(); + final a = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy], + ); + final b = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy], + ); + + // Window A creates Personal and saves. + await a.createUserProfile(name: 'Personal'); + a.save(); + + // Window B, which never saw Personal, now saves its own edit. + b.rename('default', 'Work'); + b.save(); + + final onDisk = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(fs.written['$kRoot/profiles.json']), + probe: _allFree, + ); + expect( + onDisk.byId('personal'), + isNotNull, + reason: 'window B clobbered a profile window A created', + ); + expect(onDisk.byId('default')!.name, 'Work'); + }); + + test('a merge cannot resurrect a profile this instance deleted', () { + final fs = _MemoryFs(); + final reg = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy, _dev], + ); + reg.save(); + // Someone else's stale copy still lists _dev; ours deletes it. + expect(reg.remove(_dev.id), isTrue); + reg.save(); + + final onDisk = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(fs.written['$kRoot/profiles.json']), + probe: _allFree, + ); + expect(onDisk.byId(_dev.id), isNull); + }); + + test('an unmodified profile does not revert another window\'s rename', () { + // Window A renames X and saves; window B (loaded before) saves an + // unrelated change. B must not write its stale copy of X back over A's + // rename (SPEC-50 D1 lost-update). + final fs = _MemoryFs(); + final a = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy, _dev], + ); + final b = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy, _dev], + ); + + a.rename(_dev.id, 'Renamed'); + a.save(); + + // B never touched _dev, but still holds it; its unrelated save must keep + // A's rename rather than clobber it. + b.rename('default', 'Work'); + b.save(); + + final onDisk = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(fs.written['$kRoot/profiles.json']), + probe: _allFree, + ); + expect(onDisk.byId(_dev.id)!.name, 'Renamed'); + expect(onDisk.byId('default')!.name, 'Work'); + }); + + test('a deletion by another window is honoured, not resurrected', () { + // A deletes X (persisting a tombstone) after B loaded X. B's later save + // must not bring X back — its on-disk stores are already gone. + final fs = _MemoryFs(); + final a = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy, _dev], + ); + final b = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy, _dev], + ); + a.save(); + + a.remove(_dev.id); + a.save(); // writes deletedIds: [_dev.id] + + // B still lists _dev and saves an unrelated edit. + b.rename('default', 'Work'); + b.save(); + + final onDisk = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(fs.written['$kRoot/profiles.json']), + probe: _allFree, + ); + expect( + onDisk.byId(_dev.id), + isNull, + reason: 'a stale window resurrected a deleted profile', + ); + }); + + test( + 'a profile created after a delete is not dropped by the tombstone', + () async { + // _uniqueId must avoid tombstoned ids: reusing a just-deleted slug would + // make save() drop the new profile (it honours the tombstone) and orphan + // its home. + final fs = _MemoryFs(); + final reg = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy], + ); + final first = await reg.createUserProfile(name: 'Personal'); + reg.save(); + expect(reg.remove(first.id), isTrue); // tombstones 'personal' + reg.save(); + + final second = await reg.createUserProfile(name: 'Personal'); + expect( + second.id, + isNot(first.id), + reason: 'must not mint a tombstoned id', + ); + reg.save(); + + final onDisk = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(fs.written['$kRoot/profiles.json']), + probe: _allFree, + ); + expect( + onDisk.byId(second.id), + isNotNull, + reason: + 'the new profile must persist, not be swallowed by the tombstone', + ); + }, + ); + + test('a duplicate port on load is reassigned to a free one', () { + // A hand-edited (or fallback-7777) namespaced profile that collides with + // the legacy port must be moved off it, or its daemon fails to bind. + final raw = jsonEncode({ + 'profiles': [ + { + 'id': 'default', + 'home': '/h/.makit', + 'name': 'Work', + 'storage': 'legacy', + 'port': 7777, + }, + { + 'id': 'clash', + 'home': '/h/.makit-dev/clash', + 'name': 'Clash', + 'storage': 'namespaced', + 'port': 7777, + }, + ], + }); + final reg = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(raw), + probe: _allFree, + ); + expect(reg.byId('default')!.port, 7777, reason: 'legacy keeps its port'); + expect(reg.byId('clash')!.port, isNot(7777)); + }); + + test('a second legacy profile is dropped on load (D2)', () { + final raw = jsonEncode({ + 'profiles': [ + {'id': 'default', 'home': '/h/.makit', 'storage': 'legacy'}, + {'id': 'intruder', 'home': '/h/.makit2', 'storage': 'legacy'}, + ], + }); + final reg = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(raw), + probe: _allFree, + ); + expect(reg.byId('default'), isNotNull); + expect( + reg.byId('intruder'), + isNull, + reason: 'two legacy profiles would share creds and prefs', + ); + }); + + test( + 'two instances that allocate the same port are reconciled on save', + () async { + // Each instance probes with only its own list visible, so both can pick + // the same free port. save() must detect the post-merge collision and + // move one, or a daemon fails with EADDRINUSE next launch. + final fs = _MemoryFs(); + final a = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy], + ); + final b = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy], + ); + + final pa = await a.createUserProfile(name: 'Alpha'); + final pb = await b.createUserProfile(name: 'Beta'); + expect(pa.port, pb.port, reason: 'the scenario needs them to collide'); + final contestedPort = pa.port; + + a.save(); // Alpha is persisted first, on the contested port. + b.save(); // Beta collides; the already-persisted Alpha must keep it. + + final onDisk = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(fs.written['$kRoot/profiles.json']), + probe: _allFree, + ); + final ports = onDisk.profiles.map((p) => p.port).toList(); + expect( + ports.toSet().length, + ports.length, + reason: 'no two profiles may share a port after the merge', + ); + // The on-disk (possibly-running) profile keeps its port; the newcomer + // yields. + expect(onDisk.byId(pa.id)!.port, contestedPort); + expect(onDisk.byId(pb.id)!.port, isNot(contestedPort)); + }, + ); + // The id is interpolated into a filesystem path (the secure-store namespace + // file) and into preference keys, and profiles.json is user-writable. + test('an id that could escape its directory is dropped', () { + for (final bad in [ + '../../../../tmp/x', + 'a/b', + 'a.b', + '..', + '.', + 'UPPER', + '-leading', + '', + 'has space', + ]) { + expect( + ServerProfile.fromJson({'id': bad, 'home': '/h'}), + isNull, + reason: 'accepted unsafe id "$bad"', + ); + } + // ...and the ids the registry itself mints still parse. + for (final good in ['default', 'a1b2c3d4', 'my-personal', 'personal-2']) { + expect( + ServerProfile.fromJson({'id': good, 'home': '/h'}), + isNotNull, + reason: 'rejected a legitimate id "$good"', + ); + } + }); + }); + + group('ProfileRegistry mutations', () { + Future seeded() async { + final reg = _empty(); + await reg.resolveFor(executablePath: kInstalledExe, home: kHome); + await reg.resolveFor(executablePath: kDevExe, home: kHome); + return reg; + } + + test('createUserProfile slugs the name into id and home', () async { + final reg = await seeded(); + final p = await reg.createUserProfile(name: 'My Personal!'); + expect(p.id, 'my-personal'); + expect(p.home, '$kRoot/profiles/my-personal'); + expect(p.kind, ProfileKind.user); + expect(p.storage, ProfileStorage.namespaced); + expect(p.isProtected, isFalse); + }); + + test('a duplicate name gets a distinct id, not a shared home', () async { + final reg = await seeded(); + final a = await reg.createUserProfile(name: 'Personal'); + final b = await reg.createUserProfile(name: 'Personal'); + expect(a.id, 'personal'); + expect(b.id, 'personal-2'); + expect(a.home, isNot(b.home)); + }); + + test('a blank name is refused', () async { + final reg = await seeded(); + expect( + () => reg.createUserProfile(name: ' '), + throwsA(isA()), + ); + }); + + // A mint/validate mismatch is data loss, not a cosmetic wart: an id the + // registry creates but fromJson later rejects means the profile silently + // vanishes on the next launch, taking its home and pairings with it. + test('a very long name still mints an id that survives a reload', () async { + final reg = await seeded(); + final created = await reg.createUserProfile(name: 'Q' * 200); + + expect(isSafeProfileId(created.id), isTrue); + expect(created.id.length, lessThanOrEqualTo(64)); + expect(created.id, isNot(endsWith('-'))); + expect(reg.allIdsRoundTrip, isTrue); + // And it really does come back. + expect(ServerProfile.fromJson(created.toJson()), created); + }); + + test( + 'two long names with the same prefix still get distinct ids', + () async { + final reg = await seeded(); + final a = await reg.createUserProfile(name: 'Z' * 100); + final b = await reg.createUserProfile(name: 'Z' * 100); + expect(a.id, isNot(b.id)); + expect(isSafeProfileId(a.id), isTrue); + expect(isSafeProfileId(b.id), isTrue); + expect(a.home, isNot(b.home)); + }, + ); + + test('the legacy profile can be renamed but never removed', () async { + final reg = await seeded(); + expect(reg.rename('default', 'Work'), isTrue); + expect(reg.byId('default')!.name, 'Work'); + // D2/D8: it holds AuthKey_*.p8, ota/ and push.json. + expect(reg.remove('default'), isFalse); + expect(reg.byId('default'), isNotNull); + }); + + test('a dev profile can be removed', () async { + final reg = await seeded(); + final dev = reg.profiles.firstWhere((p) => p.kind == ProfileKind.dev); + expect(reg.remove(dev.id), isTrue); + expect(reg.byId(dev.id), isNull); + }); + + test('rename refuses a blank name and an unknown id', () async { + final reg = await seeded(); + expect(reg.rename('default', ' '), isFalse); + expect(reg.rename('nope', 'x'), isFalse); + }); + + test('setPort persists a retried port and validates the range', () async { + final reg = await seeded(); + expect(reg.setPort('default', 7900), isTrue); + expect(reg.byId('default')!.port, 7900); + expect(reg.setPort('default', 0), isFalse); + expect(reg.setPort('default', 70000), isFalse); + }); + + test('setOrigin re-points a moved dev build', () async { + final reg = await seeded(); + final dev = reg.profiles.firstWhere((p) => p.kind == ProfileKind.dev); + expect(reg.setOrigin(dev.id, '/moved/here'), isTrue); + expect(reg.byId(dev.id)!.origin, '/moved/here'); + }); + }); + + group('last active profile', () { + Future seeded(_MemoryFs fs) async { + final reg = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy], + ); + await reg.createUserProfile(name: 'Personal'); + return reg; + } + + test('round-trips through profiles.json', () async { + final fs = _MemoryFs(); + final reg = await seeded(fs); + expect(reg.setLastActive('personal'), isTrue); + reg.save(); + + final again = ProfileRegistry.load( + makitRoot: kRoot, + fs: _MemoryFs(fs.written['$kRoot/profiles.json']), + probe: _allFree, + ); + expect(again.lastActiveId, 'personal'); + }); + + test( + 'an unknown id is refused, so a stale value is never written', + () async { + final reg = await seeded(_MemoryFs()); + expect(reg.setLastActive('nope'), isFalse); + expect(reg.lastActiveId, isNull); + }, + ); + + test('the installed app reopens the last profile chosen', () async { + final reg = await seeded(_MemoryFs()); + reg.setLastActive('personal'); + expect(reg.preferredFor(_legacy).id, 'personal'); + }); + + // A dev build exists to isolate its worktree. Reopening Work would make + // building that worktree look like it did nothing. + test( + 'a dev build always opens its own profile, ignoring last active', + () async { + final reg = await seeded(_MemoryFs()); + reg.setLastActive('personal'); + expect(reg.preferredFor(_dev).id, _dev.id); + }, + ); + + test( + 'a last-active profile that has since been deleted falls back', + () async { + final reg = await seeded(_MemoryFs()); + reg.setLastActive('personal'); + reg.remove('personal'); + expect(reg.preferredFor(_legacy).id, _legacy.id); + }, + ); + + test('absent lastActive leaves the file free of the key', () async { + final fs = _MemoryFs(); + (await seeded(fs)).save(); + expect(fs.written['$kRoot/profiles.json'], isNot(contains('lastActive'))); + }); + + test( + 'save() preserves a newer on-disk lastActive it did not set itself', + () async { + // Shared "disk": both registries read and write the same map. + final fs = _MemoryFs(); + final regA = ProfileRegistry( + makitRoot: kRoot, + probe: _allFree, + fs: fs, + profiles: const [_legacy], + ); + await regA.createUserProfile(name: 'Personal'); + regA.save(); // disk: [legacy, personal], no lastActive + + // Another window loads the same disk and switches the active profile. + final regB = ProfileRegistry.load( + makitRoot: kRoot, + fs: fs, + probe: _allFree, + ); + expect(regB.setLastActive('personal'), isTrue); + regB.save(); // disk lastActive = personal + + // regA (loaded before that, never touched lastActive) saves an + // unrelated rename. It must NOT clobber the newer selection. + expect(regA.rename('personal', 'Personal 2'), isTrue); + regA.save(); + + final reloaded = ProfileRegistry.load( + makitRoot: kRoot, + fs: fs, + probe: _allFree, + ); + expect(reloaded.lastActiveId, 'personal'); + }, + ); + }); + + group('ProfileRegistry.staleProfiles', () { + test('lists dev profiles whose origin is gone, and only those', () async { + final reg = _empty(); + await reg.resolveFor(executablePath: kInstalledExe, home: kHome); + await reg.resolveFor(executablePath: kDevExe, home: kHome); + await reg.createUserProfile(name: 'Personal'); + + final stale = reg.staleProfiles(dirExists: (_) => false); + expect(stale.length, 1); + expect(stale.single.origin, kDevRoot); + + expect(reg.staleProfiles(dirExists: (_) => true), isEmpty); + }); + }); + + group('probePortIsFree', () { + test('reports a really-held port as busy and a free one as free', () async { + final held = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async => held.close()); + expect(await probePortIsFree(held.port), isFalse); + + final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final freePort = free.port; + await free.close(); + expect(await probePortIsFree(freePort), isTrue); + }); + }); +} diff --git a/app/test/desktop/daemon/profile_runtime_test.dart b/app/test/desktop/daemon/profile_runtime_test.dart new file mode 100644 index 00000000..8b1ecc39 --- /dev/null +++ b/app/test/desktop/daemon/profile_runtime_test.dart @@ -0,0 +1,210 @@ +// Tests for the profile-switch sequence (SPEC-50 D10). +// +// `verifyThenHandOver` is the safety property of a switch: the target must be +// confirmed *answering* before anything is torn down, so a target that cannot +// come up leaves the window exactly as it was. The irreversible half — building +// the new runtime, swapping the ProviderScope, disposing the old graph — is +// injected as `handOver`, so these tests can assert the thing that matters: +// whether it is called at all. +// ignore_for_file: depend_on_referenced_packages, invalid_use_of_visible_for_testing_member +import 'dart:io'; + +import 'package:flutter/foundation.dart' show FlutterError; +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_runtime.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _NoWriteFs extends FileSystemAdapter { + @override + String? readOrNull(String path) => null; + @override + void writeAtomic(String path, String contents) {} + @override + T withLock(String path, T Function() body) => body(); +} + +ProfileRegistry _registry() => ProfileRegistry( + makitRoot: '/h/.makit', + fs: _NoWriteFs(), + profiles: const [_target], +); + +Future _prefs() async { + SharedPreferences.setMockInitialValues({}); + return SharedPreferences.getInstance(); +} + +const ServerProfile _target = ServerProfile( + id: 'personal', + name: 'Personal', + kind: ProfileKind.user, + home: '/h/.makit/profiles/personal', + port: 7805, + storage: ProfileStorage.namespaced, +); + +MakitCliResolver _resolver() => MakitCliResolver( + candidatePaths: const [], + exists: (_) => false, + shellLookup: () async => '/usr/local/bin/makit', +); + +/// A lifecycle whose liveness answers come from [answers], consumed in order, so +/// a test can say "down, then up after the start". +ProfileLifecycle _lifecycle({ + required List answers, + int startExit = 0, + String startStdout = '', + List? spawned, +}) { + final queue = [...answers]; + return ProfileLifecycle( + resolver: _resolver(), + run: (exe, args, {environment}) async { + spawned?.addAll(args); + return ProcessResult(0, startExit, startStdout, ''); + }, + socketExists: (_) => true, + statusProbe: (_) async => queue.isEmpty ? false : queue.removeAt(0), + sleep: (_) async {}, + ); +} + +void main() { + group('verifyThenHandOver', () { + test('hands over without starting an already-running target', () async { + final spawned = []; + var handedOver = 0; + final failure = await verifyThenHandOver( + target: _target, + lifecycle: _lifecycle(answers: [true], spawned: spawned), + handOver: () async => handedOver++, + ); + + expect(failure, isNull); + expect(handedOver, 1); + expect(spawned, isEmpty, reason: 'a running target must not be started'); + }); + + test('starts a stopped target, then hands over', () async { + final spawned = []; + var handedOver = 0; + final failure = await verifyThenHandOver( + target: _target, + // down, then up once started + lifecycle: _lifecycle(answers: [false, true], spawned: spawned), + handOver: () async => handedOver++, + ); + + expect(failure, isNull); + expect(handedOver, 1); + expect(spawned, contains('start')); + }); + + // The property the whole design exists for: a target that will not start must + // leave the current profile untouched. + test('does NOT hand over when the target refuses to start', () async { + var handedOver = 0; + final failure = await verifyThenHandOver( + target: _target, + lifecycle: _lifecycle( + answers: [false], + startExit: 1, + startStdout: 'makit: failed to start — no response within 3000ms', + ), + handOver: () async => handedOver++, + ); + + expect(handedOver, 0, reason: 'the window was torn down on a failure'); + expect(failure, isNotNull); + expect(failure, contains('failed to start')); + }); + + // The subtler failure: `makit start` exits 0 but nothing is listening. Taking + // exit 0 as proof would hand the window to a dead server. + test('does NOT hand over when a started target never answers', () async { + var handedOver = 0; + final failure = await verifyThenHandOver( + target: _target, + lifecycle: _lifecycle(answers: [false, false]), + handOver: () async => handedOver++, + ); + + expect(handedOver, 0); + expect(failure, contains('not answering')); + expect(failure, contains('Personal')); + }); + + test('a failure names the profile, so the report is actionable', () async { + final failure = await verifyThenHandOver( + target: _target, + lifecycle: _lifecycle(answers: [false], startExit: 1), + handOver: () async {}, + ); + expect(failure, contains('Personal')); + }); + + test('hands over exactly once', () async { + var handedOver = 0; + await verifyThenHandOver( + target: _target, + lifecycle: _lifecycle(answers: [true, true, true]), + handOver: () async => handedOver++, + ); + expect(handedOver, 1); + }); + }); + + group('ProfileRuntime', () { + test('exposes per-profile objects wired to the profile', () async { + // A real runtime, but nothing is started: create() is synchronous and must + // not touch the network or the daemon. + final runtime = ProfileRuntime.create( + profile: _target, + registry: _registry(), + prefs: await _prefs(), + ); + addTearDown(runtime.dispose); + + expect(runtime.profile, _target); + expect(runtime.configController.current.port, _target.port); + // The deleter must consider THIS profile active, or a window could delete + // the profile it is using. + expect( + runtime.profileDeleter.activeProfileId, + _target.id, + reason: 'the runtime must protect its own profile from deletion', + ); + expect(runtime.profilesController.activeProfileId, _target.id); + }); + + test('dispose is safe on a runtime that never connected', () async { + final runtime = ProfileRuntime.create( + profile: _target, + registry: _registry(), + prefs: await _prefs(), + ); + await expectLater(runtime.dispose(), completes); + }); + + test('dispose disposes the profilesController (no leak per switch)', () async { + // profilesController is injected via overrideWithValue, which Riverpod does + // not dispose, so the runtime must. A disposed ChangeNotifier throws when a + // listener is added. + final runtime = ProfileRuntime.create( + profile: _target, + registry: _registry(), + prefs: await _prefs(), + ); + await runtime.dispose(); + expect( + () => runtime.profilesController.addListener(() {}), + throwsA(isA()), + ); + }); + }); +} diff --git a/app/test/desktop/daemon/profiles_controller_test.dart b/app/test/desktop/daemon/profiles_controller_test.dart new file mode 100644 index 00000000..32d37873 --- /dev/null +++ b/app/test/desktop/daemon/profiles_controller_test.dart @@ -0,0 +1,219 @@ +// Unit tests for [ProfilesController] (SPEC-50 P3). +// ignore_for_file: depend_on_referenced_packages +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/profiles_controller.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; + +class _MemoryFs extends FileSystemAdapter { + final Map written = {}; + @override + String? readOrNull(String path) => written[path]; + @override + void writeAtomic(String path, String contents) => written[path] = contents; + @override + T withLock(String path, T Function() body) => body(); +} + +Future _allFree(int port) async => true; + +ServerProfile _p( + String id, { + ProfileKind kind = ProfileKind.user, + String? origin, + ProfileStorage storage = ProfileStorage.namespaced, + int port = 7800, +}) => ServerProfile( + id: id, + name: id, + kind: kind, + home: '/h/$id', + port: port, + storage: storage, + origin: origin, +); + +void main() { + group('ProfilesController', () { + late _MemoryFs fs; + setUp(() => fs = _MemoryFs()); + + ProfilesController build({ + List? profiles, + String active = 'default', + RunningProbe? isRunning, + DiskProbe? diskUsage, + bool Function(String)? dirExists, + }) => ProfilesController( + registry: ProfileRegistry( + makitRoot: '/h/.makit', + probe: _allFree, + fs: fs, + profiles: + profiles ?? + [ + _p('default', storage: ProfileStorage.legacy, port: 7777), + _p('dev1', kind: ProfileKind.dev, origin: '/gone', port: 7801), + ], + ), + activeProfileId: active, + isRunning: isRunning, + diskUsage: diskUsage, + dirExists: dirExists, + ); + + test('the active profile sorts first', () { + final c = build(active: 'dev1'); + expect(c.rows.first.profile.id, 'dev1'); + expect(c.active!.id, 'dev1'); + }); + + test('user profiles sort before dev profiles', () { + final c = build( + profiles: [ + _p('zzz-dev', kind: ProfileKind.dev, port: 7801), + _p('aaa-user', port: 7802), + _p('default', storage: ProfileStorage.legacy, port: 7777), + ], + active: 'default', + ); + final ids = c.rows.map((r) => r.profile.id).toList(); + expect(ids.first, 'default'); + expect(ids.indexOf('aaa-user'), lessThan(ids.indexOf('zzz-dev'))); + }); + + test('active is null when the registry lost it', () { + expect(build(active: 'nope').active, isNull); + }); + + test('a dev profile with a missing origin is stale; others are not', () { + final c = build(dirExists: (_) => false); + final stale = c.rows.where((r) => r.stale).toList(); + expect(stale.length, 1); + expect(stale.single.profile.id, 'dev1'); + // The legacy/user profile has no origin, so it can never be stale. + expect( + c.rows.firstWhere((r) => r.profile.id == 'default').stale, + isFalse, + ); + }); + + test('nothing is stale when origins still exist', () { + expect(build(dirExists: (_) => true).rows.any((r) => r.stale), isFalse); + }); + + test('refresh records running state and disk usage', () async { + final c = build( + isRunning: (p) async => p.id == 'dev1', + diskUsage: (p) async => p.id == 'dev1' ? 4096 : 128, + dirExists: (_) => true, + ); + await c.refresh(); + final dev = c.rows.firstWhere((r) => r.profile.id == 'dev1'); + expect(dev.running, isTrue); + expect(dev.diskBytes, 4096); + expect( + c.rows.firstWhere((r) => r.profile.id == 'default').running, + isFalse, + ); + }); + + test('refresh survives a throwing probe and still notifies', () async { + // A probe reads the live world and can throw. One failure must not abort + // the loop (skipping later profiles) nor suppress notifyListeners, which + // three call sites depend on after a delete. + var notified = 0; + final c = build( + diskUsage: (p) async { + if (p.id == 'default') throw Exception('boom'); + return 4096; + }, + dirExists: (_) => true, + ); + c.addListener(() => notified++); + + await c.refresh(); + + // The later profile was still probed despite the earlier throw... + expect(c.rows.firstWhere((r) => r.profile.id == 'dev1').diskBytes, 4096); + // ...and listeners were notified exactly once. + expect(notified, 1); + }); + + test('diskBytes is null before measurement, not zero', () { + // A profile shown as "0 B" would be a lie; null lets the UI say nothing. + expect(build().rows.every((r) => r.diskBytes == null), isTrue); + }); + + test('staleSummary sums only the stale rows', () async { + final c = build( + diskUsage: (p) async => p.id == 'dev1' ? 1024 : 999999, + dirExists: (p) => false, + ); + await c.refresh(); + final s = c.staleSummary; + expect(s.rows.length, 1); + expect(s.bytes, 1024); + }); + + test('an active stale profile stays listed and out of staleSummary', () async { + // If the active profile's source folder is gone it is still stale, but it + // must not fall into the reclaim group (whose deleter refuses the active + // profile). It stays in the main list so its switch-away-&-delete works. + final c = build(active: 'dev1', dirExists: (_) => false); + await c.refresh(); + expect(c.rows.firstWhere((r) => r.profile.id == 'dev1').stale, isTrue); + expect( + c.staleSummary.rows.any((r) => r.profile.id == 'dev1'), + isFalse, + reason: 'the active profile must not be offered in the reclaim group', + ); + }); + + test('create persists and notifies', () async { + final c = build(); + var notified = 0; + c.addListener(() => notified++); + final created = await c.create('Personal'); + expect(created, isNotNull); + expect(created!.name, 'Personal'); + expect(c.registry.byId(created.id), isNotNull); + expect(notified, 1); + // Persisted, not just held in memory: a profile the user named must + // survive a relaunch. + expect(fs.written['/h/.makit/profiles.json'], contains('Personal')); + }); + + test('create refuses a blank name without throwing', () async { + final c = build(); + expect(await c.create(' '), isNull); + }); + + test('rename updates and notifies; an unknown id does neither', () { + final c = build(); + var notified = 0; + c.addListener(() => notified++); + expect(c.rename('dev1', 'Renamed'), isTrue); + expect(c.registry.byId('dev1')!.name, 'Renamed'); + expect(notified, 1); + + expect(c.rename('nope', 'x'), isFalse); + expect(notified, 1); + }); + + test('forget removes a dev profile but never the protected one', () { + final c = build(); + expect(c.forget('default'), isFalse); + expect(c.registry.byId('default'), isNotNull); + + expect(c.forget('dev1'), isTrue); + expect(c.registry.byId('dev1'), isNull); + }); + + test('noteRunning flips one row without a full refresh', () { + final c = build(); + c.noteRunning('dev1', running: true); + expect(c.rows.firstWhere((r) => r.profile.id == 'dev1').running, isTrue); + }); + }); +} diff --git a/app/test/desktop/server_config_test.dart b/app/test/desktop/server_config_test.dart index 142c9fa1..928ce35a 100644 --- a/app/test/desktop/server_config_test.dart +++ b/app/test/desktop/server_config_test.dart @@ -1,116 +1,194 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:makit/desktop/settings/server_config.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; void main() { - test( - 'defaults to auto bind mode on port 7777 with no CLI override', - () async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final cfg = ServerConfigController.load(prefs); - expect(cfg.bindMode, ServerBindMode.auto); - expect(cfg.customHost, ''); - expect(cfg.port, 7777); - expect(cfg.cliPath, ''); - }, - ); + test('defaults to myDevices, no LAN fallback, port 7777, no CLI', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final cfg = ServerConfigController.load(ProfileScopedPrefs.unscoped(prefs)); + expect(cfg.reachability, Reachability.myDevices); + expect(cfg.allowLanFallback, isFalse); + expect(cfg.customHost, ''); + expect(cfg.port, 7777); + expect(cfg.cliPath, ''); + }); - test('load reads persisted values', () async { + test('load reads persisted new-schema values', () async { SharedPreferences.setMockInitialValues({ - 'desktop_server_bind_mode': 'custom', + 'desktop_server_reachability': 'thisMacOnly', + 'desktop_server_allow_lan_fallback': true, 'desktop_server_custom_host': '0.0.0.0', 'desktop_server_port': 9000, 'desktop_server_cli_path': '/opt/makit/makit', }); final prefs = await SharedPreferences.getInstance(); - final cfg = ServerConfigController.load(prefs); - expect(cfg.bindMode, ServerBindMode.custom); + final cfg = ServerConfigController.load(ProfileScopedPrefs.unscoped(prefs)); + expect(cfg.reachability, Reachability.thisMacOnly); + expect(cfg.allowLanFallback, isTrue); expect(cfg.customHost, '0.0.0.0'); expect(cfg.port, 9000); expect(cfg.cliPath, '/opt/makit/makit'); }); - group('legacy host migration', () { - test('a deliberately-set non-loopback host migrates to custom', () async { - SharedPreferences.setMockInitialValues({ + group('bind-mode migration (pre-SPEC-50 desktop_server_bind_mode)', () { + Future migrate(Map values) async { + SharedPreferences.setMockInitialValues(values); + final prefs = await SharedPreferences.getInstance(); + return ServerConfigController.load(ProfileScopedPrefs.unscoped(prefs)); + } + + test('auto → myDevices, no LAN fallback, no custom host', () async { + final cfg = await migrate({'desktop_server_bind_mode': 'auto'}); + expect(cfg.reachability, Reachability.myDevices); + expect(cfg.allowLanFallback, isFalse); + expect(cfg.customHost, ''); + }); + + test('lan → myDevices with allowLanFallback true', () async { + final cfg = await migrate({'desktop_server_bind_mode': 'lan'}); + expect(cfg.reachability, Reachability.myDevices); + expect(cfg.allowLanFallback, isTrue); + expect(cfg.customHost, ''); + }); + + test('loopback → thisMacOnly', () async { + final cfg = await migrate({'desktop_server_bind_mode': 'loopback'}); + expect(cfg.reachability, Reachability.thisMacOnly); + expect(cfg.allowLanFallback, isFalse); + }); + + test('custom → myDevices, retains the custom host', () async { + final cfg = await migrate({ + 'desktop_server_bind_mode': 'custom', + 'desktop_server_custom_host': '0.0.0.0', + }); + expect(cfg.reachability, Reachability.myDevices); + expect(cfg.customHost, '0.0.0.0'); + }); + + test('a stale custom host under auto is not carried over', () async { + final cfg = await migrate({ + 'desktop_server_bind_mode': 'auto', + 'desktop_server_custom_host': '0.0.0.0', + }); + expect(cfg.reachability, Reachability.myDevices); + expect(cfg.customHost, ''); + }); + }); + + group('legacy host migration (desktop_server_host)', () { + Future migrate(Map values) async { + SharedPreferences.setMockInitialValues(values); + final prefs = await SharedPreferences.getInstance(); + return ServerConfigController.load(ProfileScopedPrefs.unscoped(prefs)); + } + + test('a deliberately-set non-loopback host → myDevices + custom', () async { + final cfg = await migrate({ 'desktop_server_host': '100.1.2.3', 'desktop_server_port': 9100, }); - final prefs = await SharedPreferences.getInstance(); - final cfg = ServerConfigController.load(prefs); - expect(cfg.bindMode, ServerBindMode.custom); + expect(cfg.reachability, Reachability.myDevices); expect(cfg.customHost, '100.1.2.3'); expect(cfg.port, 9100); }); - test('the old default loopback host migrates to auto', () async { - SharedPreferences.setMockInitialValues({ - 'desktop_server_host': 'localhost', - }); - final prefs = await SharedPreferences.getInstance(); - final cfg = ServerConfigController.load(prefs); - expect(cfg.bindMode, ServerBindMode.auto); + test('the old default loopback host → default myDevices', () async { + final cfg = await migrate({'desktop_server_host': 'localhost'}); + expect(cfg.reachability, Reachability.myDevices); expect(cfg.customHost, ''); }); - test('an explicit new bind mode wins over a stale legacy host', () async { - SharedPreferences.setMockInitialValues({ + test('a new-schema value wins over a stale legacy host', () async { + final cfg = await migrate({ + 'desktop_server_reachability': 'thisMacOnly', + 'desktop_server_host': '100.1.2.3', + }); + expect(cfg.reachability, Reachability.thisMacOnly); + }); + + test('a bind-mode value wins over a stale legacy host', () async { + final cfg = await migrate({ 'desktop_server_bind_mode': 'lan', 'desktop_server_host': '100.1.2.3', }); - final prefs = await SharedPreferences.getInstance(); - final cfg = ServerConfigController.load(prefs); - expect(cfg.bindMode, ServerBindMode.lan); + expect(cfg.reachability, Reachability.myDevices); + expect(cfg.allowLanFallback, isTrue); + }); + + // Layer precedence, top to bottom, with ALL THREE present at once. A user + // who upgrades twice keeps every generation of key on disk, so the newest + // must win outright -- otherwise a stale pre-SPEC-50 bind mode would quietly + // re-open a server the user had since restricted to this Mac. + test('the newest schema wins when every generation is present', () async { + final cfg = await migrate({ + 'desktop_server_reachability': 'thisMacOnly', + 'desktop_server_allow_lan_fallback': true, + 'desktop_server_bind_mode': 'lan', + 'desktop_server_host': '100.1.2.3', + }); + expect( + cfg.reachability, + Reachability.thisMacOnly, + reason: 'a stale bind mode re-opened a restricted server', + ); + expect(cfg.serveArgs(), contains('127.0.0.1')); }); }); group('serveArgs', () { - test('auto passes no --host/--lan, only the port', () { - expect(const ServerConfig().serveArgs(), ['--port', '7777']); + test('thisMacOnly pins 127.0.0.1', () { + const cfg = ServerConfig(reachability: Reachability.thisMacOnly); + expect(cfg.serveArgs(), ['--host', '127.0.0.1', '--port', '7777']); }); - test('lan passes --lan', () { - const cfg = ServerConfig(bindMode: ServerBindMode.lan, port: 8000); - expect(cfg.serveArgs(), ['--lan', '--port', '8000']); + test('myDevices with no fallback passes no host flag', () { + const cfg = ServerConfig(port: 8000); + expect(cfg.serveArgs(), ['--port', '8000']); }); - test('loopback pins 127.0.0.1', () { - const cfg = ServerConfig(bindMode: ServerBindMode.loopback); - expect(cfg.serveArgs(), ['--host', '127.0.0.1', '--port', '7777']); + test('myDevices with LAN fallback passes --lan', () { + const cfg = ServerConfig(allowLanFallback: true, port: 8000); + expect(cfg.serveArgs(), ['--lan', '--port', '8000']); }); - test('custom forwards the explicit host', () { + test('a non-empty custom host wins (explicit escape hatch)', () { const cfg = ServerConfig( - bindMode: ServerBindMode.custom, + reachability: Reachability.thisMacOnly, + allowLanFallback: true, customHost: '0.0.0.0', ); expect(cfg.serveArgs(), ['--host', '0.0.0.0', '--port', '7777']); }); - test('custom with a blank host falls back to auto', () { - const cfg = ServerConfig( - bindMode: ServerBindMode.custom, - customHost: ' ', - ); - expect(cfg.serveArgs(), ['--port', '7777']); + test('a blank custom host is ignored', () { + const cfg = ServerConfig(customHost: ' ', allowLanFallback: true); + expect(cfg.serveArgs(), ['--lan', '--port', '7777']); }); }); test('setters persist and blank/invalid falls back to defaults', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final controller = ServerConfigController(prefs, const ServerConfig()); + final controller = ServerConfigController( + ProfileScopedPrefs.unscoped(prefs), + const ServerConfig(), + ); - await controller.setBindMode(ServerBindMode.lan); + await controller.setReachability(Reachability.thisMacOnly); + await controller.setAllowLanFallback(true); await controller.setCustomHost('example.local'); await controller.setPort(9100); await controller.setCliPath('/opt/makit/makit'); - expect(controller.state.bindMode, ServerBindMode.lan); + expect(controller.state.reachability, Reachability.thisMacOnly); + expect(controller.state.allowLanFallback, isTrue); expect(controller.state.customHost, 'example.local'); expect(controller.state.port, 9100); expect(controller.state.cliPath, '/opt/makit/makit'); - expect(prefs.getString('desktop_server_bind_mode'), 'lan'); + expect(prefs.getString('desktop_server_reachability'), 'thisMacOnly'); + expect(prefs.getBool('desktop_server_allow_lan_fallback'), isTrue); expect(prefs.getString('desktop_server_custom_host'), 'example.local'); expect(prefs.getInt('desktop_server_port'), 9100); expect(prefs.getString('desktop_server_cli_path'), '/opt/makit/makit'); @@ -123,7 +201,7 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final controller = ServerConfigController( - prefs, + ProfileScopedPrefs.unscoped(prefs), const ServerConfig(port: 7842), defaultPort: 7842, ); @@ -132,4 +210,30 @@ void main() { expect(controller.state.port, 7842); expect(prefs.getInt('desktop_server_port'), 7842); }); + + test('setReachability clears a custom host so the choice takes effect', () async { + // serveArgs gives a non-empty customHost precedence, so a stale 0.0.0.0 left + // over from Advanced would keep the server network-reachable after the user + // picks “Just this Mac”. The explicit reachability choice must win. + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final controller = ServerConfigController( + ProfileScopedPrefs.unscoped(prefs), + const ServerConfig(), + ); + + await controller.setCustomHost('0.0.0.0'); + expect(controller.state.customHost, '0.0.0.0'); + + await controller.setReachability(Reachability.thisMacOnly); + + expect(controller.state.customHost, ''); + expect(prefs.getString('desktop_server_custom_host'), ''); + expect(controller.state.serveArgs(), [ + '--host', + '127.0.0.1', + '--port', + '7777', + ]); + }); } diff --git a/app/test/desktop/server_control_integration_test.dart b/app/test/desktop/server_control_integration_test.dart index 00772188..88f796b3 100644 --- a/app/test/desktop/server_control_integration_test.dart +++ b/app/test/desktop/server_control_integration_test.dart @@ -15,6 +15,7 @@ import 'package:makit/desktop/desktop_controller.dart'; import 'package:makit/desktop/screens/fake_control_client.dart'; import 'package:makit/desktop/settings/server_config.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; const _cliPath = '/usr/local/bin/makit'; @@ -38,7 +39,7 @@ void main() { ]) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - return ServerConfigController(prefs, initial); + return ServerConfigController(ProfileScopedPrefs.unscoped(prefs), initial); } DesktopController build(ServerConfigController config) { @@ -75,7 +76,7 @@ void main() { test('LAN mode forwards --lan', () async { final config = await makeConfig(); - await config.setBindMode(ServerBindMode.lan); + await config.setAllowLanFallback(true); final controller = build(config); addTearDown(controller.dispose); @@ -86,7 +87,7 @@ void main() { test('loopback mode pins --host 127.0.0.1', () async { final config = await makeConfig(); - await config.setBindMode(ServerBindMode.loopback); + await config.setReachability(Reachability.thisMacOnly); final controller = build(config); addTearDown(controller.dispose); @@ -104,7 +105,6 @@ void main() { test('custom mode forwards the explicit host and port on restart', () async { final config = await makeConfig(); - await config.setBindMode(ServerBindMode.custom); await config.setCustomHost('0.0.0.0'); await config.setPort(7788); final controller = build(config); @@ -146,7 +146,7 @@ void main() { // Change the bind mode after the controller was built; the serveArgs // closure reads live config, so the next start reflects it. calls.clear(); - await config.setBindMode(ServerBindMode.lan); + await config.setAllowLanFallback(true); await controller.start(); expect(calls.single, [_cliPath, 'start', '--lan', '--port', '7777']); }, diff --git a/app/test/desktop/settings/general_section_test.dart b/app/test/desktop/settings/general_section_test.dart new file mode 100644 index 00000000..93aa26c6 --- /dev/null +++ b/app/test/desktop/settings/general_section_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/cli_installer.dart'; +import 'package:makit/desktop/screens/providers.dart' show cliInstallerProvider; +import 'package:makit/desktop/settings/sections/general_section.dart'; +import 'package:makit/status/status_center.dart'; +import 'package:makit/status/status_providers.dart'; + +Future _pump( + WidgetTester tester, { + required CliInstaller installer, + StatusCenter? statusCenter, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + cliInstallerProvider.overrideWithValue(installer), + if (statusCenter != null) + statusCenterProvider.overrideWithValue(statusCenter), + ], + child: const MaterialApp(home: Scaffold(body: GeneralSection())), + ), + ); + await tester.pump(); +} + +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('cli_install_general')); + tearDown(() => tmp.deleteSync(recursive: true)); + + CliInstaller installerWithBundle({required bool bundled}) { + final path = '${tmp.path}/Resources/makit/makit'; + if (bundled) { + File(path).createSync(recursive: true); + } + return CliInstaller(bundledCliPath: () => path, homeDir: () => tmp.path); + } + + testWidgets('Install CLI is shown here and installs on tap', (tester) async { + final center = StatusCenter(); + addTearDown(center.dispose); + await _pump( + tester, + installer: installerWithBundle(bundled: true), + statusCenter: center, + ); + + final button = find.widgetWithText(OutlinedButton, 'Install CLI'); + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pump(); + await tester.pump(); + + expect(File('${tmp.path}/.local/bin/makit').existsSync(), isTrue); + expect(center.events.single.title, startsWith('Installed makit CLI')); + }); + + testWidgets('the Install button is hidden without a bundled CLI', ( + tester, + ) async { + await _pump(tester, installer: installerWithBundle(bundled: false)); + expect(find.widgetWithText(OutlinedButton, 'Install CLI'), findsNothing); + // The row itself still explains why. + expect( + find.text('This build has no bundled CLI to install.'), + findsOneWidget, + ); + }); +} diff --git a/app/test/desktop/settings/profile_switch_sheet_test.dart b/app/test/desktop/settings/profile_switch_sheet_test.dart new file mode 100644 index 00000000..ed557954 --- /dev/null +++ b/app/test/desktop/settings/profile_switch_sheet_test.dart @@ -0,0 +1,196 @@ +// Tests for the profile-switch confirmation sheets (SPEC-50 D10). +// +// These sheets are the last thing between a click and an irreversible action, so +// what they *say* is part of the contract, not decoration. In particular +// `confirmSwitchAwayAndDelete` gates a delete that erases a profile's database, +// media, pairings and TLS identity — it had no coverage at all until `ocr` pointed +// that out. +// ignore_for_file: depend_on_referenced_packages +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/app/theme.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; +import 'package:makit/desktop/settings/sections/profile_switch_sheet.dart'; + +const ServerProfile _work = ServerProfile( + id: 'work', + name: 'Work', + kind: ProfileKind.user, + home: '/h/.makit', + port: 7777, + storage: ProfileStorage.legacy, +); + +const ServerProfile _personal = ServerProfile( + id: 'personal', + name: 'Personal', + kind: ProfileKind.user, + home: '/h/.makit/profiles/personal', + port: 7805, + storage: ProfileStorage.namespaced, +); + +/// Mounts a button that opens [open] and records what it returned. +Future> _run( + WidgetTester tester, + Future Function(BuildContext) open, +) async { + final results = []; + await tester.pumpWidget( + MaterialApp( + theme: makitDarkTheme, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async => results.add(await open(context)), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return results; +} + +void main() { + group('confirmProfileSwitch', () { + Future> open( + WidgetTester tester, { + bool targetRunning = true, + }) => _run( + tester, + (context) => confirmProfileSwitch( + context, + from: _work, + to: _personal, + targetRunning: targetRunning, + ), + ); + + testWidgets('names the target and both halves of the consequence', ( + tester, + ) async { + await open(tester); + expect(find.text('Switch to “Personal”?'), findsOneWidget); + expect(find.text('WHAT HAPPENS HERE'), findsOneWidget); + expect(find.text('WHAT KEEPS RUNNING'), findsOneWidget); + // The reassuring half is the point: switching must not read as "stops my + // work". + expect(find.textContaining('Work’s server stays up'), findsOneWidget); + expect( + find.textContaining('Work’s agents are not interrupted'), + findsOneWidget, + ); + expect(find.textContaining('stay paired'), findsOneWidget); + }); + + testWidgets('promises to start the target only when it is stopped', ( + tester, + ) async { + await open(tester, targetRunning: false); + expect(find.textContaining('Personal’s server starts'), findsOneWidget); + }); + + testWidgets('does not promise a start when the target already runs', ( + tester, + ) async { + await open(tester); + expect( + find.textContaining('server starts'), + findsNothing, + reason: 'claiming to start a running server is a small lie', + ); + }); + + testWidgets('returns true only when confirmed', (tester) async { + final results = await open(tester); + await tester.tap(find.text('Switch to Personal')); + await tester.pumpAndSettle(); + expect(results, [true]); + }); + + testWidgets('returns false on Cancel', (tester) async { + final results = await open(tester); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('returns false when dismissed by tapping outside', ( + tester, + ) async { + // A dismissed dialog pops `null`; treating that as consent would switch a + // window the user only meant to look at. + final results = await open(tester); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + }); + + group('confirmSwitchAwayAndDelete', () { + Future> open(WidgetTester tester) => _run( + tester, + (context) => + confirmSwitchAwayAndDelete(context, victim: _personal, target: _work), + ); + + testWidgets('leads with the delete, not the switch', (tester) async { + // The switch is the mechanism; the deletion is what the user must weigh. + await open(tester); + expect(find.text('Delete “Personal”?'), findsOneWidget); + }); + + testWidgets('explains why a switch is involved at all', (tester) async { + await open(tester); + expect( + find.textContaining('the profile this window is using'), + findsOneWidget, + ); + expect(find.textContaining('switch to “Work” first'), findsOneWidget); + }); + + testWidgets('names what is destroyed and what survives', (tester) async { + await open(tester); + expect(find.text('WHAT HAPPENS'), findsOneWidget); + expect(find.text('WHAT IS KEPT'), findsOneWidget); + expect( + find.textContaining('sessions, transcripts, pairings'), + findsOneWidget, + ); + // Without this line, "delete" next to a path beside your worktree reads as + // "deletes my branch". + expect( + find.textContaining('worktrees and repos are never touched'), + findsOneWidget, + ); + expect( + find.textContaining('every other profile is unaffected'), + findsOneWidget, + ); + }); + + testWidgets('returns true only when confirmed', (tester) async { + final results = await open(tester); + await tester.tap(find.textContaining('Switch & delete')); + await tester.pumpAndSettle(); + expect(results, [true]); + }); + + testWidgets('returns false on Cancel', (tester) async { + final results = await open(tester); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('returns false when dismissed', (tester) async { + final results = await open(tester); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + }); +} diff --git a/app/test/desktop/settings/profiles_section_test.dart b/app/test/desktop/settings/profiles_section_test.dart new file mode 100644 index 00000000..3f6e4f9b --- /dev/null +++ b/app/test/desktop/settings/profiles_section_test.dart @@ -0,0 +1,572 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_deleter.dart'; +import 'package:makit/desktop/daemon/profile_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/profiles_controller.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; +import 'package:makit/desktop/settings/sections/profiles_providers.dart'; +import 'package:makit/desktop/settings/sections/profiles_section.dart'; +import 'package:makit/status/status_center.dart'; +import 'package:makit/status/status_event.dart'; +import 'package:makit/status/status_providers.dart'; + +/// A registry [FileSystemAdapter] that persists nowhere, so `save()` in tests +/// touches no disk. +class _MemFs extends FileSystemAdapter { + const _MemFs(); + @override + String? readOrNull(String path) => null; + @override + void writeAtomic(String path, String contents) {} + // Without this, the base withLock creates `.lock` on the REAL disk + // (under a non-existent `/Users/test`), which breaks the no-disk guarantee and + // stalls the widget test's pumpAndSettle. + @override + T withLock(String path, T Function() body) => body(); +} + +/// A registry adapter whose persistence fails, to prove the UI reports a save +/// failure instead of leaking an unhandled async error. +class _FailingFs extends FileSystemAdapter { + const _FailingFs(); + @override + String? readOrNull(String path) => null; + @override + void writeAtomic(String path, String contents) => + throw const FileSystemException('read-only filesystem'); + @override + T withLock(String path, T Function() body) => body(); +} + +/// An in-memory [ProfileFileSystem] for the deleter: only the paths seeded in +/// [sizes] exist, and deletes are recorded rather than performed. +class _FakeProfileFs implements ProfileFileSystem { + _FakeProfileFs(this.sizes); + final Map sizes; + final Set deleted = {}; + + @override + bool exists(String path) => + sizes.containsKey(path) && !deleted.contains(path); + @override + bool isDirectory(String path) => exists(path); + @override + Future sizeOf(String path) async => sizes[path] ?? 0; + @override + Future deleteDirectory(String path) async => deleted.add(path); + @override + Future deleteFile(String path) async => deleted.add(path); + @override + String? resolveRealPath(String path) => null; +} + +const _homeDir = '/Users/test'; + +ServerProfile _legacy({String id = 'work', String name = 'Work'}) => + ServerProfile( + id: id, + name: name, + kind: ProfileKind.user, + home: '$_homeDir/.makit', + port: 7777, + storage: ProfileStorage.legacy, + ); + +ServerProfile _dev({ + String id = 'a1b2c3d4', + String name = 'feat-profiles', + String? origin = '/Users/test/.worktrees/makit/feat-profiles', + String? home, +}) => ServerProfile( + id: id, + name: name, + kind: ProfileKind.dev, + home: home ?? '$_homeDir/.makit-dev/$id', + port: 7813, + storage: ProfileStorage.namespaced, + origin: origin, +); + +ProfileLifecycle _okLifecycle() => ProfileLifecycle( + resolver: MakitCliResolver( + candidatePaths: const ['/opt/homebrew/bin/makit'], + exists: (path) => path == '/opt/homebrew/bin/makit', + shellLookup: () async => null, + ), + run: (exe, args, {environment}) async => ProcessResult(0, 0, '', ''), + socketExists: (path) => false, + statusProbe: (profile) async => false, + sleep: (d) async {}, +); + +ProfileLifecycle _failingLifecycle(String stdout) => ProfileLifecycle( + resolver: MakitCliResolver( + candidatePaths: const ['/opt/homebrew/bin/makit'], + exists: (path) => path == '/opt/homebrew/bin/makit', + shellLookup: () async => null, + ), + run: (exe, args, {environment}) async => ProcessResult(0, 1, stdout, ''), + socketExists: (path) => false, + sleep: (d) async {}, +); + +({ + ProfilesController controller, + ProfileDeleter deleter, + ProfileLifecycle lifecycle, + ProfileRegistry registry, +}) +_wiring({ + required List profiles, + required String activeId, + Map running = const {}, + Map disk = const {}, + Set existingOrigins = const {}, + Map? fsSizes, + ProfileLifecycle? lifecycle, + bool failWrites = false, +}) { + final registry = ProfileRegistry( + makitRoot: '$_homeDir/.makit', + profiles: profiles, + probe: (port) async => true, + fs: failWrites ? const _FailingFs() : const _MemFs(), + ); + final controller = ProfilesController( + registry: registry, + activeProfileId: activeId, + isRunning: (p) async => running[p.id] ?? false, + diskUsage: (p) async => disk[p.id] ?? 0, + dirExists: (path) => existingOrigins.contains(path), + ); + final life = lifecycle ?? _okLifecycle(); + final deleter = ProfileDeleter( + registry: registry, + lifecycle: life, + activeProfileId: activeId, + homeDir: _homeDir, + fs: _FakeProfileFs(fsSizes ?? const {}), + isMacOS: false, + ); + return ( + controller: controller, + deleter: deleter, + lifecycle: life, + registry: registry, + ); +} + +Future _pump( + WidgetTester tester, { + required ProfilesController controller, + required ProfileDeleter deleter, + required ProfileLifecycle lifecycle, + StatusCenter? statusCenter, +}) async { + tester.view.physicalSize = const Size(1400, 2000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + profilesControllerProvider.overrideWithValue(controller), + profileDeleterProvider.overrideWithValue(deleter), + profileLifecycleProvider.overrideWithValue(lifecycle), + if (statusCenter != null) + statusCenterProvider.overrideWithValue(statusCenter), + ], + child: const MaterialApp(home: Scaffold(body: ProfilesSection())), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('lists profiles with active and dev-build pills', (tester) async { + final w = _wiring( + profiles: [_legacy(), _dev()], + activeId: 'work', + running: const {'work': true}, + disk: const {'work': 128 * 1024 * 1024, 'a1b2c3d4': 4600000}, + existingOrigins: const {'/Users/test/.worktrees/makit/feat-profiles'}, + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + expect(find.text('Work'), findsOneWidget); + expect(find.text('feat-profiles'), findsOneWidget); + expect(find.text('active'), findsOneWidget); + expect(find.text('dev build'), findsOneWidget); + expect(find.text('Running'), findsWidgets); + }); + + testWidgets('stale group is hidden when nothing is stale', (tester) async { + final w = _wiring( + profiles: [_legacy(), _dev()], + activeId: 'work', + existingOrigins: const {'/Users/test/.worktrees/makit/feat-profiles'}, + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + expect(find.text('STALE — SOURCE FOLDER IS GONE'), findsNothing); + }); + + testWidgets('stale group appears only when a dev origin is gone', ( + tester, + ) async { + final w = _wiring( + profiles: [_legacy(), _dev()], + activeId: 'work', + disk: const {'a1b2c3d4': 4600000}, + existingOrigins: const {}, // origin folder is gone → stale + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + expect(find.text('STALE — SOURCE FOLDER IS GONE'), findsOneWidget); + expect(find.text('1 orphaned dev profile'), findsOneWidget); + expect(find.text('Review…'), findsOneWidget); + }); + + // A stale profile is represented by the stale group. Listing it in the main + // group as well showed it TWICE, and on a real machine (27 orphans measured) + // the dead profiles crowd the live ones off the screen entirely. + testWidgets('a stale profile is listed once, in the stale group only', ( + tester, + ) async { + final w = _wiring( + profiles: [_legacy(), _dev()], + activeId: 'work', + disk: const {'a1b2c3d4': 4600000}, + existingOrigins: const {}, // the dev profile's origin is gone → stale + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + // The stale one is summarised... + expect(find.text('1 orphaned dev profile'), findsOneWidget); + // ...and does NOT also appear as an ordinary row. + expect( + find.text('feat-profiles'), + findsNothing, + reason: 'a stale profile appeared in the main list as well', + ); + // The live profile is unaffected. + expect(find.text('Work'), findsOneWidget); + }); + + testWidgets('a protected profile offers no Delete in its menu', ( + tester, + ) async { + final w = _wiring( + profiles: [_legacy()], + activeId: 'work', + existingOrigins: const {}, + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + await tester.tap(find.byType(PopupMenuButton).first); + await tester.pumpAndSettle(); + + expect(find.text('Rename…'), findsOneWidget); + expect(find.text('Delete…'), findsNothing); + expect(find.text('Switch away & delete…'), findsNothing); + }); + + testWidgets('the active non-protected profile disables Delete with a note', ( + tester, + ) async { + // A namespaced active profile (not the legacy one): Delete is present but + // disabled, phrased as "switch away & delete". + final w = _wiring( + profiles: [_dev(id: 'active-dev', origin: null)], + activeId: 'active-dev', + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + await tester.tap(find.byType(PopupMenuButton).first); + await tester.pumpAndSettle(); + expect(find.text('Switch away & delete…'), findsOneWidget); + }); + + testWidgets('the active profile menu offers no Start/Stop', (tester) async { + // Stopping the daemon this window talks to is self-defeating (D7): the + // menu must omit the toggle for the active profile. + final w = _wiring( + profiles: [_dev(id: 'active-dev', origin: null)], + activeId: 'active-dev', + running: const {'active-dev': true}, + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + await tester.tap(find.byType(PopupMenuButton).first); + await tester.pumpAndSettle(); + + expect(find.text('Rename…'), findsOneWidget); + expect(find.widgetWithText(PopupMenuItem, 'Stop'), findsNothing); + expect(find.widgetWithText(PopupMenuItem, 'Start'), findsNothing); + }); + + testWidgets( + 'the active profile detail delete button is enabled (switch away & delete)', + (tester) async { + // Regression: the danger-zone button was permanently disabled for the + // active profile even though it promises the switch-then-delete flow. + final w = _wiring( + profiles: [_dev(id: 'active-dev', name: 'Active', origin: null)], + activeId: 'active-dev', + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + // Expand the profile's inline detail. + await tester.tap(find.byType(ListTile).first); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.widgetWithText(OutlinedButton, 'Switch away & delete…'), + ); + expect( + button.onPressed, + isNotNull, + reason: 'the active danger-zone button must run switch-away-&-delete', + ); + }, + ); + + testWidgets('delete sheet names both what goes and what stays', ( + tester, + ) async { + final w = _wiring( + profiles: [_legacy(), _dev()], + activeId: 'work', + disk: const {'a1b2c3d4': 4600000}, + existingOrigins: const {'/Users/test/.worktrees/makit/feat-profiles'}, + fsSizes: const {'/Users/test/.makit-dev/a1b2c3d4': 4600000}, + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + // The dev profile's menu is the second one (active work is first). + await tester.tap(find.byType(PopupMenuButton).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete…')); + await tester.pumpAndSettle(); + + expect(find.text('Delete “feat-profiles”?'), findsOneWidget); + expect(find.text('WILL BE DELETED'), findsOneWidget); + expect(find.text('WILL BE KEPT'), findsOneWidget); + expect(find.text('worktrees and repos are never touched'), findsOneWidget); + expect(find.text('every other profile is unaffected'), findsOneWidget); + }); + + testWidgets('a refusal surfaces its reason through the status center', ( + tester, + ) async { + final center = StatusCenter(); + addTearDown(center.dispose); + // A non-active, non-protected profile whose home is OUTSIDE ~/.makit* — the + // deleter refuses with refusedUnsafePath. + final unsafe = _dev(id: 'bad', origin: null, home: '/tmp/outside'); + final w = _wiring(profiles: [_legacy(), unsafe], activeId: 'work'); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + statusCenter: center, + ); + + await tester.tap(find.byType(PopupMenuButton).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete…')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete profile')); + await tester.pumpAndSettle(); + + final failures = center.events.where( + (e) => e.severity == StatusSeverity.failure, + ); + expect(failures, isNotEmpty); + expect(failures.last.detail, contains('outside')); + }); + + testWidgets('a successful delete reports bytes freed and the skipped prefs', ( + tester, + ) async { + final center = StatusCenter(); + addTearDown(center.dispose); + final w = _wiring( + profiles: [_legacy(), _dev()], + activeId: 'work', + disk: const {'a1b2c3d4': 4600000}, + existingOrigins: const {'/Users/test/.worktrees/makit/feat-profiles'}, + fsSizes: const {'/Users/test/.makit-dev/a1b2c3d4': 4600000}, + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + statusCenter: center, + ); + + await tester.tap(find.byType(PopupMenuButton).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete…')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete profile')); + await tester.pumpAndSettle(); + // Advance the binding's (fake) clock so the delete's async work settles. + // A bare `await Future.delayed(...)` deadlocks here: inside `testWidgets` + // the clock only moves when the tester pumps. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + final successes = center.events.where( + (e) => e.severity == StatusSeverity.success, + ); + expect(successes, isNotEmpty); + expect(successes.last.title, contains('Deleted feat-profiles')); + // Stores the deleter could not purge are surfaced, not hidden. This wiring + // has no prefs, so the prefs store is honestly reported as skipped. + expect(successes.last.detail, contains('preference keys')); + // The registry entry is gone, so the row disappears. + expect(find.text('feat-profiles'), findsNothing); + }); + + testWidgets('New profile prompts for a name and creates it', (tester) async { + final w = _wiring(profiles: [_legacy()], activeId: 'work'); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + ); + + await tester.tap(find.text('New profile')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Personal'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(w.registry.profiles.any((p) => p.name == 'Personal'), isTrue); + expect(find.text('Personal'), findsOneWidget); + }); + + testWidgets('a rename whose save fails is reported and reverted', ( + tester, + ) async { + // rename() mutates in memory and then persists. If the save throws, the row + // must not keep showing a name that never reached disk. + final center = StatusCenter(); + addTearDown(center.dispose); + final w = _wiring( + profiles: [_legacy(), _dev(origin: null)], + activeId: 'work', + failWrites: true, + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + statusCenter: center, + ); + + await tester.tap(find.byType(PopupMenuButton).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Rename…')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Renamed'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final failures = center.events.where( + (e) => e.severity == StatusSeverity.failure, + ); + expect(failures, isNotEmpty); + expect(failures.last.title, contains('Could not rename profile')); + // Reverted: the row shows the persisted name, not the failed rename. + expect(find.text('Renamed'), findsNothing); + expect(find.text('feat-profiles'), findsOneWidget); + expect(w.registry.byId('a1b2c3d4')!.name, 'feat-profiles'); + }); + + testWidgets('a failed Start is reported through the status center', ( + tester, + ) async { + final center = StatusCenter(); + addTearDown(center.dispose); + final w = _wiring( + profiles: [_legacy(), _dev()], + activeId: 'work', + existingOrigins: const {'/Users/test/.worktrees/makit/feat-profiles'}, + lifecycle: _failingLifecycle('makit: failed to start — port in use'), + ); + await _pump( + tester, + controller: w.controller, + deleter: w.deleter, + lifecycle: w.lifecycle, + statusCenter: center, + ); + + await tester.tap(find.byType(PopupMenuButton).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + + final failures = center.events.where( + (e) => e.severity == StatusSeverity.failure, + ); + expect(failures, isNotEmpty); + expect(failures.last.detail, contains('failed to start')); + }); +} diff --git a/app/test/desktop/settings/server_devices_section_test.dart b/app/test/desktop/settings/server_devices_section_test.dart index eb2a8220..4ad28cc9 100644 --- a/app/test/desktop/settings/server_devices_section_test.dart +++ b/app/test/desktop/settings/server_devices_section_test.dart @@ -4,19 +4,22 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show SystemChannels; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:makit/desktop/daemon/cli_installer.dart'; import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; -import 'package:makit/desktop/desktop_app.dart' show desktopControllerProvider; +import 'package:makit/desktop/daemon/server_profile.dart'; +import 'package:makit/desktop/desktop_app.dart' + show desktopControllerProvider, serverProfileProvider; import 'package:makit/desktop/desktop_controller.dart'; import 'package:makit/desktop/screens/fake_control_client.dart'; import 'package:makit/desktop/screens/providers.dart' - show cliInstallerProvider, controlClientProvider; + show controlClientProvider; import 'package:makit/desktop/settings/sections/server_devices_section.dart'; import 'package:makit/desktop/settings/server_config.dart'; import 'package:makit/status/status_center.dart'; +import 'package:makit/status/status_event.dart'; import 'package:makit/status/status_providers.dart'; import 'package:makit/store/connection.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; /// Records pushed routes so a test can assert no page was navigated to. class _RecordingObserver extends NavigatorObserver { @@ -39,52 +42,105 @@ DesktopController _controller() => DesktopController( ), ); +/// A controller whose CLI always succeeds, without touching a real binary. +DesktopController _okController() => DesktopController( + client: FakeControlClient(), + lifecycle: DaemonLifecycle( + resolver: MakitCliResolver( + candidatePaths: const ['/opt/homebrew/bin/makit'], + exists: (path) => path == '/opt/homebrew/bin/makit', + shellLookup: () async => null, + ), + run: (exe, args) async => ProcessResult(0, 0, '', ''), + ), +); + +/// A controller whose CLI always fails, reporting [stdout] the way the real +/// `makit start` does -- on stdout, with stderr empty (see `_failureMessage` in +/// `daemon_lifecycle.dart`). +DesktopController _failingController(String stdout) => DesktopController( + client: FakeControlClient(), + lifecycle: DaemonLifecycle( + resolver: MakitCliResolver( + candidatePaths: const ['/opt/homebrew/bin/makit'], + exists: (path) => path == '/opt/homebrew/bin/makit', + shellLookup: () async => null, + ), + run: (exe, args) async => ProcessResult(0, 1, stdout, ''), + ), +); + Future _pump( WidgetTester tester, { required ServerConfigController config, DesktopController? controller, MakitConnState? connection, - CliInstaller? installer, StatusCenter? statusCenter, + ServerProfile? profile, + NavigatorObserver? observer, + bool tall = false, }) async { + if (tall) { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } await tester.pumpWidget( ProviderScope( overrides: [ serverConfigProvider.overrideWith((ref) => config), + serverProfileProvider.overrideWithValue(profile ?? _testProfile()), desktopControllerProvider.overrideWithValue( controller ?? _controller(), ), connectionProvider.overrideWithValue(connection ?? MakitConnState()), - if (installer != null) - cliInstallerProvider.overrideWithValue(installer), + controlClientProvider.overrideWithValue( + FakeControlClient(devices: const [], sessions: const []), + ), if (statusCenter != null) statusCenterProvider.overrideWithValue(statusCenter), ], - child: const MaterialApp(home: Scaffold(body: ServerDevicesSection())), + child: MaterialApp( + navigatorObservers: observer == null ? const [] : [observer], + home: const Scaffold(body: ServerDevicesSection()), + ), ), ); await tester.pump(); } +ServerProfile _testProfile() => const ServerProfile( + id: 'work', + name: 'Work', + kind: ProfileKind.user, + home: '/Users/test/.makit', + port: 7777, + storage: ProfileStorage.legacy, +); + void main() { setUp(() => SharedPreferences.setMockInitialValues({})); Future makeConfig() async { final prefs = await SharedPreferences.getInstance(); - return ServerConfigController(prefs, const ServerConfig()); + return ServerConfigController( + ProfileScopedPrefs.unscoped(prefs), + const ServerConfig(), + ); } - testWidgets('renders the section with its subsection headers', ( + testWidgets('renders the four-row Server group and subsection headers', ( tester, ) async { await _pump(tester, config: await makeConfig()); - expect(find.text('SERVER & DEVICES'), findsOneWidget); expect(find.text('SERVER'), findsOneWidget); - expect(find.text('Endpoint'), findsOneWidget); - expect(find.text('Lifecycle'), findsOneWidget); + expect(find.text('Who can reach this server?'), findsOneWidget); + expect(find.text('Pair a phone'), findsOneWidget); + expect(find.text('Diagnostics'), findsOneWidget); - await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.drag(find.byType(ListView), const Offset(0, -600)); await tester.pump(); expect(find.text('DEVICES'), findsOneWidget); @@ -93,138 +149,242 @@ void main() { expect(find.text('SESSIONS'), findsOneWidget); }); - testWidgets('defaults to Auto and has no Save button', (tester) async { - final config = await makeConfig(); - await _pump(tester, config: config); - - expect(config.current.bindMode, ServerBindMode.auto); - expect(find.widgetWithText(FilledButton, 'Save'), findsNothing); - expect(find.widgetWithText(OutlinedButton, 'Save'), findsNothing); - - // Auto mode shows no host field. + testWidgets('active-profile row shows the profile name and a status', ( + tester, + ) async { + await _pump(tester, config: await makeConfig()); + expect(find.text('Work'), findsOneWidget); expect( - find.ancestor(of: find.text('Host'), matching: find.byType(TextField)), - findsNothing, + find.text( + 'Projects, agents, devices and sessions are separate per profile.', + ), + findsOneWidget, ); + // Daemon is stopped in tests → the status reads "Stopped". + expect(find.text('Stopped'), findsOneWidget); }); - testWidgets('selecting LAN persists the bind mode', (tester) async { + testWidgets('defaults to My devices and has no Save button', (tester) async { final config = await makeConfig(); await _pump(tester, config: config); - await tester.tap(find.text('LAN')); - await tester.pump(); + expect(config.current.reachability, Reachability.myDevices); + expect(find.text('Save & restart server'), findsNothing); + expect(find.widgetWithText(FilledButton, 'Save'), findsNothing); - expect(config.current.bindMode, ServerBindMode.lan); + // The LAN fallback checkbox is shown under "My devices"; it is off. + expect(config.current.allowLanFallback, isFalse); + expect( + find.text('Also allow plain Wi-Fi when Tailscale is off'), + findsOneWidget, + ); }); - testWidgets('Custom reveals a host field that applies on commit', ( + testWidgets('selecting "Just this Mac" persists thisMacOnly + restarts', ( tester, ) async { final config = await makeConfig(); - await _pump(tester, config: config); - - await tester.tap(find.text('Custom')); - await tester.pump(); - expect(config.current.bindMode, ServerBindMode.custom); + final controller = _okController(); + addTearDown(controller.dispose); + await _pump(tester, config: config, controller: controller); - final host = find.ancestor( - of: find.text('Host'), - matching: find.byType(TextField), - ); - await tester.enterText(host, '0.0.0.0'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); + await tester.tap(find.text('Just this Mac')); + await tester.pumpAndSettle(); - expect(config.current.customHost, '0.0.0.0'); + expect(config.current.reachability, Reachability.thisMacOnly); }); - testWidgets('endpoint applies a valid port on commit', (tester) async { + testWidgets('the LAN fallback checkbox toggles allowLanFallback', ( + tester, + ) async { final config = await makeConfig(); - await _pump(tester, config: config); + final controller = _okController(); + addTearDown(controller.dispose); + await _pump(tester, config: config, controller: controller); - final port = find.ancestor( - of: find.text('Port'), - matching: find.byType(TextField), - ); - await tester.enterText(port, '9000'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); - expect(config.current.port, 9000); + expect(config.current.allowLanFallback, isTrue); }); - testWidgets('endpoint rejects an out-of-range port and keeps config', ( + testWidgets('a failed restart after a reachability change is reported', ( tester, ) async { - final config = await makeConfig(); - await _pump(tester, config: config); - - final port = find.ancestor( - of: find.text('Port'), - matching: find.byType(TextField), + final center = StatusCenter(); + addTearDown(center.dispose); + final controller = _failingController( + 'makit: failed to start \u2014 no response within 3000ms ' + '(see /Users/le/.makit-dev/a1b2c3d4/makit.log)', + ); + addTearDown(controller.dispose); + await _pump( + tester, + config: await makeConfig(), + controller: controller, + statusCenter: center, ); - await tester.enterText(port, '70000'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - expect(config.current.port, kDefaultServerPort); + await tester.tap(find.text('Just this Mac')); + await tester.pumpAndSettle(); + expect( - find.text('Port must be a number between 1 and 65535.'), - findsOneWidget, + center.events.where((e) => e.severity == StatusSeverity.failure), + isNotEmpty, ); + expect(center.events.last.detail, contains('makit.log')); }); - testWidgets('shows the fingerprint with a copy action when connected', ( + testWidgets('Install CLI does not live in this section (moved to General)', ( tester, ) async { - // Tall viewport so the fingerprint row (in the Server group) is on-screen - // and its copy button is hit-testable. - tester.view.physicalSize = const Size(1200, 2400); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + await _pump(tester, config: await makeConfig(), tall: true); + expect(find.text('Install CLI'), findsNothing); + }); - // Capture what the copy button writes to the system clipboard. - String? copied; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async { - if (call.method == 'Clipboard.setData') { - copied = (call.arguments as Map)['text'] as String?; - } - return null; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - null, - ), - ); + group('Diagnostics disclosure', () { + testWidgets('is collapsed until tapped, then reveals the moved rows', ( + tester, + ) async { + await _pump(tester, config: await makeConfig(), tall: true); + + // Collapsed: none of the moved rows are built yet. + expect(find.text('Lifecycle'), findsNothing); + + await tester.tap(find.text('Diagnostics')); + await tester.pumpAndSettle(); + + expect(find.text('Lifecycle'), findsOneWidget); + expect(find.text('CLI'), findsOneWidget); + expect(find.text('Fingerprint / TLS trust'), findsOneWidget); + expect(find.text('Advanced'), findsOneWidget); + }); + + testWidgets('Advanced applies a valid port on commit', (tester) async { + final config = await makeConfig(); + final controller = _okController(); + addTearDown(controller.dispose); + await _pump(tester, config: config, controller: controller, tall: true); + + await tester.tap(find.text('Diagnostics')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + final port = find.ancestor( + of: find.text('Port'), + matching: find.byType(TextField), + ); + await tester.enterText(port, '9000'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); - const fingerprint = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99'; - final connected = MakitConnState( - servers: [ - PairedServer( - host: 'h', - port: 7788, - fingerprint: fingerprint, - bearer: 'b', - label: 'Mac', + expect(config.current.port, 9000); + }); + + testWidgets('Advanced rejects an out-of-range port and keeps config', ( + tester, + ) async { + final config = await makeConfig(); + final controller = _okController(); + addTearDown(controller.dispose); + await _pump(tester, config: config, controller: controller, tall: true); + + await tester.tap(find.text('Diagnostics')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + final port = find.ancestor( + of: find.text('Port'), + matching: find.byType(TextField), + ); + await tester.enterText(port, '70000'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(config.current.port, kDefaultServerPort); + expect( + find.text('Port must be a number between 1 and 65535.'), + findsOneWidget, + ); + }); + + testWidgets('a failed Lifecycle "Start" is reported', (tester) async { + final center = StatusCenter(); + addTearDown(center.dispose); + final controller = _failingController( + 'makit: failed to start \u2014 no response within 3000ms', + ); + addTearDown(controller.dispose); + await _pump( + tester, + config: await makeConfig(), + controller: controller, + statusCenter: center, + tall: true, + ); + + await tester.tap(find.text('Diagnostics')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + + expect( + center.events.where((e) => e.severity == StatusSeverity.failure), + isNotEmpty, + ); + expect(center.events.last.detail, contains('failed to start')); + }); + + testWidgets('shows the fingerprint with a copy action when connected', ( + tester, + ) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, ), - ], - activeId: fingerprint, - ); - await _pump(tester, config: await makeConfig(), connection: connected); + ); - expect(find.byTooltip('Copy fingerprint'), findsOneWidget); + const fingerprint = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99'; + final connected = MakitConnState( + servers: [ + PairedServer( + host: 'h', + port: 7788, + fingerprint: fingerprint, + bearer: 'b', + label: 'Mac', + ), + ], + activeId: fingerprint, + ); + await _pump( + tester, + config: await makeConfig(), + connection: connected, + tall: true, + ); - await tester.tap(find.byTooltip('Copy fingerprint')); - await tester.pump(); + await tester.tap(find.text('Diagnostics')); + await tester.pumpAndSettle(); - // The full fingerprint is copied (the subtitle only shows a shortened form). - expect(copied, fingerprint); + expect(find.byTooltip('Copy fingerprint'), findsOneWidget); + await tester.tap(find.byTooltip('Copy fingerprint')); + await tester.pump(); + expect(copied, fingerprint); + }); }); testWidgets('unpair shows a confirm dialog that can be cancelled', ( @@ -252,41 +412,20 @@ void main() { testWidgets('nav rows disclose their content inline (no page push)', ( tester, ) async { - // Tall viewport so every row lays out (a ListView doesn't build far - // off-screen children) and is hit-testable without scrolling. - tester.view.physicalSize = const Size(1200, 2400); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final config = await makeConfig(); final observer = _RecordingObserver(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - serverConfigProvider.overrideWith((ref) => config), - desktopControllerProvider.overrideWithValue(_controller()), - connectionProvider.overrideWithValue(MakitConnState()), - controlClientProvider.overrideWithValue( - FakeControlClient(sessions: const []), - ), - ], - child: MaterialApp( - navigatorObservers: [observer], - home: const Scaffold(body: ServerDevicesSection()), - ), - ), + await _pump( + tester, + config: await makeConfig(), + observer: observer, + tall: true, ); - await tester.pump(); observer.pushed.clear(); - // Collapsed: the row's inline content is not built yet. expect(find.text('No running sessions'), findsNothing); await tester.tap(find.text('Running sessions')); await tester.pumpAndSettle(); - // Expanded inline — content is revealed and nothing was pushed onto the - // navigator. expect(find.text('No running sessions'), findsOneWidget); expect(observer.pushed.whereType>(), isEmpty); }); @@ -294,25 +433,7 @@ void main() { testWidgets('rows behave as an accordion (opening one closes the other)', ( tester, ) async { - tester.view.physicalSize = const Size(1200, 2400); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final config = await makeConfig(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - serverConfigProvider.overrideWith((ref) => config), - desktopControllerProvider.overrideWithValue(_controller()), - connectionProvider.overrideWithValue(MakitConnState()), - controlClientProvider.overrideWithValue( - FakeControlClient(devices: const [], sessions: const []), - ), - ], - child: const MaterialApp(home: Scaffold(body: ServerDevicesSection())), - ), - ); - await tester.pump(); + await _pump(tester, config: await makeConfig(), tall: true); await tester.tap(find.text('Paired devices')); await tester.pumpAndSettle(); @@ -321,66 +442,7 @@ void main() { await tester.tap(find.text('Running sessions')); await tester.pumpAndSettle(); - // Opening Sessions collapses the previously-open Devices row. expect(find.text('No running sessions'), findsOneWidget); expect(find.text('No paired devices'), findsNothing); }); - - group('Install CLI button', () { - late Directory tmp; - setUp(() => tmp = Directory.systemTemp.createTempSync('cli_install_ui')); - tearDown(() => tmp.deleteSync(recursive: true)); - - CliInstaller installerWithBundle({required bool bundled}) { - final path = '${tmp.path}/Resources/makit/makit'; - if (bundled) { - File(path).createSync(recursive: true); - } - return CliInstaller(bundledCliPath: () => path, homeDir: () => tmp.path); - } - - Future scrollToCli(WidgetTester tester) async { - await tester.scrollUntilVisible( - find.text('CLI'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pump(); - } - - testWidgets('shown when the app bundles a CLI; installs on tap', ( - tester, - ) async { - final center = StatusCenter(); - addTearDown(center.dispose); - await _pump( - tester, - config: await makeConfig(), - installer: installerWithBundle(bundled: true), - statusCenter: center, - ); - await scrollToCli(tester); - - final button = find.text('Install CLI'); - expect(button, findsOneWidget); - - await tester.tap(button); - await tester.pump(); - await tester.pump(); - - expect(File('${tmp.path}/.local/bin/makit').existsSync(), isTrue); - expect(center.events.single.title, startsWith('Installed makit CLI')); - }); - - testWidgets('hidden when the build has no bundled CLI', (tester) async { - await _pump( - tester, - config: await makeConfig(), - installer: installerWithBundle(bundled: false), - ); - await scrollToCli(tester); - - expect(find.text('Install CLI'), findsNothing); - }); - }); } diff --git a/app/test/desktop/settings/settings_registry_test.dart b/app/test/desktop/settings/settings_registry_test.dart index 0edc6209..b487f8cb 100644 --- a/app/test/desktop/settings/settings_registry_test.dart +++ b/app/test/desktop/settings/settings_registry_test.dart @@ -5,12 +5,13 @@ import 'package:makit/desktop/settings/registry/settings_section.dart'; void main() { group('kSettingsSections', () { - test('lists the 8 top-level sections in taxonomy order', () { + test('lists the 9 top-level sections in taxonomy order', () { expect(kSettingsSections.map((s) => s.id).toList(), [ 'general', 'appearance', 'agents_chat', 'server_devices', + 'profiles', 'notifications', 'shortcuts', 'advanced', diff --git a/app/test/desktop/settings/settings_window_test.dart b/app/test/desktop/settings/settings_window_test.dart index f3b0f970..755032ac 100644 --- a/app/test/desktop/settings/settings_window_test.dart +++ b/app/test/desktop/settings/settings_window_test.dart @@ -16,6 +16,7 @@ import 'package:makit/desktop/settings/settings_nav_pane.dart'; import 'package:makit/desktop/settings/settings_window.dart'; import 'package:makit/store/connection.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; class _RecordingObserver extends NavigatorObserver { final List> pushed = []; @@ -40,7 +41,10 @@ ProviderContainer _sectionContainer({PreferencesController? controller}) { if (controller != null) preferencesControllerProvider.overrideWith((ref) => controller), serverConfigProvider.overrideWith( - (ref) => ServerConfigController(_prefs, const ServerConfig()), + (ref) => ServerConfigController( + ProfileScopedPrefs.unscoped(_prefs), + const ServerConfig(), + ), ), desktopControllerProvider.overrideWithValue( DesktopController( diff --git a/app/test/store/prefs/profile_scoped_prefs_test.dart b/app/test/store/prefs/profile_scoped_prefs_test.dart new file mode 100644 index 00000000..22349330 --- /dev/null +++ b/app/test/store/prefs/profile_scoped_prefs_test.dart @@ -0,0 +1,124 @@ +// Unit tests for [ProfileScopedPrefs] (SPEC-50 D11). +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future raw() => SharedPreferences.getInstance(); + + group('ProfileScopedPrefs', () { + test('a namespaced scope prefixes every accessor', () async { + final prefs = await raw(); + final scope = ProfileScopedPrefs(prefs, 'a1b2c3d4.'); + + await scope.setString('host', 'h'); + await scope.setInt('port', 7813); + await scope.setBool('lan', true); + await scope.setStringList('list', ['a', 'b']); + + // Visible through the scope by the bare key... + expect(scope.getString('host'), 'h'); + expect(scope.getInt('port'), 7813); + expect(scope.getBool('lan'), isTrue); + expect(scope.getStringList('list'), ['a', 'b']); + // ...and stored under the prefixed key. + expect(prefs.getString('a1b2c3d4.host'), 'h'); + expect(prefs.getInt('a1b2c3d4.port'), 7813); + // The bare key must NOT be written: that is the legacy profile's slot. + expect(prefs.getString('host'), isNull); + }); + + test('the legacy (empty) scope reads and writes bare keys', () async { + final prefs = await raw(); + final scope = ProfileScopedPrefs(prefs, ''); + await scope.setInt('desktop_server_port', 7777); + expect(prefs.getInt('desktop_server_port'), 7777); + expect(scope.getInt('desktop_server_port'), 7777); + }); + + // The point of the whole class: two profiles must not see each other. + test('two scopes are mutually invisible', () async { + final prefs = await raw(); + final a = ProfileScopedPrefs(prefs, 'aaa.'); + final b = ProfileScopedPrefs(prefs, 'bbb.'); + + await a.setInt('port', 7801); + await b.setInt('port', 7802); + + expect(a.getInt('port'), 7801); + expect(b.getInt('port'), 7802); + }); + + test('the legacy scope does not see a namespaced profile key', () async { + final prefs = await raw(); + await ProfileScopedPrefs(prefs, 'dev.').setInt('port', 7801); + expect(ProfileScopedPrefs(prefs, '').getInt('port'), isNull); + }); + + test('containsKey and remove respect the scope', () async { + final prefs = await raw(); + final scope = ProfileScopedPrefs(prefs, 'x.'); + await scope.setString('k', 'v'); + expect(scope.containsKey('k'), isTrue); + expect(prefs.containsKey('k'), isFalse); + + expect(await scope.remove('k'), isTrue); + expect(scope.containsKey('k'), isFalse); + }); + + test('keys() strips the prefix and hides other scopes', () async { + final prefs = await raw(); + await ProfileScopedPrefs(prefs, 'mine.').setString('a', '1'); + await ProfileScopedPrefs(prefs, 'mine.').setString('b', '2'); + await ProfileScopedPrefs(prefs, 'other.').setString('c', '3'); + + final mine = ProfileScopedPrefs(prefs, 'mine.').keys(); + expect(mine, {'a', 'b'}); + expect(mine, isNot(contains('c'))); + }); + + test( + 'clearScope removes only this profile and reports the count', + () async { + final prefs = await raw(); + final mine = ProfileScopedPrefs(prefs, 'mine.'); + final other = ProfileScopedPrefs(prefs, 'other.'); + await mine.setString('a', '1'); + await mine.setString('b', '2'); + await other.setString('c', '3'); + + expect(await mine.clearScope(), 2); + expect(mine.keys(), isEmpty); + expect(other.getString('c'), '3'); + }, + ); + + // Guard: an unscoped view cannot tell one profile's keys from another's, so + // wiping through it would take everything — including the legacy profile. + test('clearScope refuses to run on an unscoped view', () async { + final prefs = await raw(); + await ProfileScopedPrefs(prefs, '').setString('keepme', 'v'); + expect(await ProfileScopedPrefs(prefs, '').clearScope(), -1); + expect(prefs.getString('keepme'), 'v'); + }); + + test('unscoped named constructor is the identity scope', () async { + final prefs = await raw(); + final scope = ProfileScopedPrefs.unscoped(prefs); + expect(scope.prefix, ''); + await scope.setString('theme', 'dark'); + expect(prefs.getString('theme'), 'dark'); + }); + + test('a missing key reads null through every accessor', () async { + final scope = ProfileScopedPrefs(await raw(), 'p.'); + expect(scope.getString('nope'), isNull); + expect(scope.getInt('nope'), isNull); + expect(scope.getBool('nope'), isNull); + expect(scope.getStringList('nope'), isNull); + expect(scope.containsKey('nope'), isFalse); + }); + }); +} diff --git a/app/tool/profiles_demo.dart b/app/tool/profiles_demo.dart new file mode 100644 index 00000000..568dc4c0 --- /dev/null +++ b/app/tool/profiles_demo.dart @@ -0,0 +1,243 @@ +// A standalone harness for design-reviewing the SPEC-50 profile surfaces on a +// real macOS window, with no server, no daemon and no filesystem writes. +// +// rm -rf .dart_tool/flutter_build build/macos/Build/Products/Profile +// flutter run -d macos --profile -t tool/profiles_demo.dart +// +// The cache clear is not optional: `flutter run -t ` silently reuses a +// cached bundle and renders lib/main.dart instead, omitting your edits. +// +// Everything is seeded from fakes: the registry is in memory, the lifecycle +// answers without spawning `makit`, and the deleter refuses everything, so a +// misclick during review cannot delete real data. +// ignore_for_file: depend_on_referenced_packages, invalid_use_of_visible_for_testing_member +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:makit/app/theme.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_deleter.dart'; +import 'package:makit/desktop/daemon/profile_lifecycle.dart'; +import 'package:makit/desktop/daemon/profile_registry.dart'; +import 'package:makit/desktop/daemon/profiles_controller.dart'; +import 'package:makit/desktop/daemon/server_profile.dart'; +import 'package:makit/desktop/settings/sections/profiles_providers.dart'; +import 'package:makit/desktop/settings/sections/profiles_section.dart'; +import 'package:makit/desktop/settings/sections/server_devices_section.dart'; +import 'package:makit/desktop/settings/server_config.dart'; +import 'package:makit/store/prefs/profile_scoped_prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A registry seeded to look like a real machine mid-development: the installed +/// profile, a hand-made one that is stopped, two live dev builds, and a pile of +/// stale ones whose worktrees are gone. +ProfileRegistry _seededRegistry() => ProfileRegistry( + makitRoot: '/Users/dev/.makit', + fs: _NoWriteFs(), + profiles: [ + const ServerProfile( + id: 'default', + name: 'Work', + kind: ProfileKind.user, + home: '/Users/dev/.makit', + port: 7777, + storage: ProfileStorage.legacy, + ), + const ServerProfile( + id: 'personal', + name: 'Personal', + kind: ProfileKind.user, + home: '/Users/dev/.makit/profiles/personal', + port: 7805, + storage: ProfileStorage.namespaced, + ), + const ServerProfile( + id: 'a1b2c3d4', + name: 'feat-profiles', + kind: ProfileKind.dev, + home: '/Users/dev/.makit-dev/a1b2c3d4', + port: 7813, + storage: ProfileStorage.namespaced, + origin: '/Users/dev/.worktrees/makit/feat-profiles', + ), + const ServerProfile( + id: 'bb806071', + name: 'feat-serving-html', + kind: ProfileKind.dev, + home: '/Users/dev/.makit-dev/bb806071', + port: 7841, + storage: ProfileStorage.namespaced, + origin: '/Users/dev/.worktrees/makit/feat-serving-html', + ), + // Stale: their origins no longer exist. + for (final (i, id) in const [ + '5600c573', + '985570e6', + 'db91190d', + '98e0d35f', + '6cebda53', + ].indexed) + ServerProfile( + id: id, + name: 'gone-worktree-$i', + kind: ProfileKind.dev, + home: '/Users/dev/.makit-dev/$id', + port: 7850 + i, + storage: ProfileStorage.namespaced, + origin: '/Users/dev/.worktrees/makit/deleted-$i', + ), + ], +); + +/// Never touches the disk: a design review must not write `profiles.json`. +class _NoWriteFs extends FileSystemAdapter { + @override + String? readOrNull(String path) => null; + @override + void writeAtomic(String path, String contents) {} + // Without this, the base withLock creates `.lock` on disk, breaking the + // no-write guarantee. + @override + T withLock(String path, T Function() body) => body(); +} + +/// Sizes chosen to exercise the formatter: bytes, KB, MB and a big one. +const Map _sizes = { + 'default': 122 * 1024 * 1024, + 'personal': 3 * 1024 * 1024 + 200 * 1024, + 'a1b2c3d4': 4 * 1024 * 1024 + 400 * 1024, + 'bb806071': 7 * 1024 * 1024 + 200 * 1024, + '5600c573': 7 * 1024 * 1024 + 600 * 1024, + '985570e6': 6 * 1024 * 1024 + 200 * 1024, + 'db91190d': 6 * 1024 * 1024 + 200 * 1024, + '98e0d35f': 6 * 1024 * 1024 + 200 * 1024, + '6cebda53': 5 * 1024 * 1024 + 100 * 1024, +}; + +const Set _running = {'default', 'a1b2c3d4', 'bb806071'}; + +MakitCliResolver _resolver() => MakitCliResolver( + candidatePaths: const [], + exists: (_) => false, + shellLookup: () async => '/usr/local/bin/makit', +); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final registry = _seededRegistry(); + + final lifecycle = ProfileLifecycle( + resolver: _resolver(), + run: (exe, args, {environment}) async => ProcessResult(0, 0, '', ''), + socketExists: (_) => true, + statusProbe: (p) async => _running.contains(p.id), + sleep: (_) async {}, + ); + final controller = ProfilesController( + registry: registry, + activeProfileId: 'default', + isRunning: (p) async => _running.contains(p.id), + diskUsage: (p) async => _sizes[p.id] ?? 0, + dirExists: (path) => !path.contains('deleted-'), + ); + await controller.refresh(); + + runApp( + ProviderScope( + overrides: [ + profilesControllerProvider.overrideWithValue(controller), + profileLifecycleProvider.overrideWithValue(lifecycle), + profileDeleterProvider.overrideWithValue( + ProfileDeleter( + registry: registry, + lifecycle: lifecycle, + // Every delete is refused, so a misclick during a design review + // cannot erase anything. The mechanism is `homeDir`, not the id: + // `_unsafeHomeReason` requires a home under `/.makit/` or + // `/.makit-dev/`, and no seeded profile lives under + // `/nonexistent/`. (`activeProfileId` alone would NOT be enough -- + // it matches no profile, so a namespaced one like `personal` would + // pass both that check and `isProtected`.) + activeProfileId: 'ALL-REFUSED', + homeDir: '/nonexistent', + ), + ), + serverConfigProvider.overrideWith( + (ref) => ServerConfigController( + ProfileScopedPrefs.unscoped(prefs), + const ServerConfig(), + ), + ), + ], + child: const _DemoApp(), + ), + ); +} + +class _DemoApp extends StatefulWidget { + const _DemoApp(); + @override + State<_DemoApp> createState() => _DemoAppState(); +} + +enum _Pane { profiles, server } + +class _DemoAppState extends State<_DemoApp> { + _Pane _pane = _Pane.profiles; + bool _light = false; + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: makitLightTheme, + darkTheme: makitDarkTheme, + themeMode: _light ? ThemeMode.light : ThemeMode.dark, + home: Scaffold( + body: Column( + children: [ + // Harness chrome — not product UI, do not design-review this bar. + Container( + height: 34, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + for (final p in _Pane.values) + Padding( + padding: const EdgeInsets.only(right: 6), + child: TextButton( + onPressed: () => setState(() => _pane = p), + child: Text( + p.name, + style: TextStyle( + fontWeight: _pane == p + ? FontWeight.w700 + : FontWeight.w400, + ), + ), + ), + ), + const Spacer(), + TextButton( + onPressed: () => setState(() => _light = !_light), + child: Text(_light ? 'light' : 'dark'), + ), + ], + ), + ), + Expanded( + child: switch (_pane) { + _Pane.profiles => const ProfilesSection(), + _Pane.server => const ServerDevicesSection(), + }, + ), + ], + ), + ), + ); + } +} diff --git a/docs/specs/2026-08-10-SPEC-50-profiles.md b/docs/specs/2026-08-10-SPEC-50-profiles.md new file mode 100644 index 00000000..9b30a3fd --- /dev/null +++ b/docs/specs/2026-08-10-SPEC-50-profiles.md @@ -0,0 +1,185 @@ +# SPEC-50 — Profiles: one server per purpose, and a Server panel you can read + +**Status:** Implemented · **Priority:** P1 · **Branch:** `feat/profiles` +**Mockup:** [`mockups/server-settings-and-profiles.html`](../../mockups/server-settings-and-profiles.html) (12 cards, the visual ground truth) +**Depends on:** SPEC-48 (`app/lib/status/` — used as the failure sink), SPEC-13 (settings section layout) + +**Scope:** `app/lib/desktop/daemon/` (profile model, registry, runtime, lifecycle), +`app/lib/store/prefs/` (scoped prefs), `app/lib/desktop/settings/` (Server + Profiles sections), +`app/lib/desktop/chat/server_profile_badge.dart` (switcher), `app/lib/desktop/desktop_app.dart` +(runtime wiring), `server/src/pairing/url.ts` (two optional query params). + +**No change to:** the wire protocol (frames, `v:1`, bearer auth), `MAKIT_HOME` as the isolation +boundary, `chooseBindHost()` semantics, the daemon spawn path, or any server storage module. + +--- + +## Goal + +Two complaints, one root cause. + +1. **The Server panel exposes ten controls to do one thing: run the server.** Six concepts, four + of them read-only trivia, plus a four-way segmented control with two labels for one behaviour. +2. **Profiles already exist but are invisible plumbing.** They are *derived* from the running + `.app`'s filesystem path, never persisted, never nameable — so they cannot express + Work / Personal, they break when a worktree moves, and there is no way to stop or discard one. + +The root cause is the same: the app models **the daemon's configuration** where it should model +**the user's purpose**. A profile is the missing noun. + +## Evidence (measured on the author's machine, 2026-08) + +``` +~/.makit-dev: 33 profile homes 732 MB total + 6 map to a live folder 649.6 MB + 27 ORPHANED (82%) 82.5 MB ← unreachable by any UI +``` + +27 of 33 dev profiles are unreachable: their `.app` is gone, so `ServerProfile.resolve()` can +never mint their id again. Nothing can list, stop, or delete them. Each still holds a device +pairing and a TLS keypair. The honest headline is the **count**, not the bytes. + +Separately, one *live* profile (`~/Work/Vibe/makit`) holds a 544 MB `makit.db` + 99 MB `media/`. +That is session retention, **explicitly out of scope** (see "What this spec does not do"). + +## Decisions + +**D1 — A profile is its own server instance, and several may run at once.** Own `MAKIT_HOME`, +daemon, port, devices, projects. This is already how the app spawns the daemon +(`desktop_app.dart` passes `environment: profile.environment`); the spec makes it *chosen and +persisted* rather than derived. Rejected: one server with profile-scoped projects (kills the +develop-server case, and a dev crash takes the work server with it); one-at-a-time switching +(loses the ability to watch Work while testing Dev). + +**D2 — `~/.makit` becomes a named, renameable profile.** The `isDefault` boolean fused two +unrelated concerns and is split: + +| field | meaning | mutable | +|---|---|---| +| `name` | what the user calls it | yes | +| `storage` | `legacy` \| `namespaced` — which key layout it uses | **never** | + +`storage: "legacy"` (**at most one** profile) pins the prefs prefix and the unsuffixed +secure-store file, so no shipped user's settings move. It also **implies protected**: it is by +definition the profile holding `AuthKey_*.p8`, `ota/`, `push.json`, `host.json`, so no separate +`protected` flag exists. Delete is *absent* for it, not disabled. + +**D3 — Identity is minted once and persisted; `origin` is a relocation hint.** `~/.makit/profiles.json` +is the registry. `id` is generated at creation and never re-derived. `origin` (the repo root a dev +profile was created from) is used to (a) re-bind a moved/rebuilt dev build to its existing profile +instead of forking a new one, and (b) detect orphans via `existsSync(origin)`. Path-hashing +survives **only** as the bootstrap for a dev build the registry has never seen. + +**D4 — The port is allocated once, by probing, and persisted.** `7800 + fnv1a(repoRoot) % 100` +becomes a *starting guess*; creation probes upward for a free port and stores the result. Daemon +start retries on `EADDRINUSE`. Note the server already diagnoses a collision well +(`server.ts:199` logs cause + fix; `service.ts` waits for the control socket, clears the pid file, +exits 1) — the gap is that nothing reallocates. + +**D5 — Reachability is one question with two answers, plus a fallback checkbox.** +`ServerBindMode {auto,lan,loopback,custom}` → `Reachability {thisMacOnly, myDevices}` + +`allowLanFallback: bool`; `customHost` survives behind Diagnostics → Advanced. +**`chooseBindHost()` is NOT changed.** Re-reading it settled that `--lan` is a documented +*fallback* for when Tailscale is down, and `--host` bypasses the decision entirely +(`serve.ts:82`), so `Loopback`/`Custom` are exact. The defect was the four-segment exclusive +control promising four outcomes when three exist. Migration: persisted `auto`/`lan` → `myDevices` +(`lan` also sets `allowLanFallback: true`); `loopback` → `thisMacOnly`; `custom` → `myDevices` +with `customHost` retained and Advanced revealed. + +**D6 — The Server section is four rows:** active-profile row, the reachability question, a pair +row, and one collapsed **Diagnostics** disclosure holding pid, port, bind host, fingerprint, CLI +path, log path and Advanced. `Install CLI` moves to General. The retired item anchors +(`server_devices.lifecycle`, `.cli`, `.fingerprint`) must still resolve, pointing into Diagnostics, +so deep links and settings search keep working. + +**D7 — Lifecycle attaches to a profile, not to "the server".** The Server section never offers +Stop (stopping the server you are talking to disconnects the window that asked). The Profiles +section offers Stop/Start per profile. Per-profile stop reuses the existing verb: +`MAKIT_HOME= makit stop` (`server/src/index.ts:130` → `daemon.stop()`). + +**D8 — Delete is atomic across four stores, and says what it keeps.** A profile is not one +directory: (1) `$MAKIT_HOME/` (`makit.db` +`-shm`/`-wal`, `media/`, `devices.json`, +`projects.json`, `server.crt`/`.key`, `port-history.json`, `watched-ports.json`, +`capability-cache.json`, `worktree-targets.json`, `makit.log`, `control.sock`), (2) `NSUserDefaults` +keys under the profile's key segment, (3) the secure-store namespace (pairing bearer), (4) the +registry entry. Deleting only the folder leaks the other three; omitting (4) resurrects the +profile empty on next launch. Delete **stops the daemon first** (SIGTERM → SIGKILL after 300 ms) +— never unlink under a live daemon holding `makit.db-wal`. It refuses the `legacy` profile always, +and the active profile unless the user takes the offered "switch away and delete" path. Running +sessions are counted and named in the sheet. **No path outside `~/.makit*` is ever touched.** + +**D9 — Orphans are offered, never reaped.** A dev profile whose `origin` no longer exists is +listed as stale with its size, selectable, and deletable in bulk. Auto-deletion is rejected: it +would have prevented all 27 orphans and also destroyed transcripts the first time a worktree moved +or a drive unmounted. + +**D10 — Switching happens in the current window, gated by confirm-then-verify.** +1. confirm sheet (what changes here vs. what keeps running), +2. start the target and await its `control.sock` **while the current profile is still live**, +3. only then dispose the old runtime, rebuild `ProviderScope(key: ValueKey(profileId))`, retitle + the window, +4. on failure stay put and post a `StatusCenter` failure. + +Rejected: relaunching the window (flashes the app, loses position, is the "restart to apply" +friction this spec exists to remove). + +**D11 — Prefs are scoped by an app-owned key prefix, not by `SharedPreferences.setPrefix`.** +`setPrefix` throws `StateError('setPrefix cannot be called after getInstance')` +(`shared_preferences_legacy.dart:57`), which alone makes D10 impossible; `resetStatic()` is +`@visibleForTesting` and is rejected. Keys are plain concatenation — `'$_prefix$key'` (line 179) — +so moving the profile segment from the global prefix into our own key yields a **byte-identical** +stored key (`flutter..desktop_server_port` either way). **The migration is a no-op.** +Only *server-bound* prefs are scoped: server config, groups, pane layouts. Appearance, shortcuts, +recent models and cached commands are **user-level and stay shared** — today's blanket prefix is +why a worktree build opens with a default theme and empty shortcuts. + +**D12 — The pair URL gains two optional params.** `&n=` and `&id=` so the phone +can label and colour each paired server instead of showing a bare IP. Absent → fall back to +`host:port`. `n` is capped at 64 Unicode code points (`MAX_PROFILE_NAME_CODE_POINTS` in +`pairing/url.ts`), truncated on code-point boundaries so a multi-unit emoji is never split, because +the URL is rendered into a QR code of finite capacity. No protocol version bump; per-profile +pairing already works because the QR already carries `host`, `port` and `fp` (`pairing/url.ts`). + +## Phases + +| Phase | Content | Deltas (mockup card 12) | Status | +|---|---|---|---| +| **P0** | Surface daemon failures | 1 | **Implemented** | +| **P1** | `ProfileRegistry`, profile model split (D2/D3), port allocation + retry (D4) | 2–5 | **Implemented** | +| **P2** | `ProfileScopedPrefs` (D11), per-profile lifecycle + deleter (D7/D8) | 8 | **Implemented** | +| **P3** | Server section rewrite (D5/D6), Profiles section + detail + delete + reclaim (D7/D8/D9) | 6, 7, 12–16 | **Implemented** | +| **P4** | Pair-URL params (D12) | 19 | **Implemented** | +| **P5** | In-place switching (D10) + `ProfileRuntime` | 9–11 | **Implemented** | + +## Correction recorded + +Rev 1 deferred D10, arguing that `WorkspaceController` needed a 20-file refactor +and that a partial adoption would leave the window showing another profile's +panes. **That was wrong on its central fact:** `WorkspaceController` holds no +preferences at all — its only mention of `SharedPreferences` is a doc comment — +and pane layouts persist through `GroupsController`. The earlier figure counted +files that merely *mention* the class. With only `ServerConfigController` and +`GroupsController` needing the scoped view, D10 was materially cheaper than +claimed, and it is now implemented. + +## What this spec does not do + +- **Session retention / db pruning.** The 544 MB `makit.db` and 99 MB `media/` in the live profile + are a retention problem. Deleting profiles will not touch them. Own spec. +- **A designated "primary" profile** holding a stable 7777 and the phone pairing. Right end state, + wrong first step: it adds promote/demote and a second class of profile before anyone asks. +- **A detected-address dropdown** (delta 18). Needs a new `net.interfaces` command; the address row + renders the *current* bind host read-only until then. +- **Mobile profile management.** The phone gains labels/colours from D12 only; it does not create, + stop or delete profiles. +- **Auto-reaping orphans** (D9), **changing `chooseBindHost`** (D5). +- **Migrating the 27 existing orphans automatically.** They are listed and offered; the user decides. + +## Verification + +1. `cd app && flutter analyze --no-pub` → "No issues found"; `dart format --set-exit-if-changed lib test` clean. +2. `cd server && pnpm typecheck` clean; `pnpm test` green with the pre-existing count preserved. +3. Every new test's bite proven by reverting only the production line. +4. Live proof: a real second profile created, started on its own port, switched into in-place, and + deleted — with `~/.makit-dev` inspected before and after to confirm all four stores went. +5. `flutter test --no-pub` judged against the known flake baseline: only NON-`loading` failures count. diff --git a/mockups/server-settings-and-profiles.html b/mockups/server-settings-and-profiles.html new file mode 100644 index 00000000..daf5b586 --- /dev/null +++ b/mockups/server-settings-and-profiles.html @@ -0,0 +1,806 @@ + + + + + +makit — Server settings, simplified + real profiles + + + +

Server settings, simplified — and profiles that are real

+

Two problems, one shape. (a) The Server panel exposes ten controls to do one thing: run the server. (b) Profiles already exist in the code but are derived from the .app's filesystem path, never persisted and never user-chosen — so they can't express Work / Personal, and they break when a worktree moves. Grounded in server_profile.dart, server_config.dart, server_devices_section.dart, pairing/cert.ts.

+ + + + + +
+

1 · Today — ten controls to run one server

as built +

Redrawn to scale from server_devices_section.dart.

+
+
+
+
+
Settings
+
+ +
+

Server

+
+
+
Endpoint
+
Auto: Tailscale if available, else loopback. Reachable by your other devices over Tailscale.
+
+
AutoLANLoopbackCustom
+
7808Port
+
Save & restart server
+
A running server keeps its current settings until restarted.
+
+
+ +
Lifecycle
Running · pid 82738
+ Restart + Stop +
+
+
+
CLI
+
/Users/le/Work/Vibe/makit/app/build/macos/Build/Products/Release/Makit.app/Contents/MacOS/../Resources/makit/makit
+
+
Install CLI
+
Override path (optional)
+
+
+
Fingerprint / TLS trust
d3e73b15ac380e583d588c5f…
+
+
+
+
+
+
+
+

What's actually wrong

+
1
“LAN” and “Auto” are the same thing whenever Tailscale is up. chooseBindHost() (pairing/cert.ts:188) returns the Tailscale IP before it looks at allowLan, so --lan only adds a fallback for when Tailscale is down. Corrected after re-reading the code — this is not a server bug. The subtitle states the contract accurately (“allow access over the local network when Tailscale is off”), and --host bypasses the decision entirely (serve.ts:82), so Loopback and Custom are exact. The real defect is the 4-segment exclusive control: it promises four distinct outcomes when only three exist, rendering a fallback as a mode. The fix belongs in the UI (card 3), not in cert.ts.
+
2
Port is a derived value presented as a decision. It's 7800 + fnv1a(repoRoot) % 100. The user has no basis to choose 7808 over anything else.
+
3
Two-phase commit. “Save & restart server” plus “a running server keeps its current settings until restarted” makes the user model process lifetime to change a setting.
+
4
pid, CLI path and fingerprint are evidence, not settings. The fingerprint is already shipped to the phone inside the pairing QR (makit://pair?…&fp=, pairing/url.ts:19) and pinned automatically — nobody needs to read it here.
+

Net: 6 concepts, ~10 controls, 4 of them read-only trivia — plus a segmented control with two labels for one behaviour.

+
+
+
+
+ + +
+

2 · Orca, for reference — one decision, stated as a consequence

+

Not a style to copy wholesale; a framing to copy.

+
+
+
+
+
Pair a phone
+
Generate a QR code, then scan it in Orca Mobile under Pair Desktop.
+
+

Connection

+
Orca Relay
+
Phone can be on cellular or any Wi-Fi. Sign-in required for Relay only.
+
LAN
+
Phone must be on this Wi-Fi or connected through Tailscale. No account needed.
+
+
+

This computer's address

+
100.119.58.97 (utun13) +
+
The phone must be able to reach this address on Tailscale or Wi-Fi.
+
Generate QR code
+
+
+
+
+

The framing difference is the whole story

+

Orca asks “how will my phone reach this computer?” — a user goal. makit asks “how shall the daemon bind?” — an implementation detail.

+

Three things it does that we don't:

+
1
Every option states its consequence, not its mechanism. “Phone must be on this Wi-Fi…” beats “Loopback: this Mac only”.
+
2
The address is picked from reality — a dropdown of detected interfaces with a refresh, not a host you type. We already advertise host/port/fp over mDNS (pairing/mdns.ts), so we have this data.
+
3
No port, no pid, no start/stop, no cert hex. The server is an implementation of pairing, not a thing you administer.
+

Where Orca is worse: it has no profile concept at all, so it can't do the develop-server case. We should take its framing and keep our isolation.

+
+
+
+
+ + +
+

3 · Recommended — Server collapses to three rows

recommended +

Same panel, same nav slot. Everything that isn't a decision moves under Diagnostics.

+
+
+
+
+
Settings
+
+ +
+

Server

+
+
+ +
Work
+
Projects, agents, devices and sessions are separate per profile.
+ Running + +
+
+
Who can reach this server?
+
Just this Mac
+
Nothing else can connect.
+
My devices
+
Reachable from your phone over Tailscale. No account needed.
+
+ Also allow plain Wi-Fi when Tailscale is off
+
+
+ Address + 100.119.58.97 (Tailscale) +
+
+
+ +
Pair a phone
2 devices paired
+ +
+
+ +
Diagnostics
+
pid 82738 · port 7813 · fp d3e73b… · log
+
+
+
+
+
+
+
+

What changed, and why it's safe

+
1
Four bind modes → two answers + a checkbox. Because Auto and LAN are the same thing when Tailscale is up, the honest model is: loopback-only, or reachable — with LAN as a fallback preference, which is exactly what allowLan already is. The checkbox names it correctly.
+
2
Port disappears. Allocated per profile, persisted, with an EADDRINUSE retry. Visible in Diagnostics; editable in Advanced.
+
3
No “Save & restart”. Changing reachability restarts the daemon itself and shows a 1.5s inline “Applied” — the same immediate-effect contract every other settings row already has.
+
4
Lifecycle, CLI, fingerprint → Diagnostics (one collapsed row, read-only, copyable). Install CLI becomes a one-time action in General, where one-time actions live.
+
5
Custom host survives as Diagnostics → Advanced. Nobody loses an escape hatch; it just stops being the fourth thing a new user reads.
+

10 controls → 4 rows. The nav's item anchors (server_devices.endpoint, .lifecycle, .cli, .fingerprint) stay resolvable so deep links and search keep working — they just point into Diagnostics.

+
+
+
+
+ + +
+

4 · Profiles — persisted identity, not a path hash

recommended +

New nav section. Each profile is a full server instance; all can run at once.

+
+
+
+
+
Settings
+
+ +
+

Profiles

+
+
+
Work active
+
3 projects · 2 devices · ~/.makit
+ 122 MB + Running +
+
+
Personal
+
1 project · 1 device · ~/.makit/profiles/personal
+ 3.2 MB + Stopped +
+
+
feat-profiles dev build
+
auto-created · ~/.makit-dev/a1b2c3d4
+ 4.4 MB + Running +
+
New profile
+
+

Stale — source folder is gone

+
+
+
27 orphaned dev profiles
+
Their worktrees no longer exist. 82.5 MB.
+ Review…
+
+

Registry

+
~/.makit/profiles.json  ·  id minted once, never re-derived
+
+
+
+
+
+

The two robustness bugs this fixes

+
1
Port collisions have no auto-recovery. 7800 + (h % 100) gives 100 slots for an unbounded number of worktrees, and nothing reallocates on conflict. Corrected: server-side the failure is not silentonListenError (server.ts:199) logs the port, the cause and the fix, and service.ts waits for the control socket before claiming success, then clears the stale pid file and exits 1. What was broken is that the app discarded that reason (it read stderr; the CLI writes it to stdout), so the user saw the bare makit start exited 1: — now fixed. Remaining: reallocate the port automatically instead of asking the user to.
+
2
Identity is the filesystem path. id = fnv1a(repoRoot), so moving or renaming a worktree mints a new profile and silently orphans its MAKIT_HOME, pairings, projects and prefs. Fix: mint id once into profiles.json; store origin as a relocation hint, so a moved build re-binds to its existing profile instead of forking one.
+

Registry shape

+
{ "profiles": [
+  { "id":"work", "name":"Work", "kind":"user",
+    "home":"~/.makit", "port":7777,
+    "storage":"legacy" },              ← at most one
+  { "id":"a1b2c3d4", "name":"feat-profiles", "kind":"dev",
+    "home":"~/.makit-dev/a1b2c3d4", "port":7813,
+    "storage":"namespaced",
+    "origin":"/Users/le/.worktrees/makit/feat-profiles" }
+] }
+

kind:"user" profiles are created by hand and never garbage-collected. kind:"dev" ones are still auto-created for worktrees — the zero-config behaviour survives, it just becomes visible and nameable.

+
Decided: name it, pin its storage. The old isDefault boolean fused two unrelated ideas — what is this called (a UI fact, editable) and which storage layout does it use (a compatibility fact, frozen). Splitting them into name + storage lets ~/.makit become an ordinary, renameable “Work” while storage:"legacy" keeps its prefs under flutter. and its secret file unsuffixed — nobody’s settings move a byte. storage:"legacy" also implies protected (it is the profile holding AuthKey_*.p8, ota/, push.json), so no separate protected flag is needed.
+
Per-profile pairing is already free. The QR encodes makit://pair?host=&port=&fp=&t= (pairing/url.ts), so every profile's QR already carries its own endpoint and cert pin. The phone simply ends up with several paired servers — no protocol change needed.
+
+
+
+
+ + +
+

5 · Profile detail — where lifecycle actually belongs

recommended +

Stop / start / delete attach to a profile, not to "the server".

+
+
+
+
+
Settings
+
+ +
+

Profiles feat-profiles

+
+
Name
+ feat-profiles
+
+
Running
pid 91204 · port 7813
+ Stop
+
+
Data
~/.makit-dev/a1b2c3d4
+ Reveal
+
+
Created from
+
~/.worktrees/makit/feat-profiles
+
+

Contents — 4.4 MB

+
+
Sessions & transcripts
+
makit.db + −wal
3.9 MB
+
Ingested media
+
media/
412 KB
+
Pairings & projects
+
devices.json · projects.json
2 · 1
+
+

Danger zone

+
+
+
Delete profile
+
Removes this profile’s server state. Your code is untouched.
+ Delete…
+
+
+
+
+
+
+

This corrects card 3

+

I said lifecycle should leave Settings. That was half right. Stopping the server you are currently talking to is a self-defeating action — that is why the old Stop button felt odd. Stopping a profile you are not using is a real, ordinary task.

+

So lifecycle does not get deleted; it moves to the object it acts on. The Server section shows the active profile and never offers Stop; the Profiles section offers Stop/Start per profile, because there the profile is a thing in a list rather than the ground you stand on.

+

Guards

+
1
Stop is offered for any profile; Delete is not. Deleting the active profile offers “Switch to Work & delete” as one action instead of a dead button, so finishing a dev cycle is one click.
+
2
The default profile can never be deleted. ~/.makit holds shared, partly irreplaceable material — the APNs key AuthKey_4GVCUB3YYU.p8, ota/, push.json, host.json. Delete is absent for it, not merely disabled.
+
3
Stop already exists as a verb. makit stop is wired at server/src/index.ts:130daemon.stop(), and the app already spawns the CLI with MAKIT_HOME set. So per-profile Stop is MAKIT_HOME=<home> makit stop — no new server code, just aiming an existing verb at a different home.
+
4
Delete stops the daemon first (SIGTERM → SIGKILL after 300 ms, the pattern already used for session close), then unlinks. Never unlink under a live daemon holding makit.db-wal.
+
5
Running sessions block a silent delete. Agents may hold uncommitted work; the dialog names the count and requires an explicit Stop & delete.
+
+
+
+
+ + +
+

6 · Delete — say exactly what goes, and what stays

recommended +

Drawn to scale (470 px sheet). The “kept” half is the part that makes the button usable.

+
+
+
+
+

Delete “feat-profiles”?

+

This removes makit’s state for this profile only.

+

Will be deleted4.4 MB

+
+
makit.dbsessions, transcripts3.9 MB
+
media/ingested images412 KB
+
devices.json2 paired devices
+
projects.json1 project
+
server.crt/.keythis profile’s TLS identity
+
prefsflutter.a1b2c3d4.*38 keys
+
keychainpairing bearer
+
profiles.jsonregistry entry
+
+

Will be kept

+
+
your codeworktrees and repos are never touched
+
other profilesWork, Personal unaffected
+
+
+ 2 sessions are running. They will be stopped before the profile is removed.
+
CancelStop & delete
+
+
+
+

Why enumerate at all

+

A profile is not one directory — it is four stores. Delete only the folder and you leak the other three:

+
1
$MAKIT_HOME/makit.db (+-shm/-wal), media/, devices.json, projects.json, server.crt/.key, port-history.json, watched-ports.json, capability-cache.json, worktree-targets.json, makit.log, control.sock.
+
2
NSUserDefaults keys under the flutter.<id>. prefix (server_profile.dart prefsPrefix). Orphaned prefs are invisible and survive forever.
+
3
Secure store namespace — defaultSecureStore(namespace: profile.id) holds the loopback pairing bearer. On macOS that is a file; on iOS a keychain item.
+
4
The registry entry in profiles.json. Removing the folder but not the entry yields a profile that resurrects itself empty on next launch.
+

Stating the kept half is not padding. The word “delete” next to a path that sits beside your worktree reads as “deletes my branch”. One line removes that fear permanently.

+
Stop is not destructive and says so. A stopped profile keeps every byte; only the daemon exits. Stop → Start is lossless, so the safe action needs no confirmation and the destructive one always gets a sheet.
+
+
+
+
+ + +
+

7 · The pile-up this fixes — measured on your machine

recommended +

Not hypothetical: numbers below are from ~/.makit-dev right now.

+
+
+
+
+

Stale profiles

+

These dev profiles were created from folders that no longer exist.

+
+
5600c573folder gone7.6 MB
+
bb806071folder gone7.2 MB
+
985570e6folder gone6.2 MB
+
db91190dfolder gone6.2 MB
+
98e0d35ffolder gone6.2 MB
+
… 22 more49.1 MB
+
27 profiles selected82.5 MB
+
+
Keep allDelete 27 profiles
+
+
+
+

Measured, not guessed

+

I enumerated ~/.makit-dev and recomputed fnv1a(repoRoot) for every repo and worktree that still exists, then matched ids:

+
33 dev profile homes      732 MB total
+  6 map to a live folder   649.6 MB
+ 27 ORPHANED (82%)          82.5 MB   ← unreachable
+
1
82% of your dev profiles are unreachable. Their .app is gone, so ServerProfile.resolve() will never mint their id again — no UI can currently show them, stop them, or delete them. They are pure sediment.
+
2
The honest headline is the count, not the bytes. 82.5 MB is not a crisis; 27 invisible servers-worth of state, including 27 stale device pairings and TLS keys, is. I am not going to sell this as a disk-space win.
+
3
The 648 MB is a different problem. One live profile (~/Work/Vibe/makit) holds a 544 MB makit.db plus 99 MB of media/. Deleting profiles will not touch it. That is session pruning/retention — out of scope here, worth its own spec. follow-up
+

Because origin is persisted (card 4), orphan detection is a cheap existsSync(origin) per entry — no hashing, no guessing. That is the payoff for storing the path instead of hashing it.

+
+
+
+
+ + +
+

8 · The switcher — the badge you already ship

recommended +

ServerProfileBadge already derives a stable hue per profile id and already sits in the title-bar strip. Make it a button.

+
+
+
+ +
+
+

Why here and not a new surface

+

desktop_sidebar.dart:101 already renders ServerProfileBadge in TitleBarStrip.trailing, and the badge already computes a deterministic hue from the profile id. Turning it into a PopupMenuButton reuses the placement, the colour identity, and the muscle memory — no new surface, no new concept.

+

Two behavioural notes:

+
1
The badge must stop hiding itself. Today it returns SizedBox.shrink() for the default profile — correct when profiles were invisible plumbing, wrong once they're user-chosen. Show it always; a single-profile user sees one calm pill.
+
2
Switching happens in this window — see card 9. It is gated by a confirmation and a verified handover, so a target profile that cannot start leaves you exactly where you were.
+
Decided. ~/.makit becomes a named, renameable profile (“Work”) with storage:"legacy" pinning its prefs prefix and unsuffixed secret file. The badge therefore shows a name for every profile including the installed one, and its isDefault early-return disappears entirely.
+
+
+
+
+ + +
+

9 · Switching in place — confirm, verify, then hand over

recommended +

The same window reconnects to the target profile. No relaunch.

+
+
+
+
+

Switch to “Personal”?

+

This window will reconnect to Personal’s server.

+

What happens here

+
+
startPersonal’s server (currently stopped)
+
reloadpanes, selected session and scroll reset
+
discardunsent composer drafts in this window
+
+

What keeps running

+
+
Work’s serverstays up — 2 sessions keep running
+
Work’s agentsnot interrupted, not stopped
+
paired phonesstay paired to Work
+
+
CancelSwitch to Personal
+
+
Verify before committing. The target daemon is started and its control socket confirmed while the current profile is still live. Only then is the old runtime torn down. If the target cannot come up, nothing changes and a failure is posted — you are never left staring at a dead window.
+
+
+

The sequence

+
1. confirm          ← the dialog
+2. ensure target up  MAKIT_HOME=<home> makit start
+                     + wait for control.sock
+                     (current profile untouched)
+3. commit            dispose old runtime
+                     ProviderScope(key: newId) rebuilds
+                     windowManager.setTitle(...)
+4. on failure        stay put + StatusCenter failure
+

The one real blocker, and why it dissolves

+
1
SharedPreferences.setPrefix cannot be re-called. It throws StateError('setPrefix cannot be called after getInstance') (shared_preferences_legacy.dart:57), and today the profile lives in that global prefix (desktop_app.dart:93). resetStatic() exists but is @visibleForTesting — not a production option.
+
2
Move the profile segment out of the global prefix and into our own keys. Keys are plain concatenation — final prefixedKey = '$_prefix$key' (line 179). So setPrefix('flutter.<id>.') + desktop_server_port and the default flutter. + <id>.desktop_server_port both store flutter.<id>.desktop_server_port. Byte-identical — the migration is a no-op. The legacy profile keeps an empty segment, so its keys stay exactly as shipped.
+
3
Scope only what is server-bound. Today the global prefix namespaces everything, so a worktree build opens with a default theme and empty shortcuts. Server config (port, bind mode, CLI path), groups and pane layouts are genuinely per profile; appearance, shortcuts, recent models and cached commands are user-level and should stay shared. That shrinks the refactor and fixes an existing annoyance.
+
4
Cost, stated honestly. 14 files reference SharedPreferences; on the scoping above only server_config.dart, groups_controller.dart and workspace_controller.dart need the scoped wrapper. The rest are untouched.
+

Rebuilding ProviderScope under a new key is what makes this safe: Riverpod disposes the entire old container deterministically, so there is no half-switched state and no hand-written teardown list to forget an entry.

+
+
+
+
+ + +
+

10 · iOS — the phone just sees more than one server

+

Drawn to scale: 320px frame, island, 44pt glass bar.

+
+
+
+
+
9:41 +
+
Servers
+
+
+
+
Work
100.119.58.97:7777
+
+
+
feat-profiles
100.119.58.97:7813
+
+
+
Personal
Not reachable
+
+
+
+
Pair another server
+
+
+
+
+
+
+

No protocol work required

+

The phone's existing paired-server list already stores host, port and fp per pairing. Three profiles = three entries. The only additions are cosmetic and small:

+
1
Carry the profile name in the pair URL — one extra query param (&n=Work) so the phone can label the entry instead of showing a bare IP. Falls back to host:port when absent.
+
2
Carry the profile hue so the colour identity matches desktop. Derivable on-device from the id; no param needed if we send &id=.
+

This is what makes the develop-server case actually work end-to-end: pair the phone to feat-profiles, drive the new feature on a real device, and your Work sessions keep running untouched on 7777.

+
+
+
+
+ + +
+

11 · Considered and rejected

rejected
+
+ + + + + + + + + + + + + + + + + + + + + + + +
OptionWhy not
Keep the 4-way segmented control, just fix the help textCheapest, and it does remove the lie. But it leaves Auto and LAN as two labels for one behaviour, and keeps port/pid/CLI/fingerprint in the user's face. Fixes the symptom, not the framing.
Profile = a scope inside one server (one daemon, one port, one pairing; profiles only partition projects)Simplest ops and the nicest phone story — but it gives no isolation, so the develop-server case dies, and a dev-server crash takes your real work server with it. This was the stated reason for wanting profiles.
Profile = own instance, but only one runs at a timeLets every profile reuse the stable 7777, so the phone keeps one pairing forever. Genuinely attractive. Rejected for v1 because you lose the ability to watch Work while testing Dev — and per-profile QR already solves the pairing objection for free.
Concurrent instances with one designated “primary” holding 7777 + the phone pairingRight end state, wrong first step. Adds a promote/demote concept and a second class of profile before we know anyone wants it. follow-up
Relaunch the window to switch profilesMy earlier recommendation, now rejected. It is ~20 lines and honest, but it flashes the app, loses window position, and is exactly the “restart to apply” friction this redesign exists to remove. Rebuilding ProviderScope under a new key turned out to be safer and barely larger, because Riverpod does the teardown.
Call SharedPreferences.resetStatic() to re-prefix in placeWould make in-place switching nearly free. Rejected: it is @visibleForTesting, it drops a cache other controllers still hold references through, and it leaves us one package bump from breakage. Moving the profile segment into our own keys is equivalent, supported, and needs no migration.
Namespace every preference per profile (today’s behaviour)Isolation was right for the port and the pairing bearer, wrong for theme, shortcuts and recent models — which is why a worktree build opens looking unconfigured. Scope the server-bound keys; share the user-level ones.
Auto-delete a dev profile when its folder disappearsWould have prevented all 27 orphans — and silently destroyed pairings and transcripts the first time someone moved a worktree or unmounted a drive. Detect and offer; never reap unattended.
Archive / soft-delete before hard deleteA stopped profile already is the safe intermediate state: zero cost, keeps every byte, reversible with Start. A third state earns nothing.
Keep a global Stop button in ServerStopping the server you are actively talking to disconnects the window you clicked in. Per-profile Stop covers the real need; the active profile keeps only Restart, in Diagnostics.
Drop Custom host entirelyTempting for symmetry, but 0.0.0.0 / Docker / VM cases are real and cheap to keep. Demoted to Advanced instead of deleted.
+
+ + +
+

12 · Deltas — ordered by value per line changed

+

Paths are real; sizes are estimates.

+
+ + + + + + + + + + + + + + + + + + + + + +
#ChangeFileSizeWhy
1Surface daemon start/stop/restart failures: read the CLI's stdout as well as stderr, and post a StatusCenter failure from all four call sitesapp/lib/desktop/daemon/daemon_lifecycle.dart
app/lib/desktop/settings/sections/server_devices_section.dart
~60 Lshipped Every daemon failure was invisible: the reason was dropped on the floor, and the result object was discarded by both call sites.
2Free-port probe at profile creation + EADDRINUSE retry on daemon startapp/lib/desktop/daemon/daemon_lifecycle.dart~50 LRemoves a silent hard failure for the 2nd colliding worktree.
3ProfileRegistry: read/write ~/.makit/profiles.json, mint ids once, relocate by origin hintapp/lib/desktop/daemon/profile_registry.dart (new)~180 LTurns identity from “hash of a path” into a persisted fact. Fixes orphaning.
4Split isDefault into name (editable UI fact) + storage: legacy|namespaced (frozen compatibility fact); storage=="legacy" implies protectedapp/lib/desktop/daemon/server_profile.dart~40 L ΔOne boolean was doing two unrelated jobs — the shape that produced both robustness bugs. Lets ~/.makit be renamed without moving a byte of storage.
5ServerProfile.resolve → consults the registry; keeps path-derivation only as the bootstrap for an unseen dev buildapp/lib/desktop/daemon/server_profile.dart~70 L ΔZero-config worktree behaviour survives; it just becomes nameable.
6Rewrite the Server group: profile row, one reachability question, pair row, Diagnostics disclosureapp/lib/desktop/settings/sections/server_devices_section.dart~300 L Δ
(645 → ~450)
10 controls → 4 rows. The headline win.
7ServerBindModeReachability {thisMacOnly, myDevices} + allowLanFallback bool; migrate persisted auto/lan/loopback/customapp/lib/desktop/settings/server_config.dart~90 L ΔModel matches the two real answers. custom survives as an Advanced escape hatch.
8ProfileScopedPrefs: a narrow key-prefixing wrapper over SharedPreferences; stop calling setPrefix. Applied to server config, groups and pane layouts onlyapp/lib/store/prefs/profile_scoped_prefs.dart (new)
app/lib/desktop/settings/server_config.dart
app/lib/desktop/chat/groups/groups_controller.dart
app/lib/desktop/chat/panes/workspace_controller.dart
~170 LUnblocks in-place switching (setPrefix throws after getInstance). Keys stay byte-identical, so no migration. Also un-isolates theme/shortcuts, which never should have been per profile.
9ProfileRuntime: bundle the per-profile client, controller and prefs controllers plus their Riverpod overrides behind one disposable objectapp/lib/desktop/daemon/profile_runtime.dart (new)
app/lib/desktop/desktop_app.dart
~180 L ΔExtracts the 11 hand-written overrides in runDesktopApp so a second one can be built at runtime.
10In-place switch: confirm sheet → verify the target is up → rebuild ProviderScope(key: profileId) → retitle the window; roll back and post a failure if the target will not startapp/lib/desktop/desktop_app.dart
app/lib/desktop/chat/profile_switch_dialog.dart (new)
~210 LThe behaviour you asked for. Verify-then-commit means a dead target never strands the window.
11Badge → PopupMenuButton switcher; stop returning SizedBox.shrink() for the default profileapp/lib/desktop/chat/server_profile_badge.dart~90 L ΔReuses existing placement + hue identity. No new surface.
12New Profiles section + nav entry; retire server_devices.lifecycle/.cli/.fingerprint anchors into Diagnosticsapp/lib/desktop/settings/registry/settings_registry.dart
app/lib/desktop/settings/sections/profiles_section.dart (new)
~220 LKeeps deep links + settings search resolvable while the rows move.
13ProfileLifecycle: start/stop an arbitrary profile's daemon by MAKIT_HOME (today's controller only drives the active one)app/lib/desktop/daemon/daemon_lifecycle.dart~70 LReuses the existing makit stop verb (index.ts:130) with a per-profile MAKIT_HOME; only the “which profile” plumbing is new.
14deleteProfile(): stop daemon → rm $MAKIT_HOME → purge flutter.<id>. prefs → purge secure-store namespace → drop registry entry. Refuses the default and the active profile.app/lib/desktop/daemon/profile_registry.dart~140 LAll four stores or none — a partial delete leaks invisible prefs + keychain entries forever.
15Profile detail pane: name, status + Stop/Start, size breakdown, Reveal, danger zoneapp/lib/desktop/settings/sections/profiles_section.dart (new)~200 LGives lifecycle an object to attach to, instead of a global Stop button.
16Delete sheet enumerating removed vs kept, with running-session countapp/lib/desktop/settings/sections/profile_delete_dialog.dart (new)~150 L“Your code is untouched” is what makes the button usable.
17Orphan scan (existsSync(origin)) + du-per-profile + bulk reclaim sheetapp/lib/desktop/daemon/profile_registry.dart
app/lib/desktop/settings/sections/profiles_section.dart
~120 L27 of your 33 dev homes are already unreachable. Nothing today can list them.
18Detected-address dropdown fed by existing interface enumerationserver/src/pairing/cert.ts (localIPv4s) + new WS net.interfaces~70 LPick from reality instead of typing a host. Data already exists.
19Add &n= (name) + &id= to the pair URL; phone labels + colours serversserver/src/pairing/url.ts~12 LTurns “three bare IPs” into three named servers on the phone.
20Move Install CLI to General; drop the path display from Serverapp/lib/desktop/settings/sections/general_section.dart~40 L ΔOne-time actions belong with one-time actions.
+
+ +
+

Explicitly not changed

+
+

· The wire protocol. Flat frames, v:1, bearer auth, makit://pair — untouched apart from two optional query params (#19).

+

· MAKIT_HOME as the isolation boundary. Already correct, already used by every store (daemon/paths.ts, pairing/registry.ts, project-store.ts, push/config.ts). Profiles are a client concept; the server stays profile-agnostic.

+

· The daemon spawn path. desktop_app.dart:107 already passes environment: profile.environment. Nothing to add.

+

· Stored preference keys. The effective key stays flutter.<id>.<key> (and flutter.<key> for the legacy profile). The profile segment moves out of SharedPreferences.setPrefix and into app-owned keys (#8), so the stored key is byte-identical and no migration runs.

+

· Devices / Sessions / Danger-zone groups. Out of scope; they were never the complexity.

+

· Your git worktrees, repos and code. Profile deletion touches $MAKIT_HOME, prefs, secure store and the registry — never a path outside ~/.makit*. The origin field is read to detect orphans and is never written to or removed.

+

· Session retention / db size. The 544 MB makit.db + 99 MB media/ in the live ~/Work/Vibe/makit profile is a pruning problem, not a profiles problem. Deliberately deferred.

+
+
+ + + diff --git a/server/src/pairing/url.test.ts b/server/src/pairing/url.test.ts new file mode 100644 index 00000000..aaca992f --- /dev/null +++ b/server/src/pairing/url.test.ts @@ -0,0 +1,81 @@ +/** + * Pair-URL construction tests (SPEC-50 D12). + * + * D12 adds two OPTIONAL params (`n`, `id`) to the makit://pair URL so the + * phone can label each paired server. Back-compat is the hard requirement: + * when both are absent the URL must be byte-identical to the pre-D12 output, + * so already-paired phones and older app builds keep parsing. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { buildPairUrl, MAX_PROFILE_NAME_CODE_POINTS } from "./url.js"; + +const BASE = { + host: "192.168.1.42", + port: 8443, + fingerprint: "ab:cd:ef", + token: "tok-123", +}; + +test("without n/id the URL is byte-identical to the pre-D12 output", () => { + const url = buildPairUrl(BASE); + assert.equal( + url, + "makit://pair?host=192.168.1.42&port=8443&fp=ab%3Acd%3Aef&t=tok-123", + ); +}); + +test("empty-string name/id are treated as absent (byte-identical)", () => { + const withEmpties = buildPairUrl({ ...BASE, name: "", id: "" }); + assert.equal(withEmpties, buildPairUrl(BASE)); +}); + +test("name and id are appended as n and id when provided", () => { + const url = new URL(buildPairUrl({ ...BASE, name: "Work", id: "p_01" })); + assert.equal(url.searchParams.get("n"), "Work"); + assert.equal(url.searchParams.get("id"), "p_01"); + // fp/t/host/port are untouched. + assert.equal(url.searchParams.get("host"), "192.168.1.42"); + assert.equal(url.searchParams.get("port"), "8443"); + assert.equal(url.searchParams.get("fp"), "ab:cd:ef"); + assert.equal(url.searchParams.get("t"), "tok-123"); +}); + +test("name is URL-encoded for spaces, & # emoji and non-ASCII", () => { + const name = "Wörk & Play #1 🎹"; + const raw = buildPairUrl({ ...BASE, name }); + // Round-trips exactly through a parser. + assert.equal(new URL(raw).searchParams.get("n"), name); + // The raw string must not contain the literal delimiters that would break + // parsing — they must be percent-encoded. + const query = raw.split("?")[1]; + const nSegment = query.split("&").find((s) => s.startsWith("n="))!; + assert.ok(!nSegment.includes(" "), "space must be encoded"); + assert.ok(!nSegment.includes("#"), "# must be encoded"); + assert.ok(!nSegment.includes("🎹"), "emoji must be encoded"); + // The unencoded '&' would have split into an extra param; assert it didn't. + assert.equal(new URL(raw).searchParams.get("Play"), null); +}); + +test("names longer than the cap are truncated at the code-point boundary", () => { + const long = "x".repeat(MAX_PROFILE_NAME_CODE_POINTS + 50); + const n = new URL(buildPairUrl({ ...BASE, name: long })).searchParams.get("n")!; + assert.equal([...n].length, MAX_PROFILE_NAME_CODE_POINTS); +}); + +test("truncation never splits a multi-code-unit emoji", () => { + // A string of emoji, each 2 UTF-16 code units, past the cap. + const long = "🎹".repeat(MAX_PROFILE_NAME_CODE_POINTS + 10); + const n = new URL(buildPairUrl({ ...BASE, name: long })).searchParams.get("n")!; + assert.equal([...n].length, MAX_PROFILE_NAME_CODE_POINTS); + // Every code point is a whole piano emoji — no lone surrogate. + assert.ok([...n].every((cp) => cp === "🎹")); +}); + +test("a name exactly at the cap is preserved untouched", () => { + const exact = "a".repeat(MAX_PROFILE_NAME_CODE_POINTS); + const n = new URL(buildPairUrl({ ...BASE, name: exact })).searchParams.get("n")!; + assert.equal(n, exact); +}); diff --git a/server/src/pairing/url.ts b/server/src/pairing/url.ts index 9b9ba823..7dee673f 100644 --- a/server/src/pairing/url.ts +++ b/server/src/pairing/url.ts @@ -3,17 +3,41 @@ * * Format: * makit://pair?host=&port=&fp=&t= + * [&n=][&id=] * * `host` is the best-guess LAN address — first non-internal IPv4. The phone * uses mDNS to corroborate, and the cert fingerprint pin defends against * accidental connection to the wrong host. + * + * `n` (display name) and `id` (stable profile id) are OPTIONAL additions + * (SPEC-50 D12) that let the phone label each paired server instead of showing + * a bare IP. There is NO protocol version bump: per-profile pairing already + * works because the QR carries host/port/fp/t. When both are absent the URL is + * byte-identical to the pre-D12 output, so already-paired phones and older app + * builds keep parsing. Absent → the phone falls back to `host:port`. */ +/** + * Cap on the profile display name, in Unicode code points. + * + * The pair URL is rendered into a QR code, whose capacity is finite: a runaway + * name (e.g. a pasted 5000-char string) would push the QR past a scannable + * density. A display label is a short human word ("Work", "feat-profiles"), so + * 64 code points is generous headroom while keeping the QR small and reliably + * scannable. Truncation is on code-point boundaries so a multi-unit emoji is + * never split into a lone surrogate. + */ +export const MAX_PROFILE_NAME_CODE_POINTS = 64; + export interface PairUrlOpts { host: string; port: number; fingerprint: string; token: string; + /** Optional human label for the profile (SPEC-50 D12). */ + name?: string; + /** Optional stable profile id (SPEC-50 D12). */ + id?: string; } export function buildPairUrl(opts: PairUrlOpts): string { @@ -22,5 +46,20 @@ export function buildPairUrl(opts: PairUrlOpts): string { u.searchParams.set("port", String(opts.port)); u.searchParams.set("fp", opts.fingerprint); u.searchParams.set("t", opts.token); + // Optional D12 params: only emitted when a non-empty value is supplied, so an + // absent/empty value keeps the URL byte-identical to the pre-D12 output. + if (opts.name) { + u.searchParams.set("n", capName(opts.name)); + } + if (opts.id) { + u.searchParams.set("id", opts.id); + } return u.toString(); } + +/** Truncate `name` to the cap on code-point boundaries (never splits surrogates). */ +function capName(name: string): string { + const codePoints = [...name]; + if (codePoints.length <= MAX_PROFILE_NAME_CODE_POINTS) return name; + return codePoints.slice(0, MAX_PROFILE_NAME_CODE_POINTS).join(""); +}