diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/server/client_commands.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/server/client_commands.rs')
| -rw-r--r-- | src/server/client_commands.rs | 199 |
1 files changed, 199 insertions, 0 deletions
diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs new file mode 100644 index 00000000..eeb49c7e --- /dev/null +++ b/src/server/client_commands.rs @@ -0,0 +1,199 @@ +use std::io; +use std::sync::mpsc; + +use tokio::sync::mpsc as tokio_mpsc; + +use crate::api::schema::{ErrorBody, ErrorResponse, Method}; + +use super::client_transport::ServerEvent; + +pub(crate) const MAX_ENDPOINT_COMMAND_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_ENDPOINT_BOOT_ID_BYTES: usize = 128; +pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128; +const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024; + +pub(crate) fn supports_client_shell_method(method: &Method) -> bool { + matches!( + method, + Method::CommandInvoke(_) + | Method::IntegrationInstall(_) + | Method::IntegrationList(_) + | Method::LayoutSetSplitRatio(_) + | Method::PaneClose(_) + | Method::PaneCopyMotion(_) + | Method::PaneCopySearch(_) + | Method::PaneEditScrollback(_) + | Method::PaneFocus(_) + | Method::PaneFocusDirection(_) + | Method::PaneInputSet(_) + | Method::PaneLinkActivate(_) + | Method::PaneRename(_) + | Method::PaneResize(_) + | Method::PaneScroll(_) + | Method::PaneSelectionRead(_) + | Method::PaneSplit(_) + | Method::PaneSwap(_) + | Method::PaneZoom(_) + | Method::ProductAnnouncementDismiss(_) + | Method::ReleaseNotesDismiss(_) + | Method::ServerReloadConfig(_) + | Method::TabClose(_) + | Method::TabCreate(_) + | Method::TabFocus(_) + | Method::TabMove(_) + | Method::TabRename(_) + | Method::WorkspaceClose(_) + | Method::WorkspaceCreate(_) + | Method::WorkspaceFocus(_) + | Method::WorkspaceMove(_) + | Method::WorkspaceMoveBlock(_) + | Method::WorkspaceRename(_) + | Method::WorktreeCreate(_) + | Method::WorktreeList(_) + | Method::WorktreeOpen(_) + | Method::WorktreeRemove(_) + ) +} + +pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>) -> String { + serde_json::to_string(&ErrorResponse { + id, + error: ErrorBody { + code: code.into(), + message: message.into(), + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"serialization_error","message":"failed to serialize endpoint response"}}"#.into() + }) +} + +pub(crate) fn error_message( + boot_id: String, + request_id: String, + code: &str, + message: impl Into<String>, +) -> crate::protocol::ServerMessage { + let response = error_response(request_id.clone(), code, message); + crate::protocol::ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk: true, + data: response.into_bytes(), + } +} + +pub(crate) fn spawn_response_waiter( + client_id: u64, + boot_id: String, + request_id: String, + response_rx: mpsc::Receiver<String>, + server_event_tx: tokio_mpsc::Sender<ServerEvent>, +) -> io::Result<()> { + std::thread::Builder::new() + .name("herdr-client-endpoint-response".into()) + .spawn(move || { + let response = response_rx.recv().unwrap_or_else(|_| { + error_response( + request_id.clone(), + "server_unavailable", + "endpoint command ended without a response", + ) + }); + let response = response.into_bytes(); + if response.is_empty() { + let _ = server_event_tx.blocking_send( + ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk: true, + data: Vec::new(), + }, + ); + return; + } + let chunk_count = response.len().div_ceil(ENDPOINT_RESPONSE_CHUNK_BYTES); + for (index, chunk) in response.chunks(ENDPOINT_RESPONSE_CHUNK_BYTES).enumerate() { + if server_event_tx + .blocking_send(ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id: boot_id.clone(), + request_id: request_id.clone(), + final_chunk: index + 1 == chunk_count, + data: chunk.to_vec(), + }) + .is_err() + { + break; + } + } + }) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() { + assert!(supports_client_shell_method(&Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ))); + assert!(supports_client_shell_method(&Method::PaneLinkActivate( + crate::api::schema::PaneLinkActivateParams { + pane_id: "w1:p1".into(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }, + ))); + assert!(!supports_client_shell_method(&Method::Ping( + crate::api::schema::PingParams::default(), + ))); + assert!(!supports_client_shell_method(&Method::ServerStop( + crate::api::schema::EmptyParams::default(), + ))); + } + + #[test] + fn endpoint_responses_are_chunked_without_truncation() { + let (response_tx, response_rx) = mpsc::channel(); + let (event_tx, mut event_rx) = tokio_mpsc::channel(8); + spawn_response_waiter( + 7, + "boot-a".into(), + "request-a".into(), + response_rx, + event_tx, + ) + .unwrap(); + let response = "x".repeat(ENDPOINT_RESPONSE_CHUNK_BYTES + 17); + response_tx.send(response.clone()).unwrap(); + + let mut received = Vec::new(); + loop { + let ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk, + data, + } = event_rx.blocking_recv().expect("response chunk") + else { + panic!("expected response chunk"); + }; + assert_eq!(client_id, 7); + assert_eq!(boot_id, "boot-a"); + assert_eq!(request_id, "request-a"); + received.extend(data); + if final_chunk { + break; + } + } + + assert_eq!(received, response.as_bytes()); + } +} |
