Files
spaceshell/crates/spacesh-proto/src/message.rs
T
vasyansk ee845e15b3 Add full disk access checks and settings
Add background themes and custom images

Add shell command logging toggle

Add UTF-8 locale guarantee for PTY

Add Claude hook settings injection

Add hotkey system for GUI

Add glass panel styling

Add search disabled state for agent panels

Add zoom toggle command

Add device report filtering

Add entitlements for notarization

Update version to 0.1.27
2026-06-15 22:26:06 +07:00

419 lines
15 KiB
Rust

use serde::{Deserialize, Serialize};
use crate::event::{EventRecord, MarkReadTarget};
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
use crate::layout::LayoutNode;
use crate::status::SurfaceState;
use crate::workspace::{Group, WorkspaceView};
/// Wire envelope. `kind` is the serde tag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Envelope {
Req {
id: u64,
cmd: Cmd,
},
Res {
id: u64,
ok: bool,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
data: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<ErrorBody>,
},
Evt(Evt),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorBody {
pub code: String,
pub msg: String,
}
/// Direction a split grows the new neighbor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SplitDir {
Right,
Down,
}
/// Edge of a target leaf to drop a moved panel against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Edge {
Left,
Right,
Top,
Bottom,
}
/// One panel slot when applying a preset.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PresetSlot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
}
/// Client → daemon commands. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "cmd", content = "args", rename_all = "snake_case")]
pub enum Cmd {
Open { path: String },
NewSurface {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
cols: u16,
rows: u16,
},
Input {
surface_id: SurfaceId,
/// base64-encoded keyboard bytes.
bytes: String,
},
Resize { surface_id: SurfaceId, cols: u16, rows: u16 },
Attach { surface_id: SurfaceId },
Detach { surface_id: SurfaceId },
Focus { surface_id: SurfaceId },
Close { surface_id: SurfaceId },
SplitSurface {
surface_id: SurfaceId,
dir: SplitDir,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
},
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
RestartSurface {
surface_id: SurfaceId,
#[serde(default)]
resume: bool,
},
CloseWorkspace { workspace_id: WorkspaceId },
SetWorkspaceMeta {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
group_id: Option<Option<GroupId>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
unread: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
order: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pinned: Option<bool>,
},
CreateGroup { name: String, color: String },
SetGroup {
group_id: GroupId,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
order: Option<u32>,
},
DeleteGroup { group_id: GroupId },
SetState { surface_id: SurfaceId, state: SurfaceState },
EventLog {
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
},
MarkRead { target: MarkReadTarget },
ClearEvents,
SetZoom {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
surface_id: Option<SurfaceId>,
},
Health,
/// Which of the given CLI candidates are actually installed on the spawn PATH.
WhichAgents { candidates: Vec<String> },
Status,
Shutdown,
GetConfig,
SetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
default_shell: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_size: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
background: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
background_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
log_shell_commands: Option<bool>,
},
}
/// Daemon → subscribers push events. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "evt", content = "data", rename_all = "snake_case")]
pub enum Evt {
Output { surface_id: SurfaceId, bytes: Vec<u8> },
Exit { surface_id: SurfaceId, code: i32 },
SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
SurfaceClosed { surface_id: SurfaceId },
LayoutChanged { workspace_id: WorkspaceId, layout: Option<LayoutNode> },
WorkspaceChanged { workspace: WorkspaceView },
WorkspaceClosed { workspace_id: WorkspaceId },
GroupsChanged { groups: Vec<Group> },
SurfaceRestarted { surface_id: SurfaceId },
State { surface_id: SurfaceId, state: SurfaceState },
Event { record: EventRecord },
EventsRead { ids: Vec<u64> },
EventsCleared,
ConfigChanged { config: crate::config_view::ConfigView },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{SurfaceId, WorkspaceId};
#[test]
fn req_round_trips_through_json() {
let env = Envelope::Req {
id: 42,
cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) },
};
let json = serde_json::to_string(&env).unwrap();
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(env, back);
}
#[test]
fn res_ok_and_err_serialize_distinctly() {
let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None };
let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null,
error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) };
assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true"));
assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND"));
}
#[test]
fn evt_output_carries_workspace_scoped_surface() {
let evt = Envelope::Evt(Evt::Output {
surface_id: SurfaceId("s_1".into()),
bytes: vec![104, 105],
});
let json = serde_json::to_string(&evt).unwrap();
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(evt, back);
}
#[test]
fn new_surface_defaults_cmd_to_none() {
let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#;
let env: Envelope = serde_json::from_str(json).unwrap();
match env {
Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => {
assert!(command.is_none());
assert!(args.is_empty());
}
_ => panic!("wrong variant"),
}
}
#[test]
fn split_surface_serializes() {
let env = Envelope::Req {
id: 1,
cmd: Cmd::SplitSurface {
surface_id: SurfaceId("s_1".into()),
dir: SplitDir::Right,
command: None,
args: vec![],
},
};
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains("split_surface"));
assert!(j.contains(r#""dir":"right""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn apply_preset_round_trips() {
let env = Envelope::Req {
id: 2,
cmd: Cmd::ApplyPreset {
workspace_id: WorkspaceId("w_1".into()),
preset_id: "2x2".into(),
slots: vec![
PresetSlot { command: Some("claude".into()), args: vec![] },
PresetSlot { command: None, args: vec![] },
],
},
};
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
assert_eq!(back, env);
}
#[test]
fn set_ratios_round_trips() {
let env = Envelope::Req {
id: 3,
cmd: Cmd::SetRatios {
workspace_id: WorkspaceId("w_1".into()),
node_path: vec![0, 1],
ratios: vec![0.3, 0.7],
},
};
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
assert_eq!(back, env);
}
#[test]
fn layout_changed_event_round_trips() {
let evt = Envelope::Evt(Evt::LayoutChanged {
workspace_id: WorkspaceId("w_1".into()),
layout: Some(crate::layout::LayoutNode::leaf(SurfaceId("s_1".into()))),
});
let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap();
assert_eq!(back, evt);
}
#[test]
fn set_state_round_trips() {
let env = Envelope::Req {
id: 1,
cmd: Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Done },
};
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
assert_eq!(back, env);
}
#[test]
fn state_event_round_trips() {
let evt = Envelope::Evt(Evt::State { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Wait });
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"state""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn event_log_cmd_round_trips() {
let env = Envelope::Req { id: 1, cmd: Cmd::EventLog { limit: Some(50) } };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"event_log""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn mark_read_cmd_round_trips() {
let env = Envelope::Req {
id: 2,
cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::All },
};
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"mark_read""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn event_evt_round_trips() {
let evt = Envelope::Evt(Evt::Event {
record: crate::event::EventRecord {
id: 3,
surface_id: SurfaceId("s_1".into()),
workspace_id: WorkspaceId("w_1".into()),
workspace_name: "p".into(),
agent_label: None,
kind: crate::event::EventKind::Done,
ts: 1,
read: false,
},
});
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"event""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn events_read_evt_round_trips() {
let evt = Envelope::Evt(Evt::EventsRead { ids: vec![1, 2, 3] });
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"events_read""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn set_zoom_cmd_round_trips() {
let z = Envelope::Req { id: 1, cmd: Cmd::SetZoom {
workspace_id: WorkspaceId("w_1".into()), surface_id: Some(SurfaceId("s_1".into())) } };
let j = serde_json::to_string(&z).unwrap();
assert!(j.contains(r#""cmd":"set_zoom""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), z);
let unz = Envelope::Req { id: 2, cmd: Cmd::SetZoom {
workspace_id: WorkspaceId("w_1".into()), surface_id: None } };
assert_eq!(serde_json::from_str::<Envelope>(&serde_json::to_string(&unz).unwrap()).unwrap(), unz);
}
#[test]
fn health_cmd_round_trips() {
let env = Envelope::Req { id: 1, cmd: Cmd::Health };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"health""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn restart_surface_resume_defaults_false_and_round_trips() {
// Legacy frame without `resume` decodes to false.
let legacy = r#"{"kind":"req","id":5,"cmd":{"cmd":"restart_surface","args":{"surface_id":"s_1"}}}"#;
let env: Envelope = serde_json::from_str(legacy).unwrap();
match env {
Envelope::Req { cmd: Cmd::RestartSurface { resume, .. }, .. } => assert!(!resume),
_ => panic!("wrong variant"),
}
// resume=true round-trips.
let e = Envelope::Req { id: 6, cmd: Cmd::RestartSurface { surface_id: SurfaceId("s_1".into()), resume: true } };
let back: Envelope = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
assert_eq!(back, e);
}
#[test]
fn event_log_cmd_no_limit_round_trips() {
let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"event_log""#));
assert!(j.contains(r#""args":{}"#), "no-limit serializes to empty args, got: {j}");
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn mark_read_cmd_ids_and_surface_round_trip() {
let ids = Envelope::Req { id: 10, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Ids(vec![1, 2]) } };
let j = serde_json::to_string(&ids).unwrap();
assert!(j.contains(r#""target":"ids""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), ids);
let surf = Envelope::Req { id: 11, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Surface(SurfaceId("s_3".into())) } };
let j = serde_json::to_string(&surf).unwrap();
assert!(j.contains(r#""target":"surface""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), surf);
}
}