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