use serde::{Deserialize, Serialize};
use strop_core::frontend_input::Input;
use strop_core::id::{BufferRevision, DocumentId};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BaseStamp {
pub incarnation: u64,
pub generation: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientCapabilities {
pub clipboard_write: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendInfo {
pub name: String,
pub version: String,
pub build: Option<String>,
pub incarnation: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionLimits {
pub max_frame_bytes: usize,
pub max_pending_requests: usize,
pub max_viewport_cells: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerCapabilities {
pub terminals: bool,
pub workspace_search: bool,
pub filesystem: bool,
pub clipboard_write: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", content = "data", rename_all = "snake_case")]
pub enum AdmittedAction {
Input(Input),
EditorKey(strop_engine::editor::Key),
Paste(String),
Resize { columns: u16, rows: u16 },
QuitIntent,
Focus(bool),
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Geometry {
pub columns: u16,
pub rows: u16,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewBounds {
Complete,
Partial,
Loading,
Stale,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneSnapshot {
pub document: DocumentId,
pub revision: BufferRevision,
pub bounds: ViewBounds,
pub cursor: usize,
pub view_top: usize,
pub hscroll: usize,
pub terminal_input: bool,
pub overlays: bool,
pub rect: Rect,
pub budget: Rect,
pub window_top: usize,
pub lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewSnapshot {
pub generation: u64,
pub geometry: Geometry,
pub active_pane: usize,
pub panes: Vec<PaneSnapshot>,
pub state: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewDelta {
pub base: u64,
pub generation: u64,
pub geometry: Option<Geometry>,
pub active_pane: Option<usize>,
pub panes: Vec<PaneDelta>,
pub state: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneDelta {
Unchanged,
Changed(PaneSnapshot),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum AckOutcome {
Applied { applied: u64, generation: u64 },
Refused { refusal: Refusal },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "reason", rename_all = "snake_case")]
pub enum Refusal {
#[error("stale base generation; the current generation is {current}")]
StaleGeneration { current: u64 },
#[error("future base generation; the current generation is {current}")]
FutureGeneration { current: u64 },
#[error("wrong backend incarnation; the current incarnation is {current}")]
WrongIncarnation { current: u64 },
#[error("bound exceeded: {message}")]
Limit { message: String },
#[error("engine: {message}")]
Engine { message: String },
#[error("backend is closing")]
Closed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EffectRequest {
ClipboardWrite { text: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum EffectOutcome {
Applied,
Refused { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShutdownReason {
Requested,
Quit,
Disconnect,
ProtocolViolation,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProtocolError {
#[error("protocol version {offered} is not supported (supported: {supported})")]
Version { supported: u32, offered: u32 },
#[error("undecodable message: {message}")]
Decode { message: String },
#[error("frame violation: {message}")]
Frame { message: String },
#[error("unexpected message: {message}")]
Unexpected { message: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Hello {
protocol: u32,
client: ClientInfo,
capabilities: ClientCapabilities,
},
Act {
seq: u64,
base: BaseStamp,
actions: Vec<AdmittedAction>,
},
Viewport { seq: u64, columns: u16, rows: u16 },
Resync { seq: u64 },
EffectResult { id: u64, outcome: EffectOutcome },
Shutdown { seq: u64 },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
Welcome {
protocol: u32,
backend: BackendInfo,
limits: ActionLimits,
capabilities: ServerCapabilities,
},
Ack { seq: u64, outcome: AckOutcome },
Snapshot {
incarnation: u64,
view: ViewSnapshot,
},
Delta { incarnation: u64, delta: ViewDelta },
Effect { id: u64, effect: EffectRequest },
Error {
seq: Option<u64>,
error: ProtocolError,
},
Bye { reason: ShutdownReason },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelopes_round_trip() {
let messages = [
ClientMessage::Hello {
protocol: PROTOCOL_VERSION,
client: ClientInfo {
name: "driver".into(),
version: "0.1".into(),
},
capabilities: ClientCapabilities {
clipboard_write: true,
},
},
ClientMessage::Act {
seq: 7,
base: BaseStamp {
incarnation: 42,
generation: 3,
},
actions: vec![
AdmittedAction::Input(Input::Text("héllo".into())),
AdmittedAction::EditorKey(strop_engine::editor::Key::Enter),
AdmittedAction::Resize {
columns: 120,
rows: 40,
},
AdmittedAction::QuitIntent,
],
},
ClientMessage::Resync { seq: 8 },
ClientMessage::EffectResult {
id: 1,
outcome: EffectOutcome::Refused {
reason: "no clipboard".into(),
},
},
ClientMessage::Shutdown { seq: 9 },
];
for message in messages {
let bytes = serde_json::to_vec(&message).unwrap();
assert_eq!(
serde_json::from_slice::<ClientMessage>(&bytes).unwrap(),
message
);
}
}
#[test]
fn server_envelopes_round_trip() {
let messages = [
ServerMessage::Ack {
seq: 1,
outcome: AckOutcome::Applied {
applied: 5,
generation: 9,
},
},
ServerMessage::Ack {
seq: 2,
outcome: AckOutcome::Refused {
refusal: Refusal::StaleGeneration { current: 11 },
},
},
ServerMessage::Effect {
id: 0,
effect: EffectRequest::ClipboardWrite {
text: "payload".into(),
},
},
ServerMessage::Error {
seq: None,
error: ProtocolError::Version {
supported: 1,
offered: 99,
},
},
ServerMessage::Bye {
reason: ShutdownReason::ProtocolViolation,
},
];
for message in messages {
let bytes = serde_json::to_vec(&message).unwrap();
assert_eq!(
serde_json::from_slice::<ServerMessage>(&bytes).unwrap(),
message
);
}
}
#[test]
fn unknown_envelopes_fail_decoding() {
assert!(serde_json::from_slice::<ClientMessage>(br#"{"type":"teleport"}"#).is_err());
assert!(serde_json::from_slice::<ServerMessage>(br#"{"type":"teleport"}"#).is_err());
}
}