Skip to main content

fresh/client/
mod.rs

1//! Ultra-light client for session persistence
2//!
3//! The client is intentionally minimal:
4//! - Connect to server (data + control sockets)
5//! - Perform handshake (send env, check version)
6//! - Set terminal to raw mode
7//! - Relay bytes bidirectionally (stdin↔data socket, data socket↔stdout)
8//! - Forward resize events via control socket
9//! - Restore terminal on exit
10//!
11//! All complexity (input parsing, rendering, editor logic) lives server-side.
12
13use std::io;
14#[cfg(unix)]
15use std::sync::atomic::AtomicBool;
16#[cfg(unix)]
17use std::sync::Arc;
18
19use crate::server::ipc::{ClientConnection, SocketPaths};
20use crate::server::protocol::{
21    ClientControl, ClientHello, ServerControl, TermSize, PROTOCOL_VERSION,
22};
23
24#[cfg(unix)]
25mod relay_unix;
26
27/// Client configuration
28pub struct ClientConfig {
29    /// Socket paths for the session
30    pub socket_paths: SocketPaths,
31    /// Initial terminal size
32    pub term_size: TermSize,
33}
34
35/// Reason the client exited
36#[derive(Debug)]
37pub enum ClientExitReason {
38    /// Server closed the connection normally
39    ServerQuit,
40    /// User requested detach
41    Detached,
42    /// Version mismatch between client and server
43    VersionMismatch { server_version: String },
44    /// Connection error
45    Error(io::Error),
46}
47
48/// Run the client, connecting to an existing server
49///
50/// This function blocks until the connection is closed or an error occurs.
51/// It handles:
52/// - Handshake with version negotiation
53/// - Raw mode setup
54/// - Bidirectional byte relay
55/// - Resize events (via SIGWINCH on Unix)
56/// - Clean terminal restoration
57pub fn run_client(config: ClientConfig) -> io::Result<ClientExitReason> {
58    let conn = ClientConnection::connect(&config.socket_paths)?;
59    run_client_with_connection(config, conn)
60}
61
62/// Run the client with an already-established connection
63///
64/// This is useful when the caller has already established a connection
65/// (e.g., after retrying connection attempts). Performs handshake then relay.
66pub fn run_client_with_connection(
67    config: ClientConfig,
68    conn: ClientConnection,
69) -> io::Result<ClientExitReason> {
70    // Perform handshake
71    let hello = ClientHello::new(config.term_size);
72    let hello_json = serde_json::to_string(&ClientControl::Hello(hello))
73        .map_err(|e| io::Error::other(e.to_string()))?;
74    conn.write_control(&hello_json)?;
75
76    // Read server response
77    let response = conn
78        .read_control()?
79        .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "Server closed connection"))?;
80
81    let server_msg: ServerControl =
82        serde_json::from_str(&response).map_err(|e| io::Error::other(e.to_string()))?;
83
84    match server_msg {
85        ServerControl::Hello(server_hello) => {
86            if server_hello.protocol_version != PROTOCOL_VERSION {
87                return Ok(ClientExitReason::VersionMismatch {
88                    server_version: server_hello.server_version,
89                });
90            }
91            tracing::info!(
92                "Connected to session '{}' (server {})",
93                server_hello.session_id,
94                server_hello.server_version
95            );
96        }
97        ServerControl::VersionMismatch(mismatch) => {
98            return Ok(ClientExitReason::VersionMismatch {
99                server_version: mismatch.server_version,
100            });
101        }
102        ServerControl::Error { message } => {
103            return Err(io::Error::other(format!("Server error: {}", message)));
104        }
105        _ => {
106            return Err(io::Error::other("Unexpected server response"));
107        }
108    }
109
110    run_client_relay(conn)
111}
112
113/// Run the relay loop with an already-handshaked connection
114///
115/// Use this when handshake has already been performed externally.
116/// Caller must have already enabled raw mode.
117pub fn run_client_relay(
118    #[allow(unused_mut)] mut conn: ClientConnection,
119) -> io::Result<ClientExitReason> {
120    // Set up for relay
121    // On Windows, don't set nonblocking here - the relay loop uses try_read() which handles this
122    // Setting nonblocking can fail with error 233 if the pipe state isn't fully established
123    #[cfg(not(windows))]
124    conn.set_data_nonblocking(true)?;
125
126    // Run the platform-specific relay loop
127    #[cfg(unix)]
128    {
129        let resize_flag = Arc::new(AtomicBool::new(false));
130        relay_unix::setup_resize_handler(resize_flag.clone())?;
131        relay_unix::relay_loop(&mut conn, resize_flag)
132    }
133
134    #[cfg(windows)]
135    {
136        let result = fresh_winterm::relay_loop(&mut conn)?;
137        return Ok(match result {
138            fresh_winterm::RelayExitReason::ServerQuit => ClientExitReason::ServerQuit,
139            fresh_winterm::RelayExitReason::Detached => ClientExitReason::Detached,
140        });
141    }
142}
143
144/// Set the system clipboard on the client side.
145///
146/// Delegates to the shared clipboard implementation which uses a persistent
147/// arboard handle (critical on X11/Wayland where the owner must stay alive).
148fn set_client_clipboard(text: &str, use_osc52: bool, use_system_clipboard: bool) {
149    crate::services::clipboard::copy_to_system_clipboard(text, use_osc52, use_system_clipboard);
150}
151
152/// Get current terminal size
153pub fn get_terminal_size() -> io::Result<TermSize> {
154    #[cfg(unix)]
155    {
156        let mut size: libc::winsize = unsafe { std::mem::zeroed() };
157        let result = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut size) };
158        if result == -1 {
159            return Err(io::Error::last_os_error());
160        }
161        Ok(TermSize::new(size.ws_col, size.ws_row))
162    }
163
164    #[cfg(windows)]
165    {
166        let size = fresh_winterm::get_terminal_size()?;
167        Ok(TermSize::new(size.cols, size.rows))
168    }
169}
170
171// --- RelayConnection trait impl for ClientConnection (Windows only) ---
172
173#[cfg(windows)]
174impl fresh_winterm::RelayConnection for ClientConnection {
175    fn try_read_data(&mut self, buf: &mut [u8]) -> io::Result<usize> {
176        self.read_data(buf)
177    }
178
179    fn try_read_control_byte(&mut self, buf: &mut [u8; 1]) -> io::Result<usize> {
180        self.control.try_read(buf)
181    }
182
183    fn write_data(&mut self, buf: &[u8]) -> io::Result<()> {
184        ClientConnection::write_data(self, buf)
185    }
186
187    fn send_resize(&mut self, cols: u16, rows: u16) -> io::Result<()> {
188        let msg = serde_json::to_string(&ClientControl::Resize { cols, rows }).unwrap();
189        ClientConnection::write_control(self, &msg)
190    }
191
192    fn handle_server_control(&mut self, msg: &str) -> Option<fresh_winterm::RelayExitReason> {
193        if let Ok(ctrl) = serde_json::from_str::<ServerControl>(msg) {
194            match ctrl {
195                ServerControl::Quit { .. } => Some(fresh_winterm::RelayExitReason::ServerQuit),
196                ServerControl::SetClipboard {
197                    text,
198                    use_osc52,
199                    use_system_clipboard,
200                } => {
201                    set_client_clipboard(&text, use_osc52, use_system_clipboard);
202                    None
203                }
204                _ => None,
205            }
206        } else {
207            None
208        }
209    }
210}