Skip to content

Commit 66c85fb

Browse files
feat(app): SPEC-03 Phase 1 — macOS desktop control app foundations (#11)
* docs: mark SPEC-02 done, add SPEC-03 architecture & parallel plan - SPEC-02 (CLI client subcommands): all acceptance criteria met, marked complete - SPEC-03: design → implementation phase - Added SPEC-03-ARCHITECTURE-AND-PLAN.md with: - Flutter desktop (macOS) reuse rationale - Layered architecture (control-plane socket client + desktop UI + shared store) - 7 tasks with parallel breakdown (3-4 subagents, Phase 1-3 timeline) - Tech decision points (menubar lib, socket client, cert trust, daemon spawn) - Success criteria + known constraints - ~8-10 hrs estimated effort - All tasks gated for mobile compat, no changes to mobile build * feat(app): SPEC-03 Phase 1 — macOS desktop control app foundations Parallel Phase 1 work for SPEC-03 (macOS desktop control app). Three independent streams delivered in worktrees: Stream A — Control client (app/lib/control/) - control_types.dart: wire-protocol data classes (StatusData, PairMintData, DeviceInfo, LogChunk, ControlResponse) mirroring server/src/daemon/protocol.ts verbs (status, pair.mint/current, devices.list/revoke, sessions.list, server.stop, logs.tail). - control_codec.dart: NDJSON encode/decode, decodeResponse yields null on malformed input. - control_client.dart: PinoControlClient over a Unix-domain socket (~/.pino/control.sock). Injectable connector + id generator. id-correlated request/response with timeout, streaming tailLogs, convenience methods per verb. - 44 tests (codec round-trip + client behaviour). Stream B — Menubar tray (app/lib/desktop/tray/) - tray_controller.dart: TrayController (ChangeNotifier) with TrayPlatform seam for testability. init/update/dispose, full menu structure (Start/Stop, Dashboard, Pair QR, Devices(N), Sessions(N), Quit), macOS Platform.isMacOS guards on every tray_manager call, DaemonSummary model. - tray_icons.dart: default tray icon path. - 10 tests with FakeTrayPlatform. Stream C — Desktop screens (app/lib/desktop/screens/) - control_contract.dart: abstract ControlClient + plain DTOs the screens depend on (decoupled from Stream A's wire types; bridged at Phase-4 app root). - 5 screens: status_screen, qr_screen (QrImageView + countdown), devices_screen (revoke), sessions_screen, session_log_screen (streaming with auto-scroll + manual-scroll-pause). - providers.dart: controlClientProvider placeholder. - time_format.dart: formatUptime/formatCountdown/formatRelative. - fake_control_client.dart: injectable canned client. - 18 widget tests covering data/empty/error/interaction. Dependencies added to pubspec.yaml (native plugins pinned exactly): - tray_manager 0.5.3 (leanflutter.dev) - window_manager 0.5.1 (leanflutter.dev) - qr_flutter ^4.1.0 (theyakka.com, pure-Dart) Verification: - flutter analyze --fatal-infos (entire app/): No issues found - flutter test lib/control/ + lib/desktop/: 72/72 pass - server pnpm test (181/181) + typecheck: clean Companion to docs/SPEC-03-ARCHITECTURE-AND-PLAN.md. See also: SPEC-03 status updated to 'design → implementation'. * style(app): run `dart format` on SPEC-03 Phase 1 files CI `lint-and-analyze` job failed: `dart format` reported "Formatted 68 files (14 changed)". This commit applies the formatter to all files under `app/lib/control/` and `app/lib/desktop/` to reach zero diff. No semantic changes; `flutter analyze --fatal-infos` and all 72 tests remain green. * fix(app): align control client contract * fix(app): resolve PR review follow-ups * fix(app): harden control client lifecycle and log streaming - guard connect() against double-connect and reconnect-after-dispose so the previous socket + subscription can no longer leak - bound the inbound NDJSON buffer (1 MiB) so a newline-less local peer cannot grow it without limit - surface a socket drop as a stream error for follow tailLogs consumers instead of a silent clean EOF; non-follow tails still close cleanly - remove dead decodeTypedResponse codec helper - add regression tests for each Co-authored-by: Milan Le <leduckhc@users.noreply.github.com> * fix(app): make TrayController platform gate testable Introduce an injectable isMacOS seam (defaulting to Platform.isMacOS) so the guarded init/update/dispose paths run under test on non-macOS CI. Previously every tray_manager call was skipped on Linux, leaving all tray assertions checking empty state against non-empty expectations. Add a no-op-off-macOS test. Co-authored-by: Milan Le <leduckhc@users.noreply.github.com> * fix(app): guard desktop screens against use-after-dispose - qr_screen: check mounted after each await before starting the countdown ticker so a disposed screen cannot leave a Timer.periodic running - session_log_screen: bail out of the post-frame auto-scroll callback when no longer mounted, avoiding asserts on a disposed ScrollController Co-authored-by: Milan Le <leduckhc@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Milan Le <leduckhc@users.noreply.github.com>
1 parent 624df25 commit 66c85fb

23 files changed

Lines changed: 3758 additions & 3 deletions
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
/// Control-plane client for the pino desktop app (SPEC-03).
2+
///
3+
/// A thin request/response client over the daemon's unix-domain control socket
4+
/// (`~/.pino/control.sock`). It speaks the frozen NDJSON protocol from
5+
/// `server/src/daemon/protocol.ts` (ported in `control_types.dart` /
6+
/// `control_codec.dart`):
7+
///
8+
/// - single-shot verbs resolve on the first response frame matching the
9+
/// request id ([request] + the typed convenience methods);
10+
/// - `logs.tail` streams many `{ line }` frames for one id, terminated by a
11+
/// `{ done: true }` frame or the socket closing ([tailLogs]).
12+
///
13+
/// The transport is abstracted behind [ControlConnection] so tests can inject a
14+
/// scripted fake instead of a real socket.
15+
library;
16+
17+
import 'dart:async';
18+
import 'dart:convert';
19+
import 'dart:io';
20+
21+
import 'control_codec.dart';
22+
import 'control_contract.dart';
23+
import 'control_types.dart';
24+
25+
/// The default per-request timeout for single-shot verbs.
26+
const _defaultRequestTimeout = Duration(seconds: 30);
27+
28+
/// Cap on the un-terminated inbound buffer. A local peer that never sends a
29+
/// newline must not be able to grow this without bound.
30+
const _maxBufferBytes = 1 << 20; // 1 MiB
31+
32+
/// A bidirectional line-oriented connection to the control socket.
33+
///
34+
/// Abstracts `dart:io` [Socket] so the client can be tested with a scripted
35+
/// in-memory fake.
36+
abstract class ControlConnection {
37+
/// Raw inbound bytes from the peer.
38+
Stream<List<int>> get stream;
39+
40+
/// Write an already-encoded (newline-terminated) wire line.
41+
void write(String data);
42+
43+
/// Close the underlying transport.
44+
Future<void> close();
45+
}
46+
47+
/// Opens a control connection given a socket path. Injected for testing.
48+
typedef ControlConnector =
49+
Future<ControlConnection> Function(String socketPath);
50+
51+
/// A [ControlConnection] backed by a real `dart:io` unix-domain [Socket].
52+
class _SocketControlConnection implements ControlConnection {
53+
_SocketControlConnection(this._socket);
54+
55+
final Socket _socket;
56+
57+
@override
58+
Stream<List<int>> get stream => _socket;
59+
60+
@override
61+
void write(String data) => _socket.write(data);
62+
63+
@override
64+
Future<void> close() async => _socket.destroy();
65+
}
66+
67+
Future<ControlConnection> _connectUnixSocket(String socketPath) async {
68+
final socket = await Socket.connect(
69+
InternetAddress(socketPath, type: InternetAddressType.unix),
70+
0,
71+
);
72+
return _SocketControlConnection(socket);
73+
}
74+
75+
/// Default monotonically-increasing id generator (`c1`, `c2`, …).
76+
class _SeqIdGenerator {
77+
int _seq = 0;
78+
String next() => 'c${++_seq}';
79+
}
80+
81+
class _Pending {
82+
_Pending(this.verb, this.completer);
83+
final ControlVerb verb;
84+
final Completer<ControlResponse<Object?>> completer;
85+
}
86+
87+
class _LogStream {
88+
_LogStream(this.controller, {required this.follow});
89+
final StreamController<LogLine> controller;
90+
91+
/// Whether the consumer asked to keep streaming live lines. A [follow]
92+
/// stream that ends because the socket dropped is a failure, not a clean EOF.
93+
final bool follow;
94+
}
95+
96+
/// A control-plane client for a running pino daemon.
97+
class PinoControlClient implements ControlClient {
98+
/// Creates a client for the daemon at [socketPath].
99+
///
100+
/// [idGenerator] supplies request ids (injectable for deterministic tests);
101+
/// [requestTimeout] caps single-shot verbs; [connector] opens the transport
102+
/// (defaults to a real unix-domain socket).
103+
PinoControlClient({
104+
required this.socketPath,
105+
String Function()? idGenerator,
106+
Duration requestTimeout = _defaultRequestTimeout,
107+
ControlConnector? connector,
108+
}) : _requestTimeout = requestTimeout,
109+
_connector = connector ?? _connectUnixSocket,
110+
_nextId = idGenerator ?? _SeqIdGenerator().next;
111+
112+
/// Path to the daemon's control socket.
113+
final String socketPath;
114+
115+
final Duration _requestTimeout;
116+
final ControlConnector _connector;
117+
final String Function() _nextId;
118+
119+
ControlConnection? _conn;
120+
StreamSubscription<List<int>>? _sub;
121+
bool _closed = false;
122+
String _buffer = '';
123+
124+
final _pending = <String, _Pending>{};
125+
final _streams = <String, _LogStream>{};
126+
127+
/// Open the control socket. Throws (e.g. [SocketException]) if the daemon is
128+
/// not listening.
129+
///
130+
/// A client is single-use: calling [connect] more than once, or after
131+
/// [dispose], throws a [StateError] rather than silently leaking the previous
132+
/// socket and its subscription.
133+
Future<void> connect() async {
134+
if (_closed) {
135+
throw StateError('control client has been disposed');
136+
}
137+
if (_conn != null) {
138+
throw StateError('control client is already connected');
139+
}
140+
final conn = await _connector(socketPath);
141+
_conn = conn;
142+
_sub = conn.stream.listen(
143+
_onData,
144+
onError: (Object _) => _onClosed('control socket error'),
145+
onDone: () => _onClosed('control socket closed'),
146+
cancelOnError: true,
147+
);
148+
}
149+
150+
/// Issue [verb] and resolve the first response frame matching its id.
151+
///
152+
/// The returned response's `data` is typed per verb (see [parseVerbData]).
153+
/// Times out after the configured request timeout. Not usable for the
154+
/// multi-frame `logs.tail` stream — use [tailLogs] for that.
155+
Future<ControlResponse<Object?>> request(
156+
ControlVerb verb, {
157+
Map<String, dynamic>? args,
158+
}) {
159+
if (_closed || _conn == null) {
160+
return Future.error(
161+
const ControlException('control client not connected'),
162+
);
163+
}
164+
final id = _nextId();
165+
final completer = Completer<ControlResponse<Object?>>();
166+
_pending[id] = _Pending(verb, completer);
167+
_conn!.write(encodeRequest(verb, id: id, args: args));
168+
return completer.future.timeout(
169+
_requestTimeout,
170+
onTimeout: () {
171+
_pending.remove(id);
172+
throw TimeoutException('control request $id (${verb.wire}) timed out');
173+
},
174+
);
175+
}
176+
177+
/// Stream the daemon log tail.
178+
///
179+
/// Emits one [LogLine] per `{ line }` frame. The stream closes on a
180+
/// `{ done: true }` frame (non-follow) or when the socket closes, and errors
181+
/// with a [ControlException] on an error frame. When [follow] is true the
182+
/// daemon keeps streaming new lines until the subscription is cancelled.
183+
@override
184+
Stream<LogLine> tailLogs({int? lines, bool follow = false}) {
185+
late final StreamController<LogLine> controller;
186+
final id = _nextId();
187+
controller = StreamController<LogLine>(
188+
onCancel: () {
189+
_streams.remove(id);
190+
if (follow && !_closed && _conn != null) {
191+
_conn!.write(
192+
encodeRequest(
193+
ControlVerb.logsCancel,
194+
id: _nextId(),
195+
args: {'id': id},
196+
),
197+
);
198+
}
199+
},
200+
);
201+
controller.onListen = () {
202+
if (_closed || _conn == null) {
203+
controller.addError(
204+
const ControlException('control client not connected'),
205+
);
206+
unawaited(controller.close());
207+
return;
208+
}
209+
_streams[id] = _LogStream(controller, follow: follow);
210+
final args = <String, dynamic>{};
211+
if (lines != null) args['lines'] = lines;
212+
if (follow) args['follow'] = true;
213+
_conn!.write(
214+
encodeRequest(
215+
ControlVerb.logsTail,
216+
id: id,
217+
args: args.isEmpty ? null : args,
218+
),
219+
);
220+
};
221+
return controller.stream;
222+
}
223+
224+
/// Close the socket and fail any in-flight requests/streams.
225+
Future<void> dispose() async {
226+
_onClosed('control client disposed');
227+
await _sub?.cancel();
228+
_sub = null;
229+
await _conn?.close();
230+
_conn = null;
231+
}
232+
233+
// ---- convenience verbs ---------------------------------------------------
234+
235+
/// Fetch daemon [StatusData].
236+
@override
237+
Future<StatusData> status() async =>
238+
_require<StatusData>(await request(ControlVerb.status));
239+
240+
/// Mint a fresh pairing token ([PairMintData]).
241+
@override
242+
Future<PairMintData> pairMint({int? ttlMs}) async => _require<PairMintData>(
243+
await request(
244+
ControlVerb.pairMint,
245+
args: ttlMs == null ? null : {'ttlMs': ttlMs},
246+
),
247+
);
248+
249+
/// The active unexpired pairing token, or `null` if none.
250+
@override
251+
Future<PairCurrentData?> pairCurrent() async =>
252+
_optional<PairCurrentData>(await request(ControlVerb.pairCurrent));
253+
254+
/// List paired devices.
255+
@override
256+
Future<List<DeviceInfo>> devicesList() async =>
257+
_require<DevicesListData>(await request(ControlVerb.devicesList)).devices;
258+
259+
/// Revoke a paired device by [id]; returns whether one was removed.
260+
@override
261+
Future<bool> devicesRevoke(String id) async => _require<DevicesRevokeData>(
262+
await request(ControlVerb.devicesRevoke, args: {'id': id}),
263+
).removed;
264+
265+
/// List running sessions.
266+
@override
267+
Future<List<ControlSession>> sessionsList() async =>
268+
_require<SessionsListData>(
269+
await request(ControlVerb.sessionsList),
270+
).sessions;
271+
272+
/// Ask the daemon to shut down.
273+
@override
274+
Future<void> serverStop() async =>
275+
_require<ServerStopData>(await request(ControlVerb.serverStop));
276+
277+
// ---- internals -----------------------------------------------------------
278+
279+
/// Extract required typed [data] from a response, throwing on error/mismatch.
280+
T _require<T>(ControlResponse<Object?> res) {
281+
switch (res) {
282+
case ControlErr(:final error):
283+
throw ControlException(error);
284+
case ControlOk(:final data):
285+
if (data is T) return data;
286+
throw ControlException(
287+
'malformed or missing payload for response ${res.id}',
288+
);
289+
}
290+
}
291+
292+
/// Extract optional typed [data] (a `null` ok payload is a valid absence).
293+
T? _optional<T>(ControlResponse<Object?> res) {
294+
switch (res) {
295+
case ControlErr(:final error):
296+
throw ControlException(error);
297+
case ControlOk(:final data):
298+
return data as T?;
299+
}
300+
}
301+
302+
void _onData(List<int> chunk) {
303+
_buffer += utf8.decode(chunk, allowMalformed: true);
304+
final parts = _buffer.split('\n');
305+
_buffer = parts.removeLast();
306+
for (final line in parts) {
307+
if (line.isEmpty) continue;
308+
_dispatch(line);
309+
}
310+
if (_buffer.length > _maxBufferBytes) {
311+
_onClosed('control socket line exceeded $_maxBufferBytes bytes');
312+
}
313+
}
314+
315+
void _dispatch(String line) {
316+
final raw = decodeResponse(line);
317+
if (raw == null) return;
318+
final id = raw.id;
319+
320+
final pending = _pending.remove(id);
321+
if (pending != null) {
322+
switch (raw) {
323+
case ControlErr(:final error):
324+
pending.completer.complete(ControlErr<Object?>(id, error));
325+
case ControlOk(:final data):
326+
pending.completer.complete(
327+
ControlOk<Object?>(id, parseVerbData(pending.verb, data)),
328+
);
329+
}
330+
return;
331+
}
332+
333+
final logStream = _streams[id];
334+
if (logStream != null) {
335+
final controller = logStream.controller;
336+
switch (raw) {
337+
case ControlErr(:final error):
338+
_streams.remove(id);
339+
controller.addError(ControlException(error));
340+
unawaited(controller.close());
341+
case ControlOk(:final data):
342+
final chunk = LogChunk.fromJson(data);
343+
switch (chunk) {
344+
case LogLine():
345+
controller.add(chunk);
346+
case LogDone():
347+
_streams.remove(id);
348+
unawaited(controller.close());
349+
case null:
350+
break; // ignore an unrecognized chunk
351+
}
352+
}
353+
}
354+
}
355+
356+
void _onClosed(String reason) {
357+
if (_closed) return;
358+
_closed = true;
359+
final err = ControlException(reason);
360+
for (final entry in _pending.values) {
361+
if (!entry.completer.isCompleted) entry.completer.completeError(err);
362+
}
363+
_pending.clear();
364+
for (final logStream in _streams.values) {
365+
// A follow stream that ends because the socket dropped is a failure the
366+
// consumer must be able to detect; a non-follow tail ending is a normal
367+
// EOF, so close it cleanly.
368+
if (logStream.follow && !logStream.controller.isClosed) {
369+
logStream.controller.addError(err);
370+
}
371+
unawaited(logStream.controller.close());
372+
}
373+
_streams.clear();
374+
}
375+
}

0 commit comments

Comments
 (0)