1use super::domain::*;
2use serde::{Deserialize, Serialize};
3#[cfg(unix)]
4use std::os::unix::fs::MetadataExt;
5use std::{
6 cmp::Ordering,
7 io,
8 path::{Path, PathBuf},
9 time::UNIX_EPOCH,
10};
11
12pub const PROTOCOL_VERSION: u32 = 11;
14pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
15pub const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
16pub const WSX_PANE_ID_ENV: &str = "WSX_PANE_ID";
17pub const WSX_RUNTIME_GENERATION_ENV: &str = "WSX_RUNTIME_GENERATION";
18pub const WSX_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20pub fn compare_wsx_versions(left: &str, right: &str) -> Option<Ordering> {
21 let left = parse_wsx_version(left)?;
22 let right = parse_wsx_version(right)?;
23 let core = left.core.cmp(&right.core);
24 if core != Ordering::Equal {
25 return Some(core);
26 }
27 match (left.prerelease, right.prerelease) {
28 (None, None) => Some(Ordering::Equal),
29 (None, Some(_)) => Some(Ordering::Greater),
30 (Some(_), None) => Some(Ordering::Less),
31 (Some(left), Some(right)) => compare_prerelease(left, right),
32 }
33}
34
35pub fn binary_identity_version(identity: &str) -> Option<&str> {
36 binary_identity_parts(identity).map(|(version, _)| version)
37}
38
39pub fn compare_binary_identities(left: &str, right: &str) -> Option<Ordering> {
40 let (left_version, left_modified) = binary_identity_parts(left)?;
41 let (right_version, right_modified) = binary_identity_parts(right)?;
42 let version = compare_wsx_versions(left_version, right_version)?;
43 if version != Ordering::Equal {
44 return Some(version);
45 }
46 Some(left_modified.cmp(&right_modified))
47}
48
49fn binary_identity_parts(identity: &str) -> Option<(&str, u128)> {
50 let fields = identity.split(':').collect::<Vec<_>>();
51 let expected_fields = if cfg!(unix) { 5 } else { 3 };
52 if fields.len() != expected_fields || parse_wsx_version(fields[0]).is_none() {
53 return None;
54 }
55 let mut values = fields[1..]
56 .iter()
57 .map(|value| u128::from_str_radix(value, 16));
58 let modified = values.next_back()?.ok()?;
59 values
60 .all(|value| value.is_ok())
61 .then_some((fields[0], modified))
62}
63
64struct ParsedVersion<'a> {
65 core: (u64, u64, u64),
66 prerelease: Option<&'a str>,
67}
68
69fn parse_wsx_version(version: &str) -> Option<ParsedVersion<'_>> {
70 let (precedence, build) = version
71 .split_once('+')
72 .map_or((version, None), |(precedence, build)| {
73 (precedence, Some(build))
74 });
75 if build.is_some_and(|build| !valid_identifiers(build, false)) {
76 return None;
77 }
78 let (core, prerelease) = precedence
79 .split_once('-')
80 .map_or((precedence, None), |(core, prerelease)| {
81 (core, Some(prerelease))
82 });
83 if prerelease.is_some_and(|value| !valid_identifiers(value, true)) {
84 return None;
85 }
86 let mut parts = core.split('.');
87 let core = (
88 parse_core_number(parts.next()?)?,
89 parse_core_number(parts.next()?)?,
90 parse_core_number(parts.next()?)?,
91 );
92 if parts.next().is_some() {
93 return None;
94 }
95 Some(ParsedVersion { core, prerelease })
96}
97
98fn parse_core_number(value: &str) -> Option<u64> {
99 if value.is_empty() || (value.len() > 1 && value.starts_with('0')) {
100 return None;
101 }
102 value.parse().ok()
103}
104
105fn valid_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool {
106 !value.is_empty()
107 && value.split('.').all(|identifier| {
108 !identifier.is_empty()
109 && identifier
110 .bytes()
111 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
112 && !(reject_numeric_leading_zero
113 && identifier.len() > 1
114 && identifier.starts_with('0')
115 && identifier.bytes().all(|byte| byte.is_ascii_digit()))
116 })
117}
118
119fn compare_prerelease(left: &str, right: &str) -> Option<Ordering> {
120 let mut left = left.split('.');
121 let mut right = right.split('.');
122 loop {
123 match (left.next(), right.next()) {
124 (Some(left), Some(right)) => {
125 let ordering = match (
126 left.bytes().all(|byte| byte.is_ascii_digit()),
127 right.bytes().all(|byte| byte.is_ascii_digit()),
128 ) {
129 (true, true) => parse_core_number(left)?.cmp(&parse_core_number(right)?),
130 (true, false) => Ordering::Less,
131 (false, true) => Ordering::Greater,
132 (false, false) => left.cmp(right),
133 };
134 if ordering != Ordering::Equal {
135 return Some(ordering);
136 }
137 }
138 (Some(_), None) => return Some(Ordering::Greater),
139 (None, Some(_)) => return Some(Ordering::Less),
140 (None, None) => return Some(Ordering::Equal),
141 }
142 }
143}
144
145pub fn binary_identity(path: &Path) -> io::Result<String> {
146 let path = path.canonicalize()?;
147 let metadata = path.metadata()?;
148 let modified = metadata
149 .modified()?
150 .duration_since(UNIX_EPOCH)
151 .unwrap_or_default()
152 .as_nanos();
153 #[cfg(unix)]
154 return Ok(format!(
155 "{}:{:x}:{:x}:{:x}:{modified:x}",
156 env!("CARGO_PKG_VERSION"),
157 metadata.dev(),
158 metadata.ino(),
159 metadata.len()
160 ));
161 #[cfg(not(unix))]
162 Ok(format!(
163 "{}:{:x}:{modified:x}",
164 env!("CARGO_PKG_VERSION"),
165 metadata.len()
166 ))
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(tag = "method", content = "params", rename_all = "snake_case")]
171pub enum Request {
172 Hello {
173 protocol: u32,
174 },
175 Snapshot,
176 Poll {
177 after_revision: u64,
178 timeout_ms: u64,
179 #[serde(default)]
180 tui: Option<TuiClientPresence>,
181 },
182 SynchronizeProjects {
183 projects: Vec<ProjectSpec>,
184 },
185 SessionCreate {
186 worktree_id: WorktreeId,
187 label: String,
188 command: Vec<String>,
189 #[serde(default)]
190 initial_input: Option<String>,
191 rows: u16,
192 cols: u16,
193 },
194 SessionRename {
195 session_id: SessionId,
196 label: String,
197 expected_revision: u64,
198 },
199 SessionReorder {
200 session_id: SessionId,
201 target_session_id: SessionId,
202 placement: SessionPlacement,
203 expected_revision: u64,
204 },
205 SessionClose {
206 session_id: SessionId,
207 expected_revision: u64,
208 },
209 PaneSplit {
210 session_id: SessionId,
211 target: PaneId,
212 axis: SplitAxis,
213 label: String,
214 command: Vec<String>,
215 #[serde(default)]
216 initial_input: Option<String>,
217 rows: u16,
218 cols: u16,
219 expected_revision: u64,
220 },
221 PaneFocus {
222 session_id: SessionId,
223 pane_id: PaneId,
224 },
225 PaneClose {
226 pane_id: PaneId,
227 expected_revision: u64,
228 },
229 TerminalAcquire {
230 pane_id: PaneId,
231 client_id: u64,
232 takeover: bool,
233 },
234 TerminalRelease {
235 pane_id: PaneId,
236 client_id: u64,
237 },
238 TerminalHeartbeat {
239 pane_id: PaneId,
240 client_id: u64,
241 },
242 TerminalSubscribe {
243 pane_id: PaneId,
244 client_id: u64,
245 takeover: bool,
246 rows: u16,
247 cols: u16,
248 },
249 TerminalInput {
250 pane_id: PaneId,
251 client_id: u64,
252 bytes: Vec<u8>,
253 },
254 TerminalKey {
255 pane_id: PaneId,
256 client_id: u64,
257 key: KeyEvent,
258 },
259 TerminalPaste {
260 pane_id: PaneId,
261 client_id: u64,
262 text: String,
263 },
264 TerminalMouse {
265 pane_id: PaneId,
266 client_id: u64,
267 mouse: MouseEvent,
268 },
269 TerminalResize {
270 pane_id: PaneId,
271 client_id: u64,
272 rows: u16,
273 cols: u16,
274 },
275 View {
276 pane_ids: Vec<PaneId>,
277 },
278 AgentReport {
279 pane_id: PaneId,
280 #[serde(default)]
281 runtime_generation: Option<String>,
282 provider: String,
283 state: AgentState,
284 #[serde(default)]
285 conversation_id: Option<String>,
286 #[serde(default)]
287 session_ref: Option<AgentSessionRef>,
288 capabilities: AgentCapabilities,
289 },
290 AgentClear {
291 pane_id: PaneId,
292 runtime_generation: String,
293 next_runtime_generation: String,
294 },
295 PluginList,
296 PluginReload,
297 LifecycleStatus,
298 PrepareReplacement {
299 target_binary_id: String,
300 },
301 Shutdown,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(tag = "type", content = "data", rename_all = "snake_case")]
306pub enum Event {
307 Changed {
308 revision: u64,
309 entity: String,
310 id: u64,
311 },
312 Exited {
313 revision: u64,
314 pane_id: PaneId,
315 },
316 ResyncRequired {
317 revision: u64,
318 },
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct ApiError {
323 pub code: String,
324 pub message: String,
325}
326
327impl ApiError {
328 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
329 Self {
330 code: code.into(),
331 message: message.into(),
332 }
333 }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(tag = "type", content = "data", rename_all = "snake_case")]
338pub enum Response {
339 Hello {
340 protocol: u32,
341 epoch: u64,
342 #[serde(default)]
343 capabilities: Capabilities,
344 },
345 Snapshot(Snapshot),
346 View {
347 snapshot: Snapshot,
348 frames: Vec<TerminalFrame>,
349 },
350 Events {
351 revision: u64,
352 events: Vec<Event>,
353 },
354 Plugins(Vec<PluginManifest>),
355 Lifecycle(DaemonLifecycle),
356 Replacement {
357 disposition: ReplacementDisposition,
358 live_runtimes: usize,
359 #[serde(default)]
360 daemon_version: String,
361 #[serde(default)]
362 target_version: String,
363 #[serde(default)]
364 blockers: Vec<ReplacementBlocker>,
365 #[serde(default)]
366 use_current_daemon: bool,
367 },
368 Created {
369 revision: u64,
370 id: u64,
371 },
372 Ack {
373 revision: u64,
374 },
375 Error(ApiError),
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(tag = "type", content = "data", rename_all = "snake_case")]
380pub enum TerminalClientMessage {
381 Key(KeyEvent),
382 Paste(String),
383 Mouse(MouseEvent),
384 Input(Vec<u8>),
385 Resize { rows: u16, cols: u16 },
386 Heartbeat,
387 Resync,
388 Detach,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(tag = "type", content = "data", rename_all = "snake_case")]
393pub enum TerminalServerMessage {
394 Update(TerminalUpdate),
395 ClipboardWrite(Vec<u8>),
396 Error(ApiError),
397 Exited,
398}
399
400pub fn encode_line<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
401 let mut bytes = serde_json::to_vec(value)?;
402 bytes.push(b'\n');
403 Ok(bytes)
404}
405
406pub fn default_socket_path() -> PathBuf {
407 if let Some(path) = std::env::var_os("WSX_SOCKET") {
408 return PathBuf::from(path);
409 }
410 let root = std::env::var_os("XDG_STATE_HOME")
411 .map(PathBuf::from)
412 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
413 .unwrap_or_else(std::env::temp_dir);
414 root.join("wsx/wsx.sock")
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn protocol_is_tagged_and_line_delimited() {
423 let bytes = encode_line(&Request::Snapshot).unwrap();
424 assert_eq!(bytes.last(), Some(&b'\n'));
425 assert!(String::from_utf8(bytes).unwrap().contains("snapshot"));
426 }
427
428 #[test]
429 fn unknown_methods_including_recent_clear_are_rejected() {
430 for method in ["unknown_method", "project_recent_clear"] {
431 let json = format!(r#"{{"method":"{method}","params":{{}}}}"#);
432 assert!(serde_json::from_str::<Request>(&json).is_err(), "{method}");
433 }
434 }
435
436 #[test]
437 fn legacy_hello_defaults_capabilities_added_by_newer_protocols() {
438 let response = serde_json::from_str::<Response>(
439 r#"{"type":"hello","data":{"protocol":3,"epoch":1,"capabilities":{"pane_splits":true,"plugins":true,"agent_reports":true,"process_restore":false}}}"#,
440 )
441 .unwrap();
442
443 let Response::Hello { capabilities, .. } = response else {
444 panic!("expected hello response");
445 };
446 assert!(capabilities.pane_splits);
447 assert!(!capabilities.agent_session_restore);
448 assert!(!capabilities.resume_shell_fallback);
449 assert!(!capabilities.listening_ports);
450 assert!(!capabilities.foreground_jobs);
451 assert!(!capabilities.lifecycle_coordination);
452 }
453
454 #[test]
455 fn wsx_versions_and_builds_have_numeric_precedence() {
456 assert_eq!(
457 compare_wsx_versions("0.21.0", "0.20.9"),
458 Some(std::cmp::Ordering::Greater)
459 );
460 assert_eq!(
461 compare_binary_identities("0.21.0:1:2:3:20", "0.21.0:4:5:6:10"),
462 Some(std::cmp::Ordering::Greater)
463 );
464 assert_eq!(
465 compare_wsx_versions("0.21.0-beta.2", "0.21.0-beta.11"),
466 Some(std::cmp::Ordering::Less)
467 );
468 assert_eq!(
469 compare_wsx_versions("0.21.0-rc.1", "0.21.0"),
470 Some(std::cmp::Ordering::Less)
471 );
472 assert_eq!(
473 compare_wsx_versions("0.21.0+build.2", "0.21.0+build.1"),
474 Some(std::cmp::Ordering::Equal)
475 );
476 assert_eq!(
477 compare_wsx_versions("0.21.0-alpha.beta", "0.21.0-alpha.rc"),
478 Some(std::cmp::Ordering::Less)
479 );
480 assert_eq!(
481 compare_wsx_versions("0.21.0-alpha10", "0.21.0-alpha2"),
482 Some(std::cmp::Ordering::Less)
483 );
484 assert_eq!(compare_wsx_versions("0.21.0-01", "0.21.0"), None);
485 assert_eq!(compare_wsx_versions("", "0.21.0"), None);
486 assert_eq!(compare_wsx_versions("0.21.0", ""), None);
487 for malformed in [
488 "",
489 "malformed",
490 "0.21.0:1:2:3",
491 "0.21.0:1:2:3:10:5",
492 "0.21.0:1:2:3:not-hex",
493 ] {
494 assert_eq!(
495 compare_binary_identities(malformed, "0.21.0:1:2:3:10"),
496 None,
497 "{malformed:?} must not be a binary identity"
498 );
499 }
500 }
501
502 #[test]
503 fn legacy_lifecycle_requests_and_responses_default_version_fields() {
504 let poll = serde_json::from_str::<Request>(
505 r#"{"method":"poll","params":{"after_revision":7,"timeout_ms":1000}}"#,
506 )
507 .unwrap();
508 assert!(matches!(poll, Request::Poll { tui: None, .. }));
509
510 let replacement = serde_json::from_str::<Request>(
511 r#"{"method":"prepare_replacement","params":{"target_binary_id":"0.20.0:1:2:3:4"}}"#,
512 )
513 .unwrap();
514 assert!(matches!(replacement, Request::PrepareReplacement { .. }));
515
516 let lifecycle = serde_json::from_str::<Response>(
517 r#"{"type":"lifecycle","data":{"protocol":8,"epoch":7,"binary_id":"0.20.0:1:2:3:4","started_unix_ms":11,"phase":"replacement_pending","live_runtimes":2,"active_clients":1,"recovered_from_backup":false,"replacement_target":"0.21.0:1:2:3:4"}}"#,
518 )
519 .unwrap();
520 assert!(matches!(
521 lifecycle,
522 Response::Lifecycle(DaemonLifecycle {
523 binary_id,
524 version,
525 started_unix_ms: 11,
526 active_tuis: 0,
527 replacement_target,
528 replacement_target_version,
529 replacement_blockers,
530 ..
531 }) if binary_id == "0.20.0:1:2:3:4"
532 && version.is_empty()
533 && replacement_target.as_deref() == Some("0.21.0:1:2:3:4")
534 && replacement_target_version.is_empty()
535 && replacement_blockers.is_empty()
536 ));
537
538 let response = serde_json::from_str::<Response>(
539 r#"{"type":"replacement","data":{"disposition":"deferred","live_runtimes":2}}"#,
540 )
541 .unwrap();
542 assert!(matches!(
543 response,
544 Response::Replacement {
545 daemon_version,
546 target_version,
547 blockers,
548 use_current_daemon: false,
549 ..
550 } if daemon_version.is_empty() && target_version.is_empty() && blockers.is_empty()
551 ));
552 }
553
554 #[test]
555 fn lifecycle_control_is_additive_and_tagged() {
556 let request = Request::PrepareReplacement {
557 target_binary_id: "0.22.0:1:2:3:4".into(),
558 };
559 let encoded = serde_json::to_string(&request).unwrap();
560 assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
561
562 let response = Response::Lifecycle(DaemonLifecycle {
563 protocol: PROTOCOL_VERSION,
564 epoch: 7,
565 binary_id: "0.21.0:1:2:3:4".into(),
566 version: "0.21.0".into(),
567 started_unix_ms: 11,
568 phase: DaemonPhase::ReplacementPending,
569 live_runtimes: 2,
570 active_clients: 1,
571 active_tuis: 1,
572 recovered_from_backup: false,
573 replacement_target: Some("0.22.0:1:2:3:4".into()),
574 replacement_target_version: "0.22.0".into(),
575 replacement_blockers: vec![ReplacementBlocker::WorkingAgent],
576 });
577 let encoded = serde_json::to_string(&response).unwrap();
578 assert_eq!(
579 serde_json::from_str::<Response>(&encoded).unwrap(),
580 response
581 );
582 }
583
584 #[test]
585 fn legacy_snapshot_defaults_missing_foreground_job_metadata() {
586 let response = serde_json::from_str::<Response>(
587 r#"{"type":"snapshot","data":{"protocol":8,"epoch":1,"revision":1,"projects":[],"worktrees":[],"sessions":[],"panes":[],"capabilities":{}}}"#,
588 )
589 .unwrap();
590
591 let Response::Snapshot(snapshot) = response else {
592 panic!("expected snapshot response");
593 };
594 assert!(snapshot.pane_activity.is_empty());
595 assert!(!snapshot.capabilities.foreground_jobs);
596 }
597
598 #[test]
599 fn legacy_agent_report_defaults_missing_session_reference() {
600 let request = serde_json::from_str::<Request>(
601 r#"{"method":"agent_report","params":{"pane_id":1,"provider":"pi","state":"idle","conversation_id":"legacy","capabilities":{}}}"#,
602 )
603 .unwrap();
604
605 let Request::AgentReport {
606 session_ref,
607 runtime_generation,
608 capabilities,
609 ..
610 } = request
611 else {
612 panic!("expected agent report request");
613 };
614 assert_eq!(session_ref, None);
615 assert_eq!(runtime_generation, None);
616 assert!(!capabilities.escape_interrupts);
617 }
618
619 #[test]
620 fn legacy_session_create_defaults_missing_initial_input() {
621 let request = serde_json::from_str::<Request>(
622 r#"{"method":"session_create","params":{"worktree_id":1,"label":"legacy","command":[],"rows":24,"cols":80}}"#,
623 )
624 .unwrap();
625
626 let Request::SessionCreate { initial_input, .. } = request else {
627 panic!("expected session create request");
628 };
629 assert_eq!(initial_input, None);
630 }
631
632 #[test]
633 fn clipboard_write_is_a_distinct_ephemeral_stream_message() {
634 let bytes =
635 encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap();
636 assert_eq!(
637 String::from_utf8(bytes).unwrap(),
638 "{\"type\":\"clipboard_write\",\"data\":[99,111,112,105,101,100]}\n"
639 );
640 }
641
642 #[test]
643 fn legacy_terminal_wire_defaults_selection_and_pointer_bounds() {
644 let full = serde_json::from_str::<TerminalUpdate>(
645 r#"{"kind":"full","data":{"pane_id":1,"terminal_id":2,"revision":3,"cols":1,"rows":1,"cells":[["x",null,null,0,0]],"cursor":{"x":0,"y":0,"visible":false,"blinking":false,"shape":0}}}"#,
646 )
647 .unwrap();
648 let TerminalUpdate::Full(full) = full else {
649 panic!("expected full terminal update");
650 };
651 assert!(full.selection.is_empty());
652
653 let patch = serde_json::from_str::<TerminalUpdate>(
654 r#"{"kind":"patch","data":{"pane_id":1,"terminal_id":2,"base_revision":3,"revision":4,"cols":1,"rows":1,"changed_rows":[],"cursor":{"x":0,"y":0,"visible":false,"blinking":false,"shape":0}}}"#,
655 )
656 .unwrap();
657 let TerminalUpdate::Patch { selection, .. } = patch else {
658 panic!("expected terminal patch");
659 };
660 assert!(selection.is_empty());
661
662 let mouse = serde_json::from_str::<MouseEvent>(
663 r#"{"action":"release","button":"left","x":0,"y":0,"shift":false,"control":false,"alt":false,"super_key":false}"#,
664 )
665 .unwrap();
666 assert!(mouse.in_bounds);
667 }
668
669 #[test]
670 fn full_terminal_baseline_stays_within_compact_size_budget() {
671 let cell = Cell {
672 symbol: " ".into(),
673 fg: Some([220, 220, 220]),
674 bg: Some([8, 9, 11]),
675 ..Cell::default()
676 };
677 let message = TerminalServerMessage::Update(TerminalUpdate::Full(TerminalFrame {
678 pane_id: PaneId(1),
679 terminal_id: TerminalId(2),
680 revision: 1,
681 cols: 120,
682 rows: 40,
683 cells: vec![cell; 120 * 40],
684 cursor: Cursor {
685 x: 0,
686 y: 0,
687 visible: true,
688 blinking: false,
689 shape: 0,
690 },
691 selection: Vec::new(),
692 }));
693 let bytes = encode_line(&message).unwrap();
694 assert!(
695 bytes.len() < 256 * 1024,
696 "baseline was {} bytes",
697 bytes.len()
698 );
699 }
700}