aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless/bootstrap.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/server/headless/bootstrap.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs218
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");
+}