From e0458caeefefe8e9b5919ae86cb23cf7fed266b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 16:00:43 +0000 Subject: [PATCH 1/3] Add `shellshare serve` command for combined local server + client This adds a new `serve` subcommand that starts a local server and broadcasts the terminal to it in a single command, eliminating the need to run server and client separately for local sharing. Options: --host/-H: Bind address (default: 127.0.0.1) --port/-p: Listen port (default: 5000) --stdin: Read from stdin instead of spawning a shell The server automatically shuts down when the session ends. https://claude.ai/code/session_01HsviQhQjYdFSDSU7mpZmXw --- e2e/test_cli_serve.py | 373 ++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 3 +- src/cli/serve.rs | 177 ++++++++++++++++++++ src/main.rs | 20 +++ 4 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 e2e/test_cli_serve.py create mode 100644 src/cli/serve.rs diff --git a/e2e/test_cli_serve.py b/e2e/test_cli_serve.py new file mode 100644 index 0000000..26e396e --- /dev/null +++ b/e2e/test_cli_serve.py @@ -0,0 +1,373 @@ +""" +E2E Tests for Shellshare CLI - Serve Mode + +Tests for the `shellshare serve` command, which starts a local server +and broadcasts the terminal to it in a single command. + +Each test starts its own `shellshare serve` on a unique port, so these +tests are independent from the main server running on port 3000. +""" + +import socket +import subprocess +import time +import urllib.request + +import pytest + +from conftest import ( + CLI_COMMAND, + SocketListener, + wait_for_content, + wait_for_server, +) + + +def get_free_port(): + """Find a free TCP port to use for testing.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def run_serve_stdin(message, port, extra_args=None, timeout=10): + """ + Run `shellshare serve --stdin` with the given message piped to stdin. + + Returns (returncode, stdout, stderr). + """ + args = CLI_COMMAND + ["serve", "--stdin", "--port", str(port)] + if extra_args: + args.extend(extra_args) + + proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + try: + stdout, stderr = proc.communicate(input=message, timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + + return proc.returncode, stdout, stderr + + +def start_serve(port, extra_args=None, stdin_mode=False): + """ + Start `shellshare serve` on the given port. + + Returns the subprocess.Popen handle. + """ + args = CLI_COMMAND + ["serve", "--port", str(port)] + if stdin_mode: + args.append("--stdin") + if extra_args: + args.extend(extra_args) + + return subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def stop_serve(proc, send_exit=False, timeout=15): + """ + Stop a `shellshare serve` process and return (stdout, stderr). + + If send_exit is True, sends 'exit' to stdin first for a clean exit. + Otherwise terminates the process. + """ + try: + if send_exit and proc.stdin: + proc.stdin.write("exit\n") + proc.stdin.flush() + stdout, stderr = proc.communicate(timeout=timeout) + else: + proc.terminate() + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + return stdout, stderr + + +class TestServeStdinBasic: + """Basic tests for serve --stdin mode.""" + + def test_serve_stdin_message_received(self): + """Messages piped to serve --stdin should be received by viewers.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + # Send a message to stdin + proc.stdin.write("hello from serve\n") + proc.stdin.flush() + + # Verify it was received + msg = listener.wait_for_message(timeout=5, containing="hello from serve") + assert msg is not None, "Message not received via Socket.IO" + assert "hello from serve" in msg + + listener.disconnect() + finally: + stop_serve(proc) + + def test_serve_stdin_prints_url_to_stderr(self): + """Serve --stdin should print the sharing URL to stderr.""" + port = get_free_port() + returncode, stdout, stderr = run_serve_stdin("test\n", port) + + assert returncode == 0 + assert "Sharing terminal in" in stderr + assert "/r/terminal" in stderr + assert str(port) in stderr + + def test_serve_stdin_prints_end_of_transmission(self): + """Serve --stdin should print end message on clean exit.""" + port = get_free_port() + returncode, stdout, stderr = run_serve_stdin("done\n", port) + + assert returncode == 0 + assert "End of transmission." in stderr + + def test_serve_stdin_custom_port(self): + """Serve should listen on the specified port.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + response = urllib.request.urlopen(server_url, timeout=5) + assert response.status == 200 + finally: + stop_serve(proc) + + def test_serve_room_page_accessible(self): + """The /r/terminal room page should be accessible.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + response = urllib.request.urlopen( + f"{server_url}/r/terminal", timeout=5 + ) + assert response.status == 200 + finally: + stop_serve(proc) + + +class TestServeStdinStreaming: + """Tests for message streaming via serve --stdin.""" + + def test_multiple_messages_received_in_order(self): + """Multiple messages should be received in order.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + proc.stdin.write("FIRST\n") + proc.stdin.flush() + proc.stdin.write("SECOND\n") + proc.stdin.flush() + + assert wait_for_content( + listener, lambda s: "FIRST" in s and "SECOND" in s, timeout=5 + ), "Both messages not received" + + accumulated = listener.get_accumulated_messages() + first_idx = accumulated.index("FIRST") + second_idx = accumulated.index("SECOND") + assert first_idx < second_idx, "Messages received out of order" + + listener.disconnect() + finally: + stop_serve(proc) + + def test_late_joiner_sees_history(self): + """A viewer joining after messages were sent should see history.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + # Send message before any viewer connects + proc.stdin.write("early-message\n") + proc.stdin.flush() + time.sleep(1) + + # Late joiner connects + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + msg = listener.wait_for_message(timeout=5, containing="early-message") + assert msg is not None, "Late joiner did not receive history" + + listener.disconnect() + finally: + stop_serve(proc) + + +class TestServeTerminalSize: + """Tests for terminal size handling in serve mode.""" + + def test_size_event_sent(self): + """Serve --stdin should send terminal size events.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + # Send a message to trigger a POST with size + proc.stdin.write("size-test\n") + proc.stdin.flush() + + # Wait for message first (size is sent with each POST) + listener.wait_for_message(timeout=5, containing="size-test") + + assert listener.wait_for_size(timeout=5), "No size event received" + + size = listener.get_last_size() + assert size is not None + assert "cols" in size + assert "rows" in size + + listener.disconnect() + finally: + stop_serve(proc) + + +class TestServeCleanExit: + """Tests for serve mode clean exit.""" + + def test_serve_stdin_exits_on_eof(self): + """Serve --stdin should exit cleanly when stdin closes.""" + port = get_free_port() + returncode, stdout, stderr = run_serve_stdin("goodbye\n", port) + + assert returncode == 0 + assert "End of transmission." in stderr + + def test_serve_server_stops_after_exit(self): + """Server should stop accepting connections after serve exits.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + returncode, stdout, stderr = run_serve_stdin("done\n", port) + assert returncode == 0 + + # Give OS time to release the socket + time.sleep(1) + + # Server should no longer be accessible + with pytest.raises(Exception): + urllib.request.urlopen(server_url, timeout=2) + + def test_serve_sends_delete_on_exit(self): + """Room should be cleaned up (DELETE sent) on exit.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + proc.stdin.write("cleanup-test\n") + proc.stdin.flush() + + listener.wait_for_message(timeout=5, containing="cleanup-test") + listener.disconnect() + finally: + stop_serve(proc) + + # After exit, a new serve on the same port should work + # (room was cleaned up, not locked by stale password) + port2 = get_free_port() + returncode, stdout, stderr = run_serve_stdin("new-session\n", port2) + assert returncode == 0 + + +class TestServeScriptMode: + """Tests for serve in default (PTY) mode.""" + + def test_serve_script_starts_and_streams(self): + """Serve mode (PTY) should start a server and stream output.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + proc = start_serve(port) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + # PTY should produce output (shell prompt, etc.) + assert wait_for_content(listener, lambda s: len(s) > 0, timeout=10), \ + "No output received from serve mode PTY" + + listener.disconnect() + finally: + stop_serve(proc) + + def test_serve_script_prints_sharing_url(self): + """Serve mode (PTY) should print sharing URL to stdout.""" + port = get_free_port() + proc = start_serve(port) + + try: + wait_for_server(f"http://localhost:{port}", timeout_seconds=15) + time.sleep(1) + + stdout, stderr = stop_serve(proc, send_exit=True) + output = stdout + stderr + + assert "Sharing terminal in" in output + assert "/r/terminal" in output + assert str(port) in output + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + pytest.fail("Serve process timed out") diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1ee3aad..1669697 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ mod encoding; mod http; mod script; +pub mod serve; mod terminal; use std::io::{self, Read}; @@ -120,7 +121,7 @@ pub fn run(args: ClientArgs) -> Result<(), Box> { } /// Stream stdin to the server (for testing) -fn stream_stdin( +pub fn stream_stdin( client: &http::Client, running: &Arc, ) -> Result<(), Box> { diff --git a/src/cli/serve.rs b/src/cli/serve.rs new file mode 100644 index 0000000..73d5a40 --- /dev/null +++ b/src/cli/serve.rs @@ -0,0 +1,177 @@ +//! Serve mode - combined server and client in a single command +//! +//! Starts a local shellshare server and immediately connects to it, +//! providing a single command to share your terminal via a local URL. + +use super::{http, script, terminal}; + +use std::net::TcpStream; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// Configuration for the serve command +pub struct ServeArgs { + pub host: String, + pub port: u16, + pub stdin: bool, +} + +/// Fixed room name for serve mode (only one room needed) +const ROOM_NAME: &str = "terminal"; + +/// Run shellshare in serve mode: start a local server and stream to it +pub fn run(args: &ServeArgs) -> Result<(), Box> { + // Start a Tokio runtime for the async server + let runtime = tokio::runtime::Runtime::new()?; + + // Spawn the server in the background + let server_host = args.host.clone(); + let server_port = args.port; + runtime.spawn(async move { + if let Err(e) = crate::server::run(&server_host, server_port, 3600, 21600).await { + eprintln!("Server error: {e}"); + } + }); + + // Wait for the server to accept connections + let connect_host = connectable_host(&args.host); + wait_for_server(connect_host, args.port)?; + + // Build the display URL + let display_host = display_host(&args.host); + let base_url = format!("http://{display_host}:{}", args.port); + + // Create HTTP client pointing to the local server + let server_url = format!("http://{connect_host}:{}", args.port); + let room_path = format!("r/{ROOM_NAME}"); + let password = generate_internal_password(); + let client = http::Client::new(&server_url, &room_path, &password)?; + + // Setup Ctrl+C handler for cleanup + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + let client_for_cleanup = client.clone(); + + ctrlc::set_handler(move || { + running_clone.store(false, Ordering::SeqCst); + let _ = client_for_cleanup.delete_room(); + })?; + + if args.stdin { + // Stdin mode - print to stderr (same as regular client stdin mode) + eprintln!("Sharing terminal in {base_url}/r/{ROOM_NAME}"); + + super::stream_stdin(&client, &running)?; + + let _ = client.delete_room(); + eprintln!("End of transmission."); + } else { + // Script mode (PTY) - print to stdout + let size = terminal::get_terminal_size(); + if size.rows > 30 || size.cols > 160 { + println!("Current terminal size is {}x{}.", size.rows, size.cols); + println!("It's too big to be viewed on smaller screens."); + println!("You can resize it anytime."); + } + + println!("Sharing terminal in {base_url}/r/{ROOM_NAME}"); + + script::run_script_mode(&client, &running)?; + + let _ = client.delete_room(); + println!("End of transmission."); + } + + // Shut down the runtime without blocking + runtime.shutdown_background(); + + Ok(()) +} + +/// Generate a random password for internal client-server auth. +/// Uses a random value since the server may be exposed on the network. +fn generate_internal_password() -> String { + use rand::Rng; + rand::thread_rng().gen::().to_string() +} + +/// Get a connectable host address from the bind address. +/// `0.0.0.0` isn't connectable on all platforms, so map it to `127.0.0.1`. +fn connectable_host(host: &str) -> &str { + if host == "0.0.0.0" { + "127.0.0.1" + } else { + host + } +} + +/// Get a human-friendly display host for URLs. +fn display_host(host: &str) -> &str { + if host == "0.0.0.0" || host == "127.0.0.1" { + "localhost" + } else { + host + } +} + +/// Wait for the server to be ready by attempting TCP connections. +fn wait_for_server(host: &str, port: u16) -> Result<(), Box> { + let addr = format!("{host}:{port}"); + + for _ in 0..50 { + if TcpStream::connect(&addr).is_ok() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + + Err(format!("Server failed to start (could not connect to {addr})").into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connectable_host_maps_wildcard() { + assert_eq!(connectable_host("0.0.0.0"), "127.0.0.1"); + } + + #[test] + fn test_connectable_host_preserves_specific() { + assert_eq!(connectable_host("127.0.0.1"), "127.0.0.1"); + assert_eq!(connectable_host("192.168.1.1"), "192.168.1.1"); + } + + #[test] + fn test_display_host_localhost_variants() { + assert_eq!(display_host("0.0.0.0"), "localhost"); + assert_eq!(display_host("127.0.0.1"), "localhost"); + } + + #[test] + fn test_display_host_preserves_specific() { + assert_eq!(display_host("192.168.1.1"), "192.168.1.1"); + assert_eq!(display_host("10.0.0.1"), "10.0.0.1"); + } + + #[test] + fn test_generate_internal_password_is_nonempty() { + let pw = generate_internal_password(); + assert!(!pw.is_empty()); + } + + #[test] + fn test_generate_internal_password_is_numeric() { + let pw = generate_internal_password(); + assert!(pw.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn test_wait_for_server_fails_on_closed_port() { + // Port 1 is almost certainly not listening + let result = wait_for_server("127.0.0.1", 1); + assert!(result.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 2f0c8bf..cb747dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,21 @@ enum Commands { #[arg(long, default_value = "21600")] room_ttl: u64, }, + + /// Start a local server and share your terminal in one command + Serve { + /// Host to bind to + #[arg(short = 'H', long, default_value = "127.0.0.1")] + host: String, + + /// Port to listen on + #[arg(short, long, default_value = "5000")] + port: u16, + + /// Read from stdin instead of spawning a shell + #[arg(long)] + stdin: bool, + }, } fn main() -> Result<(), Box> { @@ -87,6 +102,11 @@ fn main() -> Result<(), Box> { let runtime = tokio::runtime::Runtime::new()?; runtime.block_on(server::run(&host, port, cleanup_interval, room_ttl))?; } + Some(Commands::Serve { host, port, stdin }) => { + // Run combined server + client mode + let args = cli::serve::ServeArgs { host, port, stdin }; + cli::serve::run(&args)?; + } None => { // Run client mode let args = cli::ClientArgs { From 671736829ed7ac86c16e087ae45aa7b4861ebda1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 19:08:53 +0000 Subject: [PATCH 2/3] Serve root URL directly as terminal viewer in serve mode When running `shellshare serve`, GET / now serves the room viewer page instead of the home page. This means visitors can go to http://localhost:5000 and see the terminal directly. Implementation: - Add `ServerConfig` struct with optional `serve_room` field - In serve mode, inject `window.SHELLSHARE_ROOM` into room.html so the JS knows which room to join regardless of URL path - Display clean base URL (http://localhost:5000) in output - Regular `shellshare server` is unaffected (serve_room: None) https://claude.ai/code/session_01HsviQhQjYdFSDSU7mpZmXw --- e2e/test_cli_serve.py | 31 +++++++++++++++++++---- src/cli/serve.rs | 15 +++++++---- src/main.rs | 9 ++++++- src/server/mod.rs | 59 +++++++++++++++++++++++++++++++++++++------ templates/room.html | 2 +- 5 files changed, 96 insertions(+), 20 deletions(-) diff --git a/e2e/test_cli_serve.py b/e2e/test_cli_serve.py index 26e396e..6ce4fd7 100644 --- a/e2e/test_cli_serve.py +++ b/e2e/test_cli_serve.py @@ -129,14 +129,16 @@ def test_serve_stdin_message_received(self): stop_serve(proc) def test_serve_stdin_prints_url_to_stderr(self): - """Serve --stdin should print the sharing URL to stderr.""" + """Serve --stdin should print the base sharing URL to stderr.""" port = get_free_port() returncode, stdout, stderr = run_serve_stdin("test\n", port) assert returncode == 0 assert "Sharing terminal in" in stderr - assert "/r/terminal" in stderr assert str(port) in stderr + # URL should be the base URL (no /r/terminal suffix) + assert f"http://localhost:{port}" in stderr + assert "/r/terminal" not in stderr def test_serve_stdin_prints_end_of_transmission(self): """Serve --stdin should print end message on clean exit.""" @@ -162,7 +164,7 @@ def test_serve_stdin_custom_port(self): stop_serve(proc) def test_serve_room_page_accessible(self): - """The /r/terminal room page should be accessible.""" + """The /r/terminal room page should still be accessible.""" port = get_free_port() server_url = f"http://localhost:{port}" @@ -178,6 +180,26 @@ def test_serve_room_page_accessible(self): finally: stop_serve(proc) + def test_serve_root_shows_terminal(self): + """GET / should serve the room viewer page, not the home page.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + response = urllib.request.urlopen(server_url, timeout=5) + assert response.status == 200 + content = response.read().decode() + # Should contain the room override script + assert "SHELLSHARE_ROOM" in content + # Should contain the terminal container (room page, not home page) + assert 'id="terminal"' in content + finally: + stop_serve(proc) + class TestServeStdinStreaming: """Tests for message streaming via serve --stdin.""" @@ -353,7 +375,7 @@ def test_serve_script_starts_and_streams(self): stop_serve(proc) def test_serve_script_prints_sharing_url(self): - """Serve mode (PTY) should print sharing URL to stdout.""" + """Serve mode (PTY) should print base sharing URL to stdout.""" port = get_free_port() proc = start_serve(port) @@ -365,7 +387,6 @@ def test_serve_script_prints_sharing_url(self): output = stdout + stderr assert "Sharing terminal in" in output - assert "/r/terminal" in output assert str(port) in output except subprocess.TimeoutExpired: proc.kill() diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 73d5a40..2887485 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -26,10 +26,15 @@ pub fn run(args: &ServeArgs) -> Result<(), Box> { let runtime = tokio::runtime::Runtime::new()?; // Spawn the server in the background - let server_host = args.host.clone(); - let server_port = args.port; + let server_config = crate::server::ServerConfig { + host: args.host.clone(), + port: args.port, + cleanup_interval_secs: 3600, + room_ttl_secs: 21600, + serve_room: Some(ROOM_NAME.to_string()), + }; runtime.spawn(async move { - if let Err(e) = crate::server::run(&server_host, server_port, 3600, 21600).await { + if let Err(e) = crate::server::run(&server_config).await { eprintln!("Server error: {e}"); } }); @@ -60,7 +65,7 @@ pub fn run(args: &ServeArgs) -> Result<(), Box> { if args.stdin { // Stdin mode - print to stderr (same as regular client stdin mode) - eprintln!("Sharing terminal in {base_url}/r/{ROOM_NAME}"); + eprintln!("Sharing terminal in {base_url}"); super::stream_stdin(&client, &running)?; @@ -75,7 +80,7 @@ pub fn run(args: &ServeArgs) -> Result<(), Box> { println!("You can resize it anytime."); } - println!("Sharing terminal in {base_url}/r/{ROOM_NAME}"); + println!("Sharing terminal in {base_url}"); script::run_script_mode(&client, &running)?; diff --git a/src/main.rs b/src/main.rs index cb747dc..2c59c04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,7 +100,14 @@ fn main() -> Result<(), Box> { // Create runtime only for server mode let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(server::run(&host, port, cleanup_interval, room_ttl))?; + let config = server::ServerConfig { + host, + port, + cleanup_interval_secs: cleanup_interval, + room_ttl_secs: room_ttl, + serve_room: None, + }; + runtime.block_on(server::run(&config))?; } Some(Commands::Serve { host, port, stdin }) => { // Run combined server + client mode diff --git a/src/server/mod.rs b/src/server/mod.rs index fb324e0..f22943a 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -122,13 +122,23 @@ struct AppState { /// Request body for POST /r/:room - using Value to preserve null vs missing distinction type BroadcastRequest = serde_json::Value; +/// Server configuration +pub struct ServerConfig { + pub host: String, + pub port: u16, + pub cleanup_interval_secs: u64, + pub room_ttl_secs: u64, + /// When set, `GET /` serves the room viewer for this room instead of the home page. + /// Used by `shellshare serve` so that `http://localhost:5000` shows the terminal directly. + pub serve_room: Option, +} + /// Run the shellshare server -pub async fn run( - host: &str, - port: u16, - cleanup_interval_secs: u64, - room_ttl_secs: u64, -) -> Result<(), Box> { +pub async fn run(config: &ServerConfig) -> Result<(), Box> { + let host = &config.host; + let port = config.port; + let cleanup_interval_secs = config.cleanup_interval_secs; + let room_ttl_secs = config.room_ttl_secs; info!("Starting shellshare server on {}:{}", host, port); info!( "Room cleanup: interval={}s, TTL={}s", @@ -161,10 +171,18 @@ pub async fn run( // Spawn background cleanup task for abandoned rooms spawn_cleanup_task(app_state.clone()); - // Build router + // Build router - in serve mode, GET / shows the room viewer directly + let index_route = config.serve_room.as_ref().map_or_else( + || get(index_handler), + |room| { + let room = room.clone(); + get(move |headers| serve_room_index_handler(headers, room.clone())) + }, + ); + let app = Router::new() // API routes - .route("/", get(index_handler)) + .route("/", index_route) .route("/r/{*room}", get(room_page_handler)) .route("/r/{*room}", post(broadcast_handler)) .route("/r/{*room}", delete(delete_room_handler)) @@ -362,6 +380,31 @@ async fn index_handler(headers: HeaderMap) -> impl IntoResponse { } } +/// GET / in serve mode - serves room.html with the room name injected +#[allow(clippy::unused_async)] // Must be async for axum Handler trait +async fn serve_room_index_handler(_headers: HeaderMap, room: String) -> impl IntoResponse { + match Templates::get("room.html") { + Some(content) => { + let html = String::from_utf8_lossy(&content.data); + // Inject the room override before the main script + let override_script = + format!(""); + let html = html.replacen(""); - let html = html.replacen(" +