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/headless/bootstrap.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/headless/bootstrap.rs')
| -rw-r--r-- | src/server/headless/bootstrap.rs | 218 |
1 files changed, 218 insertions, 0 deletions
diff --git a/src/server/headless/bootstrap.rs b/src/server/headless/bootstrap.rs new file mode 100644 index 00000000..638b1b2d --- /dev/null +++ b/src/server/headless/bootstrap.rs @@ -0,0 +1,218 @@ +use super::*; + +/// Run the headless server. This is the entry point called from main.rs. +pub fn run_server() -> io::Result<()> { + init_logging(); + crate::platform::raise_server_nofile_limit(); + + let args: Vec<String> = std::env::args().collect(); + if args.get(2).map(String::as_str) == Some("--handoff-import") { + let socket_path = args + .get(3) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?; + let token = args + .get(4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?; + return run_handoff_import_server(&socket_path, token); + } + + let loaded_config = config::Config::load(); + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + // Start the JSON API socket server. + let _api_server = match api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("api socket: {}", api::socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + // Create the App (with AppState, event channels, etc.). + let mut app = app::App::new( + &loaded_config.config, + app::AppPolicy::PRODUCTION, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub, + ); + seed_startup_workspace_if_empty(&mut app); + + // Create the headless server. + let mut server = match HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(_api_server), + should_quit, + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("client socket: {}", client_socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + info!( + api_socket = %api::socket_path().display(), + client_socket = %client_socket_path().display(), + "herdr server started" + ); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +fn seed_startup_workspace_if_empty(app: &mut app::App) { + let Some(cwd) = take_startup_cwd() else { + return; + }; + + if !app.state.workspaces.is_empty() { + info!( + cwd = %cwd.display(), + "restored session already has workspaces; ignoring startup cwd" + ); + return; + } + + match app.create_workspace_with_options(cwd.clone(), true) { + Ok(_) => { + info!(cwd = %cwd.display(), "created startup workspace"); + } + Err(err) => { + warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); + app.state.mode = app::Mode::Navigate; + } + } +} + +fn take_startup_cwd() -> Option<PathBuf> { + let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; + std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); + (!cwd.is_empty()).then(|| PathBuf::from(cwd)) +} + +#[cfg(unix)] +fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { + let loaded_config = config::Config::load(); + let mut received = crate::server::handoff::receive(socket_path, token)?; + crate::server::handoff::log_import_result(received.manifest.panes.len()); + + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + let mut imports = HashMap::new(); + for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { + let pane_id = pane.pane_id; + imports.insert( + pane_id, + crate::handoff_runtime::ImportedHandoffRuntime { + master_fd: fd, + state: pane, + }, + ); + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + let app = app::App::new_from_handoff( + &loaded_config.config, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub.clone(), + &received.manifest.snapshot, + &mut imports, + )?; + crate::server::handoff::report_restored(&mut received.stream)?; + if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") { + return Err(io::Error::other( + "test handoff import failure after restored", + )); + } + wait_for_old_public_sockets_to_close(Duration::from_secs(5))?; + + let api_server = api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + )?; + let mut server = HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(api_server), + should_quit, + )?; + // Carried across before any client attaches, so the first title sent is + // the override rather than the configured one it replaced. + server.api_window_title = received.manifest.api_window_title.take(); + crate::server::handoff::report_ready(&mut received.stream)?; + crate::server::handoff::wait_committed(&mut received.stream)?; + server.app.assume_handoff_ownership(); + server.app.unpause_handoff_readers(); + server.pending_handoff_repaint_nudge = true; + if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) { + warn!(err = %err, "failed to report handoff ownership; continuing as owner"); + } + info!("handoff import server started"); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +#[cfg(not(unix))] +fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> { + Err(io::Error::other("live handoff is only supported on Unix")) +} + +fn print_ready_message(api_socket: &Path, client_socket: &Path) { + eprintln!("herdr server running; you can use any herdr CLI command in another terminal."); + eprintln!("api socket: {}", api_socket.display()); + eprintln!("client socket: {}", client_socket.display()); + eprintln!( + "logs: {}", + crate::session::data_dir() + .join("herdr-server.log") + .display() + ); + eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`."); +} + +/// Initialize logging for the server process. +fn init_logging() { + crate::logging::init_file_logging("herdr-server.log"); +} |
