//! Attach-mode client: connect to a running pmacs daemon over a Unix //! socket, negotiate `semantic_render + crdt_replica`, then pump //! `InstanceMessage` frames onto the winit event loop. //! //! Session 3+ of the pmacs-gpu arc — see `docs/pmacs-gpu-design.md`. //! Scope: handshake + decode the message stream + send a few //! `FrontendEvent`s back (currently just `Viewport`; session 5+ adds //! cursor/edit/focus). Importing the CRDT snapshot, applying live //! ops, and reconstructing the rope happen on the main thread, where //! the `LoroDoc` lives (it isn't trivially `Send`; cross-thread //! shipping is the *decoded* `InstanceMessage`, not the doc state). //! //! The reader thread blocks on a single `read_message` per iteration; //! every received message becomes an [`AttachEvent`] forwarded //! through [`winit::event_loop::EventLoopProxy::send_event`], which //! wakes the main loop so the frame logic can apply the message and //! redraw. use std::os::unix::net::UnixStream; use std::path::Path; use std::sync::mpsc; use std::thread; use pmacs_protocol::{ AttachRequest, BufferId, ByteRange, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message, write_message, }; use winit::event_loop::EventLoopProxy; use crate::AppEvent; /// Errors the attach client surfaces. Kept narrow on purpose: the /// hello-world fallback is the right recovery for any of these in /// session 3, so the caller's only job is to log + drop back to the /// inert renderer. #[derive(Debug)] pub enum AttachClientError { /// Couldn't open the Unix socket. Connect(std::io::Error), /// Transport framing failed during the handshake. Handshake(TransportError), /// Server's `protocol_version` is outside `SUPPORTED_PROTOCOL_VERSIONS`. VersionMismatch { server: u32, client: u32 }, } impl std::fmt::Display for AttachClientError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Connect(e) => write!(f, "connect to daemon socket failed: {e}"), Self::Handshake(e) => write!(f, "attach handshake failed: {e}"), Self::VersionMismatch { server, client } => write!( f, "daemon protocol version {server} not in client's supported set (this client = \ {client}, supports {SUPPORTED_PROTOCOL_VERSIONS:?})" ), } } } impl std::error::Error for AttachClientError {} /// One decoded event forwarded from the reader thread to the main /// loop. `Message` carries the entire `InstanceMessage`; `Disconnected` /// fires once when the reader thread exits (clean EOF or transport /// error — both look identical from the main thread's perspective). #[derive(Debug)] pub enum AttachEvent { /// A decoded message frame from the daemon. Message(Box), /// The reader thread exited. Includes the disconnect reason for /// logging on the main thread. Disconnected(String), } /// Connect, handshake, and spawn the reader thread. /// /// Returns once the handshake has completed and the reader thread is /// running. The reader thread owns the read half of the stream; a /// writer thread owns the write half. The returned [`AttachClient`] /// queues outbound `FrontendEvent`s so the winit UI thread never blocks /// on daemon socket backpressure. /// /// **Initial window size note** — `AttachRequest::initial_size` is /// nominally a `CellSize` (rows × cols) anchored to the TUI. The /// `pmacs-gpu` window isn't a cell grid; we send a placeholder of the /// approximate cell count for the initial 800×200 window so the /// daemon's initial render makes plausible space for content. This /// is a small finding for session 3's audit (the wire-shape detail /// "`AttachRequest`'s `CellSize` assumes a grid frontend"); resolution /// classified under rule (iii) as deferred — a structural answer /// belongs with Q#2's minimap variant or its own protocol thread, /// not session 3's attach loop. pub fn connect( socket_path: &Path, proxy: EventLoopProxy, ) -> Result { let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?; // Hello round-trip. let mut handshake_stream = stream.try_clone().map_err(AttachClientError::Connect)?; let hello: Hello = read_message(&mut handshake_stream).map_err(AttachClientError::Handshake)?; if !is_supported_protocol_version(hello.protocol_version) { return Err(AttachClientError::VersionMismatch { server: hello.protocol_version, client: PROTOCOL_VERSION, }); } eprintln!( "pmacs-gpu: attached to daemon (protocol v{}, instance pmacs {})", hello.protocol_version, hello.instance_identity.pmacs_version ); // AttachRequest — declare the capabilities a semantic frontend // needs. `multi_frontend` is included because the existing daemon // gates `crdt_replica` behind it (M10.x dependency). // // **Daemon requirement**: the daemon must be built with the // `crdt` feature (`cargo run --features crdt --bin pmacs -- // --daemon ...`). Without it the daemon's // `InstanceCapabilities::default` returns `crdt_replica: false`, // negotiation succeeds but no `BufferSnapshot` ever arrives, and // the `pmacs-gpu` window sits on `(connecting...)` forever. This // surfaced as a session-3 finding when manually validating the // attach loop; classified as small under rule (iii) — recorded // here so the next person attaching against a non-crdt daemon // recognizes the symptom immediately. let req = AttachRequest { protocol_version: hello.protocol_version, frontend_capabilities: FrontendCapabilities { synchronized_output: false, unicode_smp: true, true_color: true, mouse: false, bracketed_paste: false, terminal_kind: Some("pmacs-gpu".to_owned()), multi_frontend: true, crdt_replica: true, semantic_render: true, }, // Placeholder — see the doc comment above. Cell-shaped initial // size is awkward for a pixel frontend; for session 3 we send // approximate dimensions so the daemon's initial-render // ranging is plausible. initial_size: pmacs_protocol::CellSize::new(24, 80), }; write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?; // Split read/write halves for the reader thread + writer thread. // UnixStream clones share the underlying FD with independent // buffer state — safe to read on one clone while the other writes // (the FD is full-duplex). let mut read_stream = stream.try_clone().map_err(AttachClientError::Connect)?; let write_stream = stream; let (writer_tx, writer_rx) = mpsc::channel::(); // Reader thread. Each iteration: block on read_message, decode, // forward via the event-loop proxy. Exits cleanly on EOF / any // transport error; the main thread receives a single Disconnected // event and drops back to the inert renderer. thread::Builder::new() .name("pmacs-gpu attach reader".into()) .spawn(move || { loop { match read_message::(&mut read_stream) { Ok(msg) => { if proxy .send_event(AppEvent::Attach(AttachEvent::Message(Box::new(msg)))) .is_err() { // Main loop torn down — quietly exit. return; } } Err(e) => { let _ = proxy .send_event(AppEvent::Attach(AttachEvent::Disconnected(e.to_string()))); return; } } } }) .expect("spawn attach reader thread"); // Writer thread. Socket writes can block when the daemon falls // behind; doing them here keeps keyboard input, redraws, and // message application off that backpressure path. thread::Builder::new() .name("pmacs-gpu attach writer".into()) .spawn(move || { let mut write_stream = write_stream; while let Ok(event) = writer_rx.recv() { if let Err(e) = write_message(&mut write_stream, &event) { eprintln!("pmacs-gpu: attach writer stopped: {e}"); return; } } }) .expect("spawn attach writer thread"); Ok(AttachClient { writer_tx, frontend_id: hello.assigned_frontend_id, server_protocol_version: hello.protocol_version, }) } /// Handle the main loop keeps after `connect` returns. It queues /// `FrontendEvent`s for the attach writer thread. pub struct AttachClient { writer_tx: mpsc::Sender, /// Assigned by the daemon in the `Hello` response. Every /// `FrontendEvent` carries this so the daemon can route input back /// to the per-session `SemanticRenderState`. frontend_id: FrontendId, /// The daemon's `Hello.protocol_version`. Wire variants newer /// than the daemon (e.g. `Pointer`, v5) must be gated on this — /// an older daemon hard-errors decoding an unknown variant. server_protocol_version: u32, } impl AttachClient { /// Frontend id assigned by the daemon in the initial `Hello`. pub fn frontend_id(&self) -> FrontendId { self.frontend_id } /// Send a `FrontendEvent::Viewport` to the daemon. The daemon's /// `SemanticRenderState::set_viewport` feeds the spans producer; /// without this call the daemon ships no `StyleSpans` for the /// buffer (no declared viewport ⇒ no scoped styling). pub fn send_viewport( &self, buffer_id: BufferId, visible: ByteRange, generation: u64, ) -> Result<(), TransportError> { self.send_event(FrontendEvent::Viewport { frontend_id: self.frontend_id, buffer_id, visible, generation, }) } /// Send a `FrontendEvent::Key` to the daemon (session B1). The /// daemon routes it through `dispatch_key` — the same keymap + /// command + Lua stack the TUI drives — so cursor motion and (in /// later sessions) edits are produced entirely instance-side; the /// resulting `CursorByte` / `CrdtOp` come back over the attach /// stream. `timestamp_ns` is 0 (no capture clock plumbed yet; the /// daemon does not depend on it). pub fn send_key(&self, key: Key, mods: Modifiers) -> Result<(), TransportError> { self.send_event(FrontendEvent::Key(KeyEvent { frontend_id: self.frontend_id, key, mods, timestamp_ns: 0, })) } /// Send a `FrontendEvent::Pointer` (session M-2): a locally /// hit-tested gesture in source bytes. Callers gate on /// [`Self::server_protocol_version`] `>= 5`. pub fn send_pointer( &self, buffer_id: BufferId, byte: u64, kind: PointerKind, mods: Modifiers, ) -> Result<(), TransportError> { self.send_event(FrontendEvent::Pointer { frontend_id: self.frontend_id, buffer_id, byte, kind, mods, }) } /// Send a `FrontendEvent::Paste` (Q#CM6) carrying OS-clipboard /// bytes read locally via `arboard` on Ctrl-V. The daemon inserts it /// at the cursor (replacing any region) and refreshes its clipboard /// slot, exactly as it handles the TUI's bracketed paste. pub fn send_paste(&self, data: Vec) -> Result<(), TransportError> { self.send_event(FrontendEvent::Paste { frontend_id: self.frontend_id, data, }) } /// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu /// navigation hit-tested locally against the popup we drew. `index` /// is the row the pointer is over (`None` = off the menu); `invoke` /// marks a click (invoke the row, or dismiss when `index` is `None`). pub fn send_menu_pointer( &self, index: Option, invoke: bool, ) -> Result<(), TransportError> { self.send_event(FrontendEvent::MenuPointer { frontend_id: self.frontend_id, index, invoke, }) } /// The daemon's negotiated wire version from `Hello`. pub fn server_protocol_version(&self) -> u32 { self.server_protocol_version } /// Send a locally-authored CRDT operation to the daemon. The GPU /// uses this for idle plain-text insertion after applying the same /// op to its local Loro replica, avoiding a Key round trip on the /// hot typing path. pub fn send_crdt_op(&self, buffer_id: BufferId, op: CrdtOp) -> Result<(), TransportError> { self.send_event(FrontendEvent::CrdtOp { frontend_id: self.frontend_id, buffer_id, op, }) } fn send_event(&self, event: FrontendEvent) -> Result<(), TransportError> { self.writer_tx.send(event).map_err(|_| { TransportError::Io(std::io::Error::new( std::io::ErrorKind::BrokenPipe, "attach writer thread stopped", )) }) } }