diff --git a/Cargo.lock b/Cargo.lock index f60f65b..cf394ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1440,11 +1440,21 @@ name = "unshell" version = "0.1.0" dependencies = [ "chrono", - "portable-pty", "rkyv", "static_init", "thiserror 2.0.18", + "unshell-leaves", "unshell-macros", + "unshell-protocol", +] + +[[package]] +name = "unshell-leaves" +version = "0.1.0" +dependencies = [ + "portable-pty", + "rkyv", + "unshell", ] [[package]] @@ -1456,6 +1466,14 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "unshell-protocol" +version = "0.1.0" +dependencies = [ + "rkyv", + "unshell-macros", +] + [[package]] name = "ush-obfuscate" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5b40365..1be59ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ "ush-obfuscate", "base62", "unshell-macros", + "unshell-protocol", + "unshell-leaves", "treetest", ] resolver = "2" @@ -27,6 +29,8 @@ quote = "1.0.45" proc-macro2 = "1.0.106" portable-pty = "0.9.0" unshell = { path = "." } +unshell-protocol = { path = "./unshell-protocol" } +unshell-leaves = { path = "./unshell-leaves" } # ush-obfuscate = { path = "./ush-obfuscate" } # base62 = { path = "./base62" } @@ -50,7 +54,10 @@ chrono = { workspace = true, optional = true } # ush-obfuscate = { workspace = true } static_init = { workspace = true } unshell-macros = { path = "./unshell-macros" } -portable-pty = { workspace = true } +unshell-protocol = { workspace = true } + +[dev-dependencies] +unshell-leaves = { workspace = true, features = ["endpoint"] } [[example]] name = "leaf_derive" diff --git a/examples/protocol/remote_shell_endpoint.rs b/examples/protocol/remote_shell_endpoint.rs index 9102401..8548acb 100644 --- a/examples/protocol/remote_shell_endpoint.rs +++ b/examples/protocol/remote_shell_endpoint.rs @@ -4,15 +4,13 @@ //! example over TCP, feeds inbound frames into the `ProcedureRuntime`, and flushes any resulting //! protocol frames back to the controller. -#[path = "../../src/leaf/remote_shell/mod.rs"] -mod remote_shell; - use std::error::Error; use std::net::TcpStream; use std::sync::mpsc::RecvTimeoutError; use std::time::Duration; use unshell::protocol::tree::Ingress; +use unshell_leaves::remote_shell; fn main() -> Result<(), Box> { let mut stream = TcpStream::connect(remote_shell::LISTEN_ADDR)?; diff --git a/examples/protocol/remote_shell_receive.rs b/examples/protocol/remote_shell_receive.rs index b1dcc31..8213ed9 100644 --- a/examples/protocol/remote_shell_receive.rs +++ b/examples/protocol/remote_shell_receive.rs @@ -3,13 +3,11 @@ //! This binary listens for the endpoint example, opens one remote shell session, sends a few //! commands, and prints returned hook data until the shell closes. -#[path = "../../src/leaf/remote_shell/mod.rs"] -mod remote_shell; - use std::error::Error; use std::net::TcpListener; use unshell::protocol::tree::{Endpoint, EndpointOutcome, Ingress, LocalEvent}; +use unshell_leaves::remote_shell; fn main() -> Result<(), Box> { let listener = TcpListener::bind(remote_shell::LISTEN_ADDR)?; diff --git a/examples/protocol/remote_shell_single_endpoint.rs b/examples/protocol/remote_shell_single_endpoint.rs index 208d9d6..78b8b8a 100644 --- a/examples/protocol/remote_shell_single_endpoint.rs +++ b/examples/protocol/remote_shell_single_endpoint.rs @@ -5,13 +5,11 @@ //! a shell process, so it is the easiest place to see how the endpoint and leaf metadata fit //! together. -#[path = "../../src/leaf/remote_shell/mod.rs"] -mod remote_shell; - use std::error::Error; use unshell::protocol::tree::{EndpointOutcome, LocalEvent, ProtocolEndpoint}; use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection}; +use unshell_leaves::remote_shell; fn main() -> Result<(), Box> { let mut endpoint = ProtocolEndpoint::new( diff --git a/src/leaf/mod.rs b/src/leaf/mod.rs deleted file mode 100644 index 4d94586..0000000 --- a/src/leaf/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod remote_shell; diff --git a/src/leaf/remote_shell/mod.rs b/src/leaf/remote_shell/mod.rs deleted file mode 100644 index 3da53e8..0000000 --- a/src/leaf/remote_shell/mod.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Stateful remote shell leaf used by the protocol examples. -//! -//! # Design -//! -//! The leaf owns all live hook sessions explicitly in `sessions`. Each entry in -//! that map is one `ProcedureOpen`, keyed by the caller-owned hook identity. -//! The protocol runtime still owns packet validation and transport close state, -//! while the procedure session owns application resources such as the spawned -//! shell process. -//! -//! This keeps the storage obvious: -//! - the leaf owns session maps -//! - the procedure type owns one hook conversation -//! - the runtime routes later `Data` and `Fault` packets automatically - -mod errors; -mod session; -mod transport; - -use std::collections::BTreeMap; - -use unshell::Leaf; -use unshell::protocol::tree::{ - Call, HookKey, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint, -}; - -pub use errors::ShellLeafError; -pub use session::ProcedureOpen; -pub use transport::LISTEN_ADDR; - -/// Leaf state for the remote shell example. -/// -/// The map is explicit on purpose. Stateful procedures are easier to debug when -/// the leaf clearly owns its live sessions instead of relying on generated hidden -/// enums or side tables. -#[derive(Default, Leaf)] -#[leaf(leaf_name = "remote_shell")] -pub struct RemoteShellLeaf { - sessions: BTreeMap, -} - -impl ProcedureStore for RemoteShellLeaf { - fn procedure_sessions(&mut self) -> &mut BTreeMap { - &mut self.sessions - } -} - -impl Procedure for ProcedureOpen { - type Error = ShellLeafError; - type Input = (); - - fn open(_leaf: &mut RemoteShellLeaf, call: Call) -> Result { - let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?; - ProcedureOpen::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id) - } - - fn on_data( - _leaf: &mut RemoteShellLeaf, - session: &mut Self, - data: unshell::protocol::tree::IncomingData, - ) -> Result { - session.on_data(data) - } - - fn on_fault( - _leaf: &mut RemoteShellLeaf, - _session: &mut Self, - _fault: unshell::protocol::tree::IncomingFault, - ) -> Result<(), Self::Error> { - Ok(()) - } - - fn poll( - _leaf: &mut RemoteShellLeaf, - session: &mut Self, - ) -> Result { - session.poll() - } - - fn close(_leaf: &mut RemoteShellLeaf, mut session: Self) -> Result<(), Self::Error> { - session.terminate() - } -} - -/// Returns the example endpoint path used by both shell binaries. -pub fn agent_path() -> Vec { - path(&["agent"]) -} - -/// Builds the controller endpoint used by the receiver example. -#[allow(dead_code)] -pub fn build_controller_endpoint() -> ProtocolEndpoint { - ProtocolEndpoint::new( - Vec::new(), - None, - vec![unshell::protocol::tree::ChildRoute::registered(agent_path())], - Vec::new(), - ) -} - -/// Builds the stateful shell runtime used by the endpoint example. -#[allow(dead_code)] -pub fn build_agent_runtime() -> ProcedureRuntime { - let endpoint = ProtocolEndpoint::new( - agent_path(), - Some(Vec::new()), - Vec::new(), - vec![unshell::protocol::tree::LeafSpec { - name: RemoteShellLeaf::protocol_leaf_name(), - procedures: vec![ProcedureOpen::protocol_procedure_id()], - }], - ); - ProcedureRuntime::new(endpoint, RemoteShellLeaf::default()) -} - -/// Returns the canonical leaf id used by the receiver example. -#[allow(dead_code)] -pub fn shell_leaf_name() -> String { - RemoteShellLeaf::protocol_leaf_name() -} - -/// Returns the opening `procedure_id` used to create one shell session. -#[allow(dead_code)] -pub fn shell_open_procedure() -> String { - ProcedureOpen::protocol_procedure_id() -} - -/// Encodes the empty opening payload used by the shell example. -#[allow(dead_code)] -pub fn shell_open_payload() -> Vec { - unshell::protocol::tree::encode_call_reply(&()).expect("unit shell open payload should encode") -} - -#[allow(dead_code)] -pub fn send_forward( - stream: &mut std::net::TcpStream, - outcome: unshell::protocol::tree::EndpointOutcome, -) -> std::io::Result<()> { - transport::send_forward(stream, outcome) -} - -#[allow(dead_code)] -pub fn write_frames( - stream: &mut std::net::TcpStream, - frames: &[unshell::protocol::FrameBytes], -) -> std::io::Result<()> { - transport::write_frames(stream, frames) -} - -#[allow(dead_code)] -pub fn spawn_frame_reader( - stream: std::net::TcpStream, -) -> std::sync::mpsc::Receiver> { - transport::spawn_frame_reader(stream) -} - -fn path(parts: &[&str]) -> Vec { - parts.iter().map(|part| (*part).to_owned()).collect() -} diff --git a/src/lib.rs b/src/lib.rs index f9e43f2..d327043 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,10 @@ pub extern crate alloc; extern crate self as unshell; pub mod logger; -pub mod protocol; + +/// Re-export the protocol crate behind the historical `unshell::protocol` path so +/// proc-macro output and downstream code do not need a second migration. +pub use unshell_protocol as protocol; pub use unshell_macros::{Leaf, Procedure, procedures}; diff --git a/unshell-leaves/Cargo.toml b/unshell-leaves/Cargo.toml new file mode 100644 index 0000000..0d49e16 --- /dev/null +++ b/unshell-leaves/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "unshell-leaves" +version.workspace = true +edition.workspace = true +description = "Application-layer UnShell leaves and client surfaces" + +[features] +default = [] +endpoint = ["dep:portable-pty"] +tui = [] + +[dependencies] +rkyv = { workspace = true } +portable-pty = { workspace = true, optional = true } +unshell = { workspace = true } + +[lints.rust] +elided_lifetimes_in_paths = "warn" +future_incompatible = { level = "warn", priority = -1 } +nonstandard_style = { level = "warn", priority = -1 } +rust_2018_idioms = { level = "warn", priority = -1 } +rust_2021_prelude_collisions = "warn" +semicolon_in_expressions_from_macros = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_import_braces = "warn" +unused_lifetimes = "warn" +trivial_casts = "allow" +missing_docs = "warn" diff --git a/unshell-leaves/src/lib.rs b/unshell-leaves/src/lib.rs new file mode 100644 index 0000000..bbfb091 --- /dev/null +++ b/unshell-leaves/src/lib.rs @@ -0,0 +1,79 @@ +//! Application-layer leaves and user-facing surfaces built on top of the UnShell +//! protocol runtime. +//! +//! Each leaf module always exports its shared protocol-facing types. Role-specific +//! implementations are selected with the crate-wide `endpoint` and `tui` +//! features, and can optionally be re-exported behind one stable alias. + +use unshell::protocol::DataMessage; + +/// Re-exports one role-specific type behind a stable public alias. +/// +/// This keeps consumers on a single name such as `RemoteShell` while still +/// compiling only the role implementation needed by the current binary. +#[macro_export] +macro_rules! role_leaf { + ( + $(#[$meta:meta])* + $vis:vis type $alias:ident { + endpoint => $endpoint:path, + tui => $tui:path $(,)? + } + ) => { + #[cfg(all(feature = "endpoint", feature = "tui"))] + compile_error!(concat!( + "`", + stringify!($alias), + "` can only alias one concrete role at a time; enable either `endpoint` or `tui`, not both" + )); + + #[cfg(feature = "endpoint")] + $(#[$meta])* + $vis type $alias = $endpoint; + + #[cfg(all(not(feature = "endpoint"), feature = "tui"))] + $(#[$meta])* + $vis type $alias = $tui; + }; +} + +/// Minimal leaf-specific TUI contract. +/// +/// The initial implementation intentionally stays transport-agnostic. A CLI can +/// feed validated protocol `DataMessage` values into a leaf TUI and ask it for a +/// textual frame without depending on a specific rendering crate yet. +pub trait LeafTui { + /// Returns the canonical protocol leaf name this UI understands. + fn leaf_name(&self) -> String; + + /// Applies one inbound hook payload to the local UI state. + fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError>; + + /// Produces the current textual frame for the leaf. + fn render(&self) -> String; +} + +/// Lightweight error used by the leaf TUI surface. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TuiError { + message: String, +} + +impl TuiError { + /// Creates one UI-surface error from owned text. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl core::fmt::Display for TuiError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.message) + } +} + +impl core::error::Error for TuiError {} + +pub mod remote_shell; diff --git a/unshell-leaves/src/remote_shell/endpoint.rs b/unshell-leaves/src/remote_shell/endpoint.rs new file mode 100644 index 0000000..7fdaebe --- /dev/null +++ b/unshell-leaves/src/remote_shell/endpoint.rs @@ -0,0 +1,96 @@ +//! PTY-backed endpoint implementation for the remote shell leaf. + +mod errors; +mod session; +mod transport; + +use std::collections::BTreeMap; + +use unshell::Leaf; +use unshell::protocol::tree::{ + Call, HookKey, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint, +}; + +pub use errors::ShellLeafError; +pub use session::ProcedureOpen; +pub use transport::{LISTEN_ADDR, send_forward, spawn_frame_reader, write_frames}; + +use super::{OpenRequest, agent_path}; + +/// Leaf state for the remote shell endpoint runtime. +/// +/// The endpoint keeps each live shell session in an explicit map keyed by the +/// caller-owned hook identity. That makes ownership and cleanup of hook-backed +/// shell processes easy to inspect during debugging. +#[derive(Default, Leaf)] +#[leaf(leaf_name = "remote_shell")] +pub struct RemoteShellEndpoint { + sessions: BTreeMap, +} + +impl ProcedureStore for RemoteShellEndpoint { + fn procedure_sessions(&mut self) -> &mut BTreeMap { + &mut self.sessions + } +} + +impl Procedure for ProcedureOpen { + type Error = ShellLeafError; + type Input = OpenRequest; + + fn open(_leaf: &mut RemoteShellEndpoint, call: Call) -> Result { + let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?; + ProcedureOpen::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id) + } + + fn on_data( + _leaf: &mut RemoteShellEndpoint, + session: &mut Self, + data: unshell::protocol::tree::IncomingData, + ) -> Result { + session.on_data(data) + } + + fn on_fault( + _leaf: &mut RemoteShellEndpoint, + _session: &mut Self, + _fault: unshell::protocol::tree::IncomingFault, + ) -> Result<(), Self::Error> { + Ok(()) + } + + fn poll( + _leaf: &mut RemoteShellEndpoint, + session: &mut Self, + ) -> Result { + session.poll() + } + + fn close(_leaf: &mut RemoteShellEndpoint, mut session: Self) -> Result<(), Self::Error> { + session.terminate() + } +} + +/// Builds the controller endpoint used by the receiver example. +pub fn build_controller_endpoint() -> ProtocolEndpoint { + ProtocolEndpoint::new( + Vec::new(), + None, + vec![unshell::protocol::tree::ChildRoute::registered(agent_path())], + Vec::new(), + ) +} + +/// Builds the stateful shell runtime used by the endpoint example. +pub fn build_agent_runtime() -> ProcedureRuntime { + let endpoint = ProtocolEndpoint::new( + agent_path(), + Some(Vec::new()), + Vec::new(), + vec![unshell::protocol::tree::LeafSpec { + name: RemoteShellEndpoint::protocol_leaf_name(), + procedures: vec![ProcedureOpen::protocol_procedure_id()], + }], + ); + ProcedureRuntime::new(endpoint, RemoteShellEndpoint::default()) +} diff --git a/src/leaf/remote_shell/errors.rs b/unshell-leaves/src/remote_shell/endpoint/errors.rs similarity index 74% rename from src/leaf/remote_shell/errors.rs rename to unshell-leaves/src/remote_shell/endpoint/errors.rs index 1dcd102..7a5b933 100644 --- a/src/leaf/remote_shell/errors.rs +++ b/unshell-leaves/src/remote_shell/endpoint/errors.rs @@ -1,9 +1,12 @@ use std::fmt; use std::io; +/// Error produced by the remote shell endpoint implementation. #[derive(Debug)] pub enum ShellLeafError { + /// Underlying PTY or I/O failure. Io(io::Error), + /// Shell open requires a response hook so the session can stream bytes back. MissingHook, } diff --git a/src/leaf/remote_shell/session.rs b/unshell-leaves/src/remote_shell/endpoint/session.rs similarity index 90% rename from src/leaf/remote_shell/session.rs rename to unshell-leaves/src/remote_shell/endpoint/session.rs index 35054af..65afd2a 100644 --- a/src/leaf/remote_shell/session.rs +++ b/unshell-leaves/src/remote_shell/endpoint/session.rs @@ -1,13 +1,9 @@ //! Per-hook remote shell session lifecycle. //! -//! A session opens one PTY-backed shell process and then translates protocol hook -//! traffic into stdin writes and stdout/stderr chunks. The close model is -//! intentionally two-sided: -//! - peer end: the caller sets `end_hook`, so no more stdin is accepted -//! - local end: the shell process exits and the PTY reader drains completely -//! -//! Only after both conditions are observed does the session emit its final empty -//! `end_hook` packet back through the protocol runtime. +//! A session opens one PTY-backed shell process and translates protocol hook +//! traffic into stdin writes and stdout or stderr chunks. Close is intentionally +//! two-sided: the peer signals input completion with `end_hook`, while the local +//! side closes only after the child exits and the PTY reader drains. use std::io::{self, Read, Write}; use std::process::Command; @@ -15,18 +11,18 @@ use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError}; use std::thread; use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system}; +use unshell::Procedure; use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect}; -use unshell::Procedure; - +use super::RemoteShellEndpoint; use super::errors::ShellLeafError; /// Per-hook shell session created by the `open` procedure. /// -/// The procedure type is also the stored session type. This keeps the mapping -/// between protocol procedure and hook state direct and easy to inspect. +/// The procedure type is also the stored session type so the mapping between +/// one opening procedure and one live hook remains direct and visible. #[derive(Procedure)] -#[procedure(leaf = RemoteShellLeaf, name = "open")] +#[procedure(leaf = RemoteShellEndpoint, name = "open")] pub struct ProcedureOpen { /// Spawned PTY child process. pub(super) child: Box, @@ -56,8 +52,6 @@ enum OutputEvent { ReaderClosed, } -use super::RemoteShellLeaf; - impl ProcedureOpen { pub(super) fn spawn( return_path: Vec, @@ -173,9 +167,7 @@ impl ProcedureOpen { } // Peer end means no more stdin from the caller. Keep the process alive so - // any buffered PTY output can drain through the normal poll path. On Unix - // we also send SIGHUP so an interactive shell treats this like terminal - // hangup instead of waiting forever on the still-open PTY master. + // buffered PTY output can drain through the normal poll path. self.stdin_tx.take(); self.signal_process_group("-HUP"); Ok(ProcedureEffect::default()) diff --git a/src/leaf/remote_shell/transport.rs b/unshell-leaves/src/remote_shell/endpoint/transport.rs similarity index 89% rename from src/leaf/remote_shell/transport.rs rename to unshell-leaves/src/remote_shell/endpoint/transport.rs index a6117bb..4b23041 100644 --- a/src/leaf/remote_shell/transport.rs +++ b/unshell-leaves/src/remote_shell/endpoint/transport.rs @@ -6,10 +6,11 @@ use std::thread; use unshell::protocol::FrameBytes; use unshell::protocol::tree::EndpointOutcome; +/// TCP listen address used by the remote shell examples. pub const LISTEN_ADDR: &str = "127.0.0.1:4444"; const MAX_FRAME_BYTES: usize = 1024 * 1024; -#[allow(dead_code)] +/// Writes the forwarded frame produced by one endpoint outcome. pub fn send_forward(stream: &mut TcpStream, outcome: EndpointOutcome) -> io::Result<()> { match outcome { EndpointOutcome::Forward { frame, .. } => write_frames(stream, &[frame]), @@ -17,6 +18,7 @@ pub fn send_forward(stream: &mut TcpStream, outcome: EndpointOutcome) -> io::Res } } +/// Writes one or more framed packets onto the example TCP stream. pub fn write_frames(stream: &mut TcpStream, frames: &[FrameBytes]) -> io::Result<()> { for frame in frames { let frame_len = u32::try_from(frame.len()).map_err(|_| { @@ -29,6 +31,7 @@ pub fn write_frames(stream: &mut TcpStream, frames: &[FrameBytes]) -> io::Result Ok(()) } +/// Spawns the example frame reader that lifts prefixed frames off the TCP stream. pub fn spawn_frame_reader(mut stream: TcpStream) -> Receiver> { let (tx, rx) = mpsc::sync_channel(64); @@ -65,10 +68,7 @@ fn read_frame(stream: &mut TcpStream) -> io::Result> { )); } let mut bytes = vec![0u8; frame_len]; - match stream.read_exact(&mut bytes) { - Ok(()) => {} - Err(error) => return Err(error), - } + stream.read_exact(&mut bytes)?; let mut frame = FrameBytes::with_capacity(bytes.len()); frame.extend_from_slice(&bytes); diff --git a/unshell-leaves/src/remote_shell/mod.rs b/unshell-leaves/src/remote_shell/mod.rs new file mode 100644 index 0000000..3aa1dd1 --- /dev/null +++ b/unshell-leaves/src/remote_shell/mod.rs @@ -0,0 +1,92 @@ +//! Remote shell leaf and its user-facing surfaces. +//! +//! The module always exports the protocol contract for the leaf. Role-specific +//! implementations live behind crate-wide features: +//! - `endpoint` builds the PTY-backed runtime leaf +//! - `tui` builds a placeholder client-side TUI surface + +use rkyv::{Archive, Deserialize, Serialize}; + +#[cfg(feature = "endpoint")] +mod endpoint; +#[cfg(feature = "tui")] +mod tui; + +#[cfg(feature = "endpoint")] +pub use endpoint::{ + LISTEN_ADDR, RemoteShellEndpoint, ShellLeafError, build_agent_runtime, + build_controller_endpoint, send_forward, spawn_frame_reader, write_frames, +}; +#[cfg(feature = "tui")] +pub use tui::RemoteShellTui; + +use unshell::protocol::tree::encode_call_reply; + +/// Open-request payload for the remote shell leaf. +/// +/// The shell currently needs no structured arguments, but a named payload type is +/// easier for downstream code to discover than a bare `()`. +#[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct OpenRequest; + +crate::role_leaf! { + /// Feature-selected remote shell surface. + pub type RemoteShell { + endpoint => endpoint::RemoteShellEndpoint, + tui => tui::RemoteShellTui, + } +} + +/// Returns the example endpoint path used by the remote shell samples. +pub fn agent_path() -> Vec { + path(&["agent"]) +} + +/// Returns the canonical leaf id used by endpoint and TUI code. +#[cfg(feature = "endpoint")] +pub fn shell_leaf_name() -> String { + RemoteShellEndpoint::protocol_leaf_name() +} + +/// Returns the canonical opening `procedure_id` for the shell leaf. +#[cfg(feature = "endpoint")] +pub fn shell_open_procedure() -> String { + endpoint::ProcedureOpen::protocol_procedure_id() +} + +/// Encodes the empty open-request payload used by the shell example. +#[cfg(all(not(feature = "endpoint"), feature = "tui"))] +pub fn shell_leaf_name() -> String { + RemoteShellTui::protocol_leaf_name() +} + +/// Returns the canonical opening `procedure_id` for the shell leaf. +#[cfg(all(not(feature = "endpoint"), feature = "tui"))] +pub fn shell_open_procedure() -> String { + let mut procedure_id = shell_leaf_name(); + procedure_id.push_str(".open"); + procedure_id +} + +/// Encodes the empty open-request payload used by the shell example. +#[cfg(not(any(feature = "endpoint", feature = "tui")))] +pub fn shell_leaf_name() -> String { + String::from("remote_shell") +} + +/// Returns the canonical opening `procedure_id` for the shell leaf. +#[cfg(not(any(feature = "endpoint", feature = "tui")))] +pub fn shell_open_procedure() -> String { + let mut procedure_id = shell_leaf_name(); + procedure_id.push_str(".open"); + procedure_id +} + +/// Encodes the empty open-request payload used by the shell example. +pub fn shell_open_payload() -> Vec { + encode_call_reply(&OpenRequest).expect("remote shell open payload should encode") +} + +fn path(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_owned()).collect() +} diff --git a/unshell-leaves/src/remote_shell/tui.rs b/unshell-leaves/src/remote_shell/tui.rs new file mode 100644 index 0000000..aebf52e --- /dev/null +++ b/unshell-leaves/src/remote_shell/tui.rs @@ -0,0 +1,43 @@ +//! Placeholder client-side TUI surface for the remote shell leaf. +//! +//! The first application-layer consumer will be a CLI and later a full GUI. This +//! stub keeps the leaf-specific interpretation point in place without forcing a +//! rendering-library decision yet. + +use std::string::String; +use std::vec::Vec; + +use unshell::Leaf; +use unshell::protocol::DataMessage; + +use crate::{LeafTui, TuiError}; + +/// Stub TUI surface for the remote shell leaf. +#[derive(Default, Leaf)] +#[leaf(leaf_name = "remote_shell")] +pub struct RemoteShellTui { + transcript: Vec, +} + +impl RemoteShellTui { + /// Returns a short explanation of the current stub status. + pub fn status_line(&self) -> &'static str { + "remote shell TUI stub: rendering is placeholder-only for now" + } +} + +impl LeafTui for RemoteShellTui { + fn leaf_name(&self) -> String { + Self::protocol_leaf_name() + } + + fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError> { + self.transcript.extend_from_slice(&message.data); + Ok(()) + } + + fn render(&self) -> String { + let body = String::from_utf8_lossy(&self.transcript); + format!("{}\n\n{}", self.status_line(), body) + } +} diff --git a/unshell-protocol/Cargo.toml b/unshell-protocol/Cargo.toml new file mode 100644 index 0000000..a357429 --- /dev/null +++ b/unshell-protocol/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "unshell-protocol" +version.workspace = true +edition.workspace = true +description = "Wire protocol, framing, validation, and endpoint runtime for UnShell" + +[lib] +doctest = false + +[dependencies] +rkyv = { workspace = true } +unshell-macros = { path = "../unshell-macros" } + +[lints.rust] +elided_lifetimes_in_paths = "warn" +future_incompatible = { level = "warn", priority = -1 } +nonstandard_style = { level = "warn", priority = -1 } +rust_2018_idioms = { level = "warn", priority = -1 } +rust_2021_prelude_collisions = "warn" +semicolon_in_expressions_from_macros = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_import_braces = "warn" +unused_lifetimes = "warn" +trivial_casts = "allow" +missing_docs = "warn" diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs new file mode 100644 index 0000000..456eafa --- /dev/null +++ b/unshell-protocol/src/lib.rs @@ -0,0 +1,20 @@ +//! # UnShell Protocol +//! +//! The protocol crate owns the wire types, framing, validation helpers, and the +//! small tree runtime used by endpoint implementations. + +#![no_std] + +pub extern crate alloc; +#[allow(unused_extern_crates)] +extern crate self as unshell; + +/// Keep the historical nested path so existing imports and proc-macro output can +/// continue to target `unshell::protocol::...` while the implementation lives in +/// its own crate. +pub mod protocol; + +pub use protocol::*; + +#[cfg(test)] +pub use unshell_macros::{Leaf, Procedure, procedures}; diff --git a/src/protocol/PROTOCOL_CHANGES.md b/unshell-protocol/src/protocol/PROTOCOL_CHANGES.md similarity index 100% rename from src/protocol/PROTOCOL_CHANGES.md rename to unshell-protocol/src/protocol/PROTOCOL_CHANGES.md diff --git a/src/protocol/codec.rs b/unshell-protocol/src/protocol/codec.rs similarity index 100% rename from src/protocol/codec.rs rename to unshell-protocol/src/protocol/codec.rs diff --git a/src/protocol/introspection.rs b/unshell-protocol/src/protocol/introspection.rs similarity index 100% rename from src/protocol/introspection.rs rename to unshell-protocol/src/protocol/introspection.rs diff --git a/src/protocol/mod.rs b/unshell-protocol/src/protocol/mod.rs similarity index 100% rename from src/protocol/mod.rs rename to unshell-protocol/src/protocol/mod.rs diff --git a/src/protocol/tests/call.rs b/unshell-protocol/src/protocol/tests/call.rs similarity index 100% rename from src/protocol/tests/call.rs rename to unshell-protocol/src/protocol/tests/call.rs diff --git a/src/protocol/tests/mod.rs b/unshell-protocol/src/protocol/tests/mod.rs similarity index 100% rename from src/protocol/tests/mod.rs rename to unshell-protocol/src/protocol/tests/mod.rs diff --git a/src/protocol/tests/procedure.rs b/unshell-protocol/src/protocol/tests/procedure.rs similarity index 100% rename from src/protocol/tests/procedure.rs rename to unshell-protocol/src/protocol/tests/procedure.rs diff --git a/src/protocol/tests/protocol.rs b/unshell-protocol/src/protocol/tests/protocol.rs similarity index 100% rename from src/protocol/tests/protocol.rs rename to unshell-protocol/src/protocol/tests/protocol.rs diff --git a/src/protocol/tests/tree.rs b/unshell-protocol/src/protocol/tests/tree.rs similarity index 100% rename from src/protocol/tests/tree.rs rename to unshell-protocol/src/protocol/tests/tree.rs diff --git a/src/protocol/tree/call.rs b/unshell-protocol/src/protocol/tree/call.rs similarity index 100% rename from src/protocol/tree/call.rs rename to unshell-protocol/src/protocol/tree/call.rs diff --git a/src/protocol/tree/endpoint/builders.rs b/unshell-protocol/src/protocol/tree/endpoint/builders.rs similarity index 100% rename from src/protocol/tree/endpoint/builders.rs rename to unshell-protocol/src/protocol/tree/endpoint/builders.rs diff --git a/src/protocol/tree/endpoint/core.rs b/unshell-protocol/src/protocol/tree/endpoint/core.rs similarity index 100% rename from src/protocol/tree/endpoint/core.rs rename to unshell-protocol/src/protocol/tree/endpoint/core.rs diff --git a/src/protocol/tree/endpoint/hooks.rs b/unshell-protocol/src/protocol/tree/endpoint/hooks.rs similarity index 100% rename from src/protocol/tree/endpoint/hooks.rs rename to unshell-protocol/src/protocol/tree/endpoint/hooks.rs diff --git a/src/protocol/tree/endpoint/introspection.rs b/unshell-protocol/src/protocol/tree/endpoint/introspection.rs similarity index 100% rename from src/protocol/tree/endpoint/introspection.rs rename to unshell-protocol/src/protocol/tree/endpoint/introspection.rs diff --git a/src/protocol/tree/endpoint/mod.rs b/unshell-protocol/src/protocol/tree/endpoint/mod.rs similarity index 100% rename from src/protocol/tree/endpoint/mod.rs rename to unshell-protocol/src/protocol/tree/endpoint/mod.rs diff --git a/src/protocol/tree/endpoint/receive.rs b/unshell-protocol/src/protocol/tree/endpoint/receive.rs similarity index 100% rename from src/protocol/tree/endpoint/receive.rs rename to unshell-protocol/src/protocol/tree/endpoint/receive.rs diff --git a/src/protocol/tree/hook.rs b/unshell-protocol/src/protocol/tree/hook.rs similarity index 100% rename from src/protocol/tree/hook.rs rename to unshell-protocol/src/protocol/tree/hook.rs diff --git a/src/protocol/tree/leaf.rs b/unshell-protocol/src/protocol/tree/leaf.rs similarity index 100% rename from src/protocol/tree/leaf.rs rename to unshell-protocol/src/protocol/tree/leaf.rs diff --git a/src/protocol/tree/mod.rs b/unshell-protocol/src/protocol/tree/mod.rs similarity index 100% rename from src/protocol/tree/mod.rs rename to unshell-protocol/src/protocol/tree/mod.rs diff --git a/src/protocol/tree/procedure.rs b/unshell-protocol/src/protocol/tree/procedure.rs similarity index 100% rename from src/protocol/tree/procedure.rs rename to unshell-protocol/src/protocol/tree/procedure.rs diff --git a/src/protocol/tree/routing.rs b/unshell-protocol/src/protocol/tree/routing.rs similarity index 100% rename from src/protocol/tree/routing.rs rename to unshell-protocol/src/protocol/tree/routing.rs diff --git a/src/protocol/types.rs b/unshell-protocol/src/protocol/types.rs similarity index 100% rename from src/protocol/types.rs rename to unshell-protocol/src/protocol/types.rs diff --git a/src/protocol/validation.rs b/unshell-protocol/src/protocol/validation.rs similarity index 100% rename from src/protocol/validation.rs rename to unshell-protocol/src/protocol/validation.rs