aboutsummaryrefslogtreecommitdiffstats
path: root/sl0ppij/zellij-server/src/lib.rs
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 19:34:15 +0200
committeruser <user@clank>2026-08-12 19:34:15 +0200
commitd1c14ff0f5c5b55c46d91ad12f4a291041480a99 (patch)
tree9dca9628d228b211a6919c9dd65cd7a9b4fcf6b0 /sl0ppij/zellij-server/src/lib.rs
parentchore: initialize sl0ppi monorepo (diff)
downloadsl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.gz
sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.xz
sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.zip
vendor(sl0ppij): import zellij v0.45.0 tree
Squashed import of upstream zellij at 0e6e4404 ("Support the Kitty Image Protocol (#5428)"), the merge-base our fork was developed against. Upstream's 3311 commits are deliberately NOT replayed here; only our own fork commits are, so the monorepo stays small and `git log sl0ppij/` shows sl0p work, not zellij's entire history. Upstream provenance is tracked in FORKS.md and can always be re-fetched from https://github.com/zellij-org/zellij.
Diffstat (limited to 'sl0ppij/zellij-server/src/lib.rs')
-rw-r--r--sl0ppij/zellij-server/src/lib.rs2562
1 files changed, 2562 insertions, 0 deletions
diff --git a/sl0ppij/zellij-server/src/lib.rs b/sl0ppij/zellij-server/src/lib.rs
new file mode 100644
index 0000000..daaa938
--- /dev/null
+++ b/sl0ppij/zellij-server/src/lib.rs
@@ -0,0 +1,2562 @@
+#[cfg(not(windows))]
+#[path = "os_input_output_unix.rs"]
+mod os_input_output_unix;
+#[cfg(windows)]
+#[path = "os_input_output_windows.rs"]
+mod os_input_output_windows;
+
+pub mod host_query;
+pub mod os_input_output;
+pub mod output;
+pub mod panes;
+pub mod tab;
+
+pub mod background_jobs;
+mod global_async_runtime;
+mod logging_pipe;
+mod mobile_mode;
+pub mod nested_guest;
+mod pane_groups;
+mod plugins;
+mod pty;
+mod pty_writer;
+mod route;
+mod screen;
+mod session_layout_metadata;
+mod terminal_bytes;
+mod thread_bus;
+mod ui;
+
+use background_jobs::{background_jobs_main, BackgroundJob};
+use log::info;
+use pty_writer::{pty_writer_main, PtyWriteInstruction};
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::{
+ net::{IpAddr, Ipv4Addr},
+ path::{Path, PathBuf},
+ sync::{Arc, RwLock},
+ thread,
+};
+use zellij_utils::envs;
+use zellij_utils::pane_size::Size;
+
+use zellij_utils::input::cli_assets::CliAssets;
+use zellij_utils::input::options::PaneFrameStyle;
+
+use wasmi::Engine;
+
+use crate::{
+ os_input_output::ServerOsApi,
+ panes::PaneId,
+ plugins::{plugin_thread_main, PluginInstruction},
+ pty::{get_default_shell, pty_thread_main, Pty, PtyInstruction},
+ screen::{screen_thread_main, ScreenInstruction},
+ thread_bus::{Bus, ThreadSenders},
+};
+use route::{route_thread_main, NotificationEnd};
+use zellij_utils::{
+ channels::{self, ChannelWithContext, SenderWithContext},
+ consts::{
+ DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE, ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE,
+ },
+ data::{
+ ConnectToSession, Direction, InputMode, KeyWithModifier, LayoutInfo, LayoutWithError,
+ Style, WebSharing,
+ },
+ errors::{prelude::*, ContextType, ErrorInstruction, FatalError, ServerContext},
+ home::{default_layout_dir, get_default_data_dir},
+ input::{
+ actions::Action,
+ command::{RunCommand, TerminalAction},
+ config::{watch_config_file_changes, watch_layout_dir_changes, Config},
+ keybinds::Keybinds,
+ layout::{FloatingPaneLayout, Layout, PluginAlias, Run, RunPluginOrAlias},
+ options::Options,
+ plugins::PluginAliases,
+ },
+ ipc::{ClientAttributes, ExitReason, ServerToClientMsg},
+ shared::{default_palette, web_server_base_url},
+};
+
+pub type ClientId = u16;
+
+/// Instructions related to server-side application
+#[derive(Debug, Clone)]
+pub enum ServerInstruction {
+ FirstClientConnected(
+ CliAssets,
+ bool, // is_web_client
+ ClientId,
+ ),
+ Render(Option<HashMap<ClientId, String>>),
+ UnblockInputThread,
+ ClientExit(ClientId, Option<NotificationEnd>),
+ RemoveClient(ClientId),
+ Error(String),
+ KillSession,
+ DetachSession(Vec<ClientId>, Option<NotificationEnd>),
+ AttachClient(
+ CliAssets,
+ Option<usize>, // tab position to focus
+ Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus
+ bool, // is_web_client
+ ClientId,
+ ),
+ AttachWatcherClient(ClientId, Size, bool), // bool -> is_web_client
+ ConnStatus(ClientId),
+ Log(Vec<String>, ClientId, Option<NotificationEnd>),
+ LogError(Vec<String>, ClientId, Option<NotificationEnd>),
+ SwitchSession(ConnectToSession, ClientId, Option<NotificationEnd>),
+ UnblockCliPipeInput(String), // String -> Pipe name
+ CliPipeOutput(String, String), // String -> Pipe name, String -> Output
+ AssociatePipeWithClient {
+ pipe_id: String,
+ client_id: ClientId,
+ },
+ DisconnectAllClientsExcept(ClientId),
+ ChangeMode(ClientId, InputMode, Option<NotificationEnd>),
+ ChangeModeForAllClients(InputMode),
+ Reconfigure {
+ client_id: ClientId,
+ config: String,
+ write_config_to_disk: bool,
+ },
+ ConfigWrittenToDisk(Config),
+ FailedToWriteConfigToDisk(ClientId, Option<PathBuf>), // Pathbuf - file we failed to write
+ RebindKeys {
+ client_id: ClientId,
+ keys_to_rebind: Vec<(InputMode, KeyWithModifier, Vec<Action>)>,
+ keys_to_unbind: Vec<(InputMode, KeyWithModifier)>,
+ write_config_to_disk: bool,
+ },
+ StartWebServer(ClientId),
+ ShareCurrentSession(ClientId),
+ StopSharingCurrentSession(ClientId),
+ SendWebClientsForbidden(ClientId),
+ WebServerStarted(String), // String -> base_url
+ FailedToStartWebServer(String),
+ ClearMouseHelpText(ClientId),
+ /// Relay a forwarded-query dispatch from Screen to the server main
+ /// loop. The main loop writes `ServerToClientMsg::ForwardQueryToHost`
+ /// to any connected regular client.
+ ForwardQueryToHost(u32, Vec<u8>),
+ KeyPassthroughChanged(ClientId, PaneId, PaneId, bool, Option<Direction>, bool),
+ EmitNestedSessionFrameToClient(ClientId, Vec<u8>),
+}
+
+impl From<&ServerInstruction> for ServerContext {
+ fn from(server_instruction: &ServerInstruction) -> Self {
+ match *server_instruction {
+ ServerInstruction::FirstClientConnected(..) => ServerContext::NewClient,
+ ServerInstruction::Render(..) => ServerContext::Render,
+ ServerInstruction::UnblockInputThread => ServerContext::UnblockInputThread,
+ ServerInstruction::ClientExit(..) => ServerContext::ClientExit,
+ ServerInstruction::RemoveClient(..) => ServerContext::RemoveClient,
+ ServerInstruction::Error(_) => ServerContext::Error,
+ ServerInstruction::KillSession => ServerContext::KillSession,
+ ServerInstruction::DetachSession(..) => ServerContext::DetachSession,
+ ServerInstruction::AttachClient(..) => ServerContext::AttachClient,
+ ServerInstruction::AttachWatcherClient(..) => ServerContext::AttachClient,
+ ServerInstruction::ConnStatus(..) => ServerContext::ConnStatus,
+ ServerInstruction::Log(..) => ServerContext::Log,
+ ServerInstruction::LogError(..) => ServerContext::LogError,
+ ServerInstruction::SwitchSession(..) => ServerContext::SwitchSession,
+ ServerInstruction::UnblockCliPipeInput(..) => ServerContext::UnblockCliPipeInput,
+ ServerInstruction::CliPipeOutput(..) => ServerContext::CliPipeOutput,
+ ServerInstruction::AssociatePipeWithClient { .. } => {
+ ServerContext::AssociatePipeWithClient
+ },
+ ServerInstruction::DisconnectAllClientsExcept(..) => {
+ ServerContext::DisconnectAllClientsExcept
+ },
+ ServerInstruction::ChangeMode(..) => ServerContext::ChangeMode,
+ ServerInstruction::ChangeModeForAllClients(..) => {
+ ServerContext::ChangeModeForAllClients
+ },
+ ServerInstruction::Reconfigure { .. } => ServerContext::Reconfigure,
+ ServerInstruction::FailedToWriteConfigToDisk(..) => {
+ ServerContext::FailedToWriteConfigToDisk
+ },
+ ServerInstruction::RebindKeys { .. } => ServerContext::RebindKeys,
+ ServerInstruction::StartWebServer(..) => ServerContext::StartWebServer,
+ ServerInstruction::ShareCurrentSession(..) => ServerContext::ShareCurrentSession,
+ ServerInstruction::StopSharingCurrentSession(..) => {
+ ServerContext::StopSharingCurrentSession
+ },
+ ServerInstruction::WebServerStarted(..) => ServerContext::WebServerStarted,
+ ServerInstruction::FailedToStartWebServer(..) => ServerContext::FailedToStartWebServer,
+ ServerInstruction::ConfigWrittenToDisk(..) => ServerContext::ConfigWrittenToDisk,
+ ServerInstruction::SendWebClientsForbidden(..) => {
+ ServerContext::SendWebClientsForbidden
+ },
+ ServerInstruction::ClearMouseHelpText(..) => ServerContext::ClearMouseHelpText,
+ ServerInstruction::ForwardQueryToHost(..) => ServerContext::ForwardQueryToHost,
+ ServerInstruction::KeyPassthroughChanged(..) => ServerContext::KeyPassthroughChanged,
+ ServerInstruction::EmitNestedSessionFrameToClient(..) => {
+ ServerContext::EmitNestedSessionFrameToClient
+ },
+ }
+ }
+}
+
+impl ErrorInstruction for ServerInstruction {
+ fn error(err: String) -> Self {
+ ServerInstruction::Error(err)
+ }
+}
+
+#[derive(Debug, Clone, Default)]
+pub(crate) struct SessionConfiguration {
+ runtime_config: HashMap<ClientId, Config>, // if present, overrides the saved_config
+ saved_config: Config, // the config as it is on disk (not guaranteed),
+ // when changed, this resets the runtime config to
+ // be identical to it and override any previous
+ // changes
+}
+
+impl SessionConfiguration {
+ pub fn change_saved_config(&mut self, new_saved_config: Config) -> Vec<(ClientId, Config)> {
+ self.saved_config = new_saved_config.clone();
+
+ let mut config_changes = vec![];
+ for (client_id, current_runtime_config) in self.runtime_config.iter_mut() {
+ if *current_runtime_config != new_saved_config {
+ *current_runtime_config = new_saved_config.clone();
+ config_changes.push((*client_id, new_saved_config.clone()))
+ }
+ }
+ config_changes
+ }
+ pub fn set_saved_configuration(&mut self, config: Config) {
+ self.saved_config = config;
+ }
+ pub fn set_client_runtime_configuration(&mut self, client_id: ClientId, client_config: Config) {
+ self.runtime_config.insert(client_id, client_config);
+ }
+ pub fn get_client_keybinds(&self, client_id: &ClientId) -> &Keybinds {
+ self.runtime_config
+ .get(client_id)
+ .map(|c| &c.keybinds)
+ .unwrap_or(&self.saved_config.keybinds)
+ }
+ pub fn get_client_default_input_mode(&self, client_id: &ClientId) -> InputMode {
+ self.runtime_config
+ .get(client_id)
+ .or_else(|| Some(&self.saved_config))
+ .and_then(|c| c.options.default_mode.clone())
+ .unwrap_or_default()
+ }
+ pub fn get_client_configuration(&self, client_id: &ClientId) -> Config {
+ self.runtime_config
+ .get(client_id)
+ .or_else(|| Some(&self.saved_config))
+ .cloned()
+ .unwrap_or_default()
+ }
+ pub fn reconfigure_runtime_config(
+ &mut self,
+ client_id: &ClientId,
+ stringified_config: String,
+ ) -> (Option<Config>, bool) {
+ // bool is whether the config changed
+ let mut full_reconfigured_config = None;
+ let mut config_changed = false;
+ let current_client_configuration = self.get_client_configuration(client_id);
+ match Config::from_kdl(
+ &stringified_config,
+ Some(current_client_configuration.clone()),
+ ) {
+ Ok(new_config) => {
+ config_changed = current_client_configuration != new_config;
+ full_reconfigured_config = Some(new_config.clone());
+ self.runtime_config.insert(*client_id, new_config);
+ },
+ Err(e) => {
+ log::error!("Failed to reconfigure runtime config: {}", e);
+ },
+ }
+ (full_reconfigured_config, config_changed)
+ }
+ pub fn rebind_keys(
+ &mut self,
+ client_id: &ClientId,
+ keys_to_rebind: Vec<(InputMode, KeyWithModifier, Vec<Action>)>,
+ keys_to_unbind: Vec<(InputMode, KeyWithModifier)>,
+ ) -> (Option<Config>, bool) {
+ let mut full_reconfigured_config = None;
+ let mut config_changed = false;
+
+ if self.runtime_config.get(client_id).is_none() {
+ self.runtime_config
+ .insert(*client_id, self.saved_config.clone());
+ }
+ match self.runtime_config.get_mut(client_id) {
+ Some(config) => {
+ for (input_mode, key_with_modifier) in keys_to_unbind {
+ let keys_in_mode = config
+ .keybinds
+ .0
+ .entry(input_mode)
+ .or_insert_with(Default::default);
+ let removed = keys_in_mode.remove(&key_with_modifier);
+ if removed.is_some() {
+ config_changed = true;
+ }
+ }
+ for (input_mode, key_with_modifier, actions) in keys_to_rebind {
+ let keys_in_mode = config
+ .keybinds
+ .0
+ .entry(input_mode)
+ .or_insert_with(Default::default);
+ if keys_in_mode.get(&key_with_modifier) != Some(&actions) {
+ config_changed = true;
+ keys_in_mode.insert(key_with_modifier, actions);
+ }
+ }
+ if config_changed {
+ full_reconfigured_config = Some(config.clone());
+ }
+ },
+ None => {
+ log::error!(
+ "Could not find runtime or saved configuration for client, cannot rebind keys"
+ );
+ },
+ }
+
+ (full_reconfigured_config, config_changed)
+ }
+}
+
+pub(crate) struct SessionMetaData {
+ pub senders: ThreadSenders,
+ pub default_shell: Option<TerminalAction>,
+ pub current_input_modes: HashMap<ClientId, InputMode>,
+ pub session_configuration: SessionConfiguration,
+ pub key_passthrough_clients: HashMap<ClientId, PaneId>,
+ pub web_sharing: WebSharing, // this is a special attribute explicitly set on session
+ // initialization because we don't want it to be overridden by
+ // configuration changes, the only way it can be overwritten is by
+ // explicit plugin action
+ screen_thread: Option<thread::JoinHandle<()>>,
+ pty_thread: Option<thread::JoinHandle<()>>,
+ plugin_thread: Option<thread::JoinHandle<()>>,
+ pty_writer_thread: Option<thread::JoinHandle<()>>,
+ background_jobs_thread: Option<thread::JoinHandle<()>>,
+ config_file_path: Option<PathBuf>,
+}
+
+impl SessionMetaData {
+ pub fn get_client_keybinds_and_mode(
+ &self,
+ client_id: &ClientId,
+ ) -> Option<(&Keybinds, &InputMode, InputMode)> {
+ // (keybinds, current_input_mode,
+ // default_input_mode)
+ let client_keybinds = self.session_configuration.get_client_keybinds(client_id);
+ let default_input_mode = self
+ .session_configuration
+ .get_client_default_input_mode(client_id);
+ match self.current_input_modes.get(client_id) {
+ Some(client_input_mode) => {
+ Some((client_keybinds, client_input_mode, default_input_mode))
+ },
+ _ => None,
+ }
+ }
+ pub fn remove_key_passthrough_client(&mut self, client_id: ClientId) {
+ self.remove_key_passthrough_client_with_notify(client_id, true);
+ }
+ pub fn remove_key_passthrough_client_with_notify(
+ &mut self,
+ client_id: ClientId,
+ notify_guest: bool,
+ ) {
+ if let Some(pane_id) = self.key_passthrough_clients.remove(&client_id) {
+ let pane_still_active = self.key_passthrough_clients.values().any(|p| *p == pane_id);
+ if !pane_still_active && notify_guest {
+ if let PaneId::Terminal(terminal_id) = pane_id {
+ let frame = zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::FocusLost,
+ );
+ let _ = self.senders.send_to_pty_writer(PtyWriteInstruction::Write(
+ frame,
+ terminal_id,
+ None,
+ ));
+ }
+ }
+ }
+ }
+ pub fn change_mode_for_all_clients(&mut self, input_mode: InputMode) {
+ let all_clients: Vec<ClientId> = self.current_input_modes.keys().copied().collect();
+ for client_id in all_clients {
+ self.current_input_modes.insert(client_id, input_mode);
+ }
+ }
+ pub fn propagate_configuration_changes(
+ &mut self,
+ config_changes: Vec<(ClientId, Config)>,
+ config_was_written_to_disk: bool,
+ ) {
+ let mut new_plugin_config = None;
+ for (client_id, new_config) in config_changes {
+ if new_plugin_config.is_none() {
+ new_plugin_config = Some(new_config.plugins.clone());
+ }
+
+ self.default_shell = new_config.options.default_shell.as_ref().map(|shell| {
+ TerminalAction::RunCommand(RunCommand {
+ command: shell.clone(),
+ cwd: new_config.options.default_cwd.clone(),
+ use_terminal_title: true,
+ ..Default::default()
+ })
+ });
+ let host_theme_dark = new_config
+ .options
+ .theme_dark
+ .as_ref()
+ .and_then(|name| new_config.theme_config(Some(name)));
+ let host_theme_light = new_config
+ .options
+ .theme_light
+ .as_ref()
+ .and_then(|name| new_config.theme_config(Some(name)));
+ if new_config.options.theme_dark.is_some() && host_theme_dark.is_none() {
+ log::warn!(
+ "theme_dark='{}' not found in themes; auto-theme switch disabled for dark.",
+ new_config.options.theme_dark.as_deref().unwrap_or("?")
+ );
+ }
+ if new_config.options.theme_light.is_some() && host_theme_light.is_none() {
+ log::warn!(
+ "theme_light='{}' not found in themes; auto-theme switch disabled for light.",
+ new_config.options.theme_light.as_deref().unwrap_or("?")
+ );
+ }
+ let pane_frame_style = PaneFrameStyle::from_options(&new_config.options);
+ self.senders
+ .send_to_screen(ScreenInstruction::Reconfigure {
+ client_id,
+ keybinds: new_config.keybinds.clone(),
+ default_mode: new_config
+ .options
+ .default_mode
+ .unwrap_or_else(Default::default),
+ theme: new_config
+ .theme_config(new_config.options.theme.as_ref())
+ .unwrap_or_else(|| default_palette().into()),
+ host_theme_dark,
+ host_theme_light,
+ simplified_ui: new_config.options.simplified_ui.unwrap_or(false),
+ default_shell: new_config.options.default_shell,
+ pane_frame_style,
+ copy_command: new_config.options.copy_command,
+ copy_to_clipboard: new_config.options.copy_clipboard,
+ copy_on_select: new_config.options.copy_on_select.unwrap_or(true),
+ auto_layout: new_config.options.auto_layout.unwrap_or(true),
+ rounded_corners: new_config.ui.pane_frames.rounded_corners,
+ hide_session_name: new_config.ui.pane_frames.hide_session_name,
+ stacked_resize: new_config.options.stacked_resize.unwrap_or(true),
+ stacked_pane_list: new_config.options.stacked_pane_list.unwrap_or(true),
+ default_editor: new_config.options.scrollback_editor.clone(),
+ advanced_mouse_actions: new_config
+ .options
+ .advanced_mouse_actions
+ .unwrap_or(true),
+ mouse_scroll_resize: new_config.options.mouse_scroll_resize.unwrap_or(true),
+ mouse_hover_effects: new_config.options.mouse_hover_effects.unwrap_or(true),
+ visual_bell: new_config.options.visual_bell.unwrap_or(true),
+ focus_follows_mouse: new_config.options.focus_follows_mouse.unwrap_or(false),
+ mouse_click_through: new_config.options.mouse_click_through.unwrap_or(false),
+ nested_session_handling: new_config
+ .options
+ .nested_session_handling
+ .unwrap_or_default(),
+ })
+ .unwrap();
+ self.senders
+ .send_to_plugin(PluginInstruction::Reconfigure {
+ client_id,
+ keybinds: Some(new_config.keybinds),
+ default_mode: new_config.options.default_mode,
+ default_shell: self.default_shell.clone(),
+ layout_dir: new_config.options.layout_dir,
+ was_written_to_disk: config_was_written_to_disk,
+ })
+ .unwrap();
+ self.senders
+ .send_to_pty(PtyInstruction::Reconfigure {
+ client_id,
+ default_editor: new_config.options.scrollback_editor,
+ post_command_discovery_hook: new_config.options.post_command_discovery_hook,
+ })
+ .unwrap();
+ }
+
+ // Detect and notify plugins of configuration changes
+ if config_was_written_to_disk {
+ if let Some(new_plugins) = new_plugin_config {
+ self.senders
+ .send_to_plugin(PluginInstruction::DetectPluginConfigChanges(new_plugins))
+ .unwrap();
+ }
+ }
+ }
+}
+
+impl Drop for SessionMetaData {
+ fn drop(&mut self) {
+ let _ = self.senders.send_to_pty(PtyInstruction::Exit);
+ let _ = self.senders.send_to_screen(ScreenInstruction::Exit);
+ let _ = self.senders.send_to_plugin(PluginInstruction::Exit);
+ let _ = self.senders.send_to_pty_writer(PtyWriteInstruction::Exit);
+ let _ = self.senders.send_to_background_jobs(BackgroundJob::Exit);
+ if let Some(screen_thread) = self.screen_thread.take() {
+ let _ = screen_thread.join();
+ }
+ if let Some(pty_thread) = self.pty_thread.take() {
+ let _ = pty_thread.join();
+ }
+ if let Some(plugin_thread) = self.plugin_thread.take() {
+ let _ = plugin_thread.join();
+ }
+ if let Some(pty_writer_thread) = self.pty_writer_thread.take() {
+ let _ = pty_writer_thread.join();
+ }
+ if let Some(background_jobs_thread) = self.background_jobs_thread.take() {
+ let _ = background_jobs_thread.join();
+ }
+ }
+}
+
+/// Remove a client from session state and synthesize empty
+/// `ForwardedReplyFromHost` instructions for any host-query forwards
+/// that had been dispatched to it. Without this, a client that goes
+/// away while holding an in-flight forward leaves `Screen`'s
+/// `forward_in_flight` flag stuck — the flag is only released by
+/// replies, and no reply will ever come.
+fn remove_client_and_flush_forwards(
+ client_id: ClientId,
+ os_input: &mut Box<dyn ServerOsApi>,
+ session_state: &Arc<RwLock<SessionState>>,
+ session_data: &Arc<RwLock<Option<SessionMetaData>>>,
+) {
+ let _ = os_input.remove_client(client_id);
+ let stuck_tokens = session_state.write().unwrap().remove_client(client_id);
+ if stuck_tokens.is_empty() {
+ return;
+ }
+ if let Some(session) = session_data.read().unwrap().as_ref() {
+ for token in stuck_tokens {
+ let _ = session
+ .senders
+ .send_to_screen(ScreenInstruction::ForwardedReplyFromHost {
+ token,
+ reply_bytes: Vec::new(),
+ });
+ }
+ }
+}
+
+macro_rules! remove_client {
+ ($client_id:expr, $os_input:expr, $session_state:expr, $session_data:expr) => {
+ $crate::remove_client_and_flush_forwards(
+ $client_id,
+ &mut $os_input,
+ &$session_state,
+ &$session_data,
+ );
+ };
+}
+
+macro_rules! remove_watcher {
+ ($client_id:expr, $os_input:expr, $session_state:expr) => {
+ $os_input.remove_client($client_id).unwrap();
+ $session_state.write().unwrap().remove_watcher($client_id);
+ };
+}
+
+macro_rules! send_to_client {
+ ($client_id:expr, $os_input:expr, $msg:expr, $session_state:expr, $session_data:expr) => {
+ let send_to_client_res = $os_input.send_to_client($client_id, $msg);
+ if let Err(e) = send_to_client_res {
+ // Try to recover the message
+ let context = match e.downcast_ref::<ZellijError>() {
+ Some(ZellijError::ClientTooSlow { .. }) => {
+ format!(
+ "client {} is processing server messages too slow",
+ $client_id
+ )
+ },
+ _ => {
+ format!("failed to route server message to client {}", $client_id)
+ },
+ };
+ // Log it so it isn't lost
+ Err::<(), _>(e).context(context).non_fatal();
+ // failed to send to client, remove it
+ remove_client!($client_id, $os_input, $session_state, $session_data);
+ }
+ };
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct SessionState {
+ clients: HashMap<ClientId, Option<(Size, bool)>>, // bool -> is_web_client
+ pipes: HashMap<String, ClientId>, // String => pipe_id
+ watchers: HashMap<ClientId, bool>, // watcher clients (read-only observers) bool -> is_web_client
+ last_active_client: Option<ClientId>, // last client that sent a Key message
+ /// Host-query forward tokens that have been dispatched to a
+ /// specific client and are waiting for a reply. Used to clean up
+ /// when that client disconnects (or when there's no client to
+ /// dispatch to in the first place) — each stuck token gets an
+ /// empty synthetic reply so `Screen`'s `forward_in_flight` slot
+ /// releases and the queued forwards keep moving.
+ forwards_in_flight: HashMap<u32, ClientId>,
+}
+
+impl SessionState {
+ pub fn new() -> Self {
+ SessionState {
+ clients: HashMap::new(),
+ pipes: HashMap::new(),
+ watchers: HashMap::new(),
+ last_active_client: None,
+ forwards_in_flight: HashMap::new(),
+ }
+ }
+ pub fn new_client(&mut self) -> ClientId {
+ let all_ids: HashSet<ClientId> = self
+ .clients
+ .keys()
+ .copied()
+ .chain(self.watchers.keys().copied())
+ .collect();
+
+ let mut next_client_id = 1;
+ loop {
+ if all_ids.contains(&next_client_id) {
+ next_client_id += 1;
+ } else {
+ break;
+ }
+ }
+ self.clients.insert(next_client_id, None);
+ next_client_id
+ }
+ pub fn associate_pipe_with_client(&mut self, pipe_id: String, client_id: ClientId) {
+ self.pipes.insert(pipe_id, client_id);
+ }
+ /// Remove a client and return any host-query tokens that had
+ /// been dispatched to this client and were still awaiting a
+ /// reply. Callers must synthesize an empty reply for each token
+ /// (via `ScreenInstruction::ForwardedReplyFromHost`) so
+ /// `Screen`'s in-flight slot releases and any queued forwards
+ /// can proceed.
+ pub fn remove_client(&mut self, client_id: ClientId) -> Vec<u32> {
+ self.clients.remove(&client_id);
+ self.pipes.retain(|_p_id, c_id| c_id != &client_id);
+ self.clear_last_active_client(client_id);
+ let stuck: Vec<u32> = self
+ .forwards_in_flight
+ .iter()
+ .filter_map(|(token, owner)| (*owner == client_id).then_some(*token))
+ .collect();
+ for token in &stuck {
+ self.forwards_in_flight.remove(token);
+ }
+ stuck
+ }
+ pub fn set_client_size(&mut self, client_id: ClientId, size: Size) {
+ self.clients
+ .entry(client_id)
+ .or_insert_with(Default::default)
+ .as_mut()
+ .map(|(s, _is_web_client)| *s = size);
+ }
+ pub fn set_client_data(&mut self, client_id: ClientId, size: Size, is_web_client: bool) {
+ self.clients.insert(client_id, Some((size, is_web_client)));
+ }
+ pub fn client_ids(&self) -> Vec<ClientId> {
+ self.clients.keys().copied().collect()
+ }
+ pub fn watcher_client_ids(&self) -> Vec<ClientId> {
+ self.watchers.keys().copied().collect()
+ }
+ pub fn web_client_ids(&self) -> Vec<ClientId> {
+ self.clients
+ .iter()
+ .filter_map(|(c_id, size_and_is_web_client)| {
+ size_and_is_web_client
+ .and_then(|(_s, is_web_client)| if is_web_client { Some(*c_id) } else { None })
+ })
+ .collect()
+ }
+ pub fn web_watcher_client_ids(&self) -> Vec<ClientId> {
+ self.watchers
+ .iter()
+ .filter_map(
+ |(&c_id, &is_web_client)| {
+ if is_web_client {
+ Some(c_id)
+ } else {
+ None
+ }
+ },
+ )
+ .collect()
+ }
+ pub fn get_pipe(&self, pipe_name: &str) -> Option<ClientId> {
+ self.pipes.get(pipe_name).copied()
+ }
+ pub fn active_clients_are_connected(&self) -> bool {
+ let ids_of_pipe_clients: HashSet<ClientId> = self.pipes.values().copied().collect();
+ let mut active_clients_connected = false;
+ for client_id in self.clients.keys() {
+ if ids_of_pipe_clients.contains(client_id) {
+ continue;
+ }
+ active_clients_connected = true;
+ }
+ active_clients_connected
+ }
+ pub fn convert_client_to_watcher(&mut self, client_id: ClientId, is_web_client: bool) {
+ self.clients.remove(&client_id);
+ self.watchers.insert(client_id, is_web_client);
+ }
+ pub fn is_watcher(&self, client_id: &ClientId) -> bool {
+ self.watchers.get(client_id).is_some()
+ }
+ pub fn remove_watcher(&mut self, client_id: ClientId) {
+ self.watchers.remove(&client_id);
+ }
+ pub fn set_last_active_client(&mut self, client_id: ClientId) {
+ self.last_active_client = Some(client_id);
+ }
+ pub fn get_last_active_client(&self) -> Option<ClientId> {
+ self.last_active_client
+ }
+ pub fn clear_last_active_client(&mut self, client_id: ClientId) {
+ if self.last_active_client == Some(client_id) {
+ self.last_active_client = None;
+ }
+ }
+ pub fn mark_forward_in_flight(&mut self, token: u32, client_id: ClientId) {
+ self.forwards_in_flight.insert(token, client_id);
+ }
+ pub fn clear_forward_in_flight(&mut self, token: u32) {
+ self.forwards_in_flight.remove(&token);
+ }
+ /// Client to route a host-query forward to. Prefers whichever
+ /// client most recently interacted with the session; falls back
+ /// to any currently-connected non-watcher client. Returns `None`
+ /// only when no regular client is connected.
+ pub fn pick_forward_target(&self) -> Option<ClientId> {
+ if let Some(candidate) = self.last_active_client {
+ if self.clients.contains_key(&candidate) {
+ return Some(candidate);
+ }
+ }
+ self.clients.keys().copied().next()
+ }
+}
+
+#[cfg(test)]
+mod session_state_tests {
+ use super::*;
+
+ fn with_client(id: ClientId) -> SessionState {
+ let mut s = SessionState::new();
+ s.clients.insert(id, None);
+ s
+ }
+
+ #[test]
+ fn pick_forward_target_prefers_last_active_when_still_connected() {
+ let mut s = SessionState::new();
+ s.clients.insert(1, None);
+ s.clients.insert(2, None);
+ s.set_last_active_client(2);
+ assert_eq!(s.pick_forward_target(), Some(2));
+ }
+
+ #[test]
+ fn pick_forward_target_falls_back_when_last_active_disconnected() {
+ let mut s = SessionState::new();
+ s.clients.insert(1, None);
+ s.clients.insert(2, None);
+ // Client 3 was last active but has since disconnected — not in
+ // `clients` map anymore. Must fall through to any connected
+ // client rather than returning None.
+ s.last_active_client = Some(3);
+ let picked = s
+ .pick_forward_target()
+ .expect("some client still connected");
+ assert!(picked == 1 || picked == 2);
+ }
+
+ #[test]
+ fn pick_forward_target_none_when_no_clients() {
+ let s = SessionState::new();
+ assert_eq!(s.pick_forward_target(), None);
+ }
+
+ #[test]
+ fn remove_client_returns_stuck_forward_tokens() {
+ let mut s = with_client(1);
+ s.mark_forward_in_flight(10, 1);
+ s.mark_forward_in_flight(11, 1);
+ // A forward dispatched to a *different* client — must not be
+ // returned when we remove client 1.
+ s.clients.insert(2, None);
+ s.mark_forward_in_flight(12, 2);
+
+ let mut stuck = s.remove_client(1);
+ stuck.sort_unstable();
+ assert_eq!(stuck, vec![10, 11]);
+ assert_eq!(s.forwards_in_flight.get(&12), Some(&2));
+ }
+
+ #[test]
+ fn remove_client_returns_empty_when_no_forwards_in_flight() {
+ let mut s = with_client(1);
+ assert!(s.remove_client(1).is_empty());
+ }
+
+ #[test]
+ fn clear_forward_in_flight_removes_entry() {
+ let mut s = with_client(1);
+ s.mark_forward_in_flight(42, 1);
+ s.clear_forward_in_flight(42);
+ // After clear, removing the client yields no stuck tokens.
+ assert!(s.remove_client(1).is_empty());
+ }
+}
+
+pub fn start_server(os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
+ info!("Starting Zellij server!");
+
+ #[cfg(unix)]
+ {
+ use nix::sys::stat::{umask, Mode};
+ // preserve the current umask: read current value by setting to another mode, and then restoring it
+ let current_umask = umask(Mode::all());
+ umask(current_umask);
+ daemonize::Daemonize::new()
+ .working_directory(std::env::current_dir().unwrap())
+ .umask(current_umask.bits() as u32)
+ .start()
+ .expect("could not daemonize the server process");
+ }
+
+ #[cfg(windows)]
+ {
+ // The server is spawned with CREATE_NEW_PROCESS_GROUP, which disables
+ // Ctrl+C handling for the process. Child processes inherit this
+ // disabled state, so ConPTY children (shells, commands) would silently
+ // ignore CTRL_C_EVENT signals. Re-enable Ctrl+C here so that
+ // descendants get the normal default handler (terminate on Ctrl+C).
+ //
+ use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
+ unsafe {
+ SetConsoleCtrlHandler(None, 0);
+ }
+ }
+
+ start_server_impl(os_input, socket_path, true);
+}
+
+pub fn start_server_impl(
+ mut os_input: Box<dyn ServerOsApi>,
+ socket_path: PathBuf,
+ install_panic_hook: bool,
+) {
+ envs::set_zellij("0".to_string());
+
+ let (to_server, server_receiver): ChannelWithContext<ServerInstruction> = channels::bounded(50);
+ let to_server = SenderWithContext::new(to_server);
+ let session_data: Arc<RwLock<Option<SessionMetaData>>> = Arc::new(RwLock::new(None));
+ let session_state = Arc::new(RwLock::new(SessionState::new()));
+
+ if install_panic_hook {
+ std::panic::set_hook({
+ use zellij_utils::errors::handle_panic;
+ let to_server = to_server.clone();
+ Box::new(move |info| {
+ handle_panic(info, Some(&to_server));
+ })
+ });
+ }
+
+ let _ = thread::Builder::new()
+ .name("server_listener".to_string())
+ .spawn({
+ use interprocess::local_socket::prelude::*;
+ use zellij_utils::consts::ipc_bind;
+ #[cfg(unix)]
+ use zellij_utils::shared::set_permissions;
+
+ let os_input = os_input.clone();
+ let session_data = session_data.clone();
+ let session_state = session_state.clone();
+ let to_server = to_server.clone();
+ let socket_path = socket_path.clone();
+ move || {
+ drop(std::fs::remove_file(&socket_path));
+ let listener = ipc_bind(&socket_path).unwrap();
+ // set the sticky bit to avoid the socket file being potentially cleaned up
+ // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html states that for XDG_RUNTIME_DIR:
+ // "To ensure that your files are not removed, they should have their access time timestamp modified at least once every 6 hours of monotonic time or the 'sticky' bit should be set on the file. "
+ // It is not guaranteed that all platforms allow setting the sticky bit on sockets!
+ #[cfg(unix)]
+ drop(set_permissions(&socket_path, 0o1700));
+
+ // On Windows, named pipes are half-duplex, so we need a separate
+ // reply pipe for server→client messages.
+ #[cfg(windows)]
+ let reply_listener = zellij_utils::consts::ipc_bind_reply(&socket_path).unwrap();
+
+ for stream in listener.incoming() {
+ match stream {
+ Ok(stream) => {
+ let mut os_input = os_input.clone();
+ let client_id = session_state.write().unwrap().new_client();
+
+ #[cfg(windows)]
+ let reply_stream = reply_listener
+ .accept()
+ .expect("failed to accept reply connection");
+
+ #[cfg(windows)]
+ let receiver = os_input
+ .new_client_with_reply(client_id, stream, reply_stream)
+ .unwrap();
+ #[cfg(not(windows))]
+ let receiver = os_input.new_client(client_id, stream).unwrap();
+
+ let session_data = session_data.clone();
+ let session_state = session_state.clone();
+ let to_server = to_server.clone();
+ thread::Builder::new()
+ .name("server_router".to_string())
+ .spawn(move || {
+ route_thread_main(
+ session_data,
+ session_state,
+ os_input,
+ to_server,
+ receiver,
+ client_id,
+ )
+ .fatal()
+ })
+ .unwrap();
+ },
+ Err(err) => {
+ panic!("err {:?}", err);
+ },
+ }
+ }
+ }
+ });
+
+ loop {
+ let (instruction, mut err_ctx) = server_receiver.recv().unwrap();
+ err_ctx.add_call(ContextType::IPCServer((&instruction).into()));
+ match instruction {
+ ServerInstruction::FirstClientConnected(cli_assets, is_web_client, client_id) => {
+ let (config, layout) = cli_assets.load_config_and_layout();
+ let layout_is_welcome_screen = cli_assets.layout
+ == Some(LayoutInfo::BuiltIn("welcome".to_owned()))
+ || config.options.default_layout == Some(PathBuf::from("welcome"));
+
+ let successfully_written_config = Config::write_config_to_disk_if_it_does_not_exist(
+ config.to_string(true),
+ &cli_assets.config_file_path,
+ );
+ // if we successfully wrote the config to disk, it means two things:
+ // 1. It did not exist beforehand
+ // 2. The config folder is writeable
+ //
+ // If these two are true, we should launch the setup wizard, if even one of them is
+ // false, we should never launch it.
+ let should_launch_setup_wizard = successfully_written_config;
+
+ let runtime_config_options = match &cli_assets.configuration_options {
+ Some(configuration_options) => {
+ config.options.merge(configuration_options.clone())
+ },
+ None => config.options.clone(),
+ };
+
+ let client_attributes = ClientAttributes {
+ size: cli_assets.terminal_window_size,
+ style: Style {
+ colors: config
+ .theme_config(runtime_config_options.theme.as_ref())
+ .unwrap_or_else(|| default_palette().into()),
+ rounded_corners: config.ui.pane_frames.rounded_corners,
+ hide_session_name: config.ui.pane_frames.hide_session_name,
+ },
+ };
+
+ info!("FirstClientConnected: initializing session");
+ let mut session = init_session(
+ os_input.clone(),
+ to_server.clone(),
+ client_attributes.clone(),
+ Box::new(runtime_config_options.clone()), // TODO: no box
+ Box::new(layout.clone()), // TODO: no box
+ cli_assets.clone(),
+ config.clone(),
+ config.plugins.clone(),
+ client_id,
+ );
+ info!("FirstClientConnected: session initialized, spawning tabs");
+ let mut runtime_configuration = config.clone();
+ runtime_configuration.options = runtime_config_options.clone();
+ session
+ .session_configuration
+ .set_saved_configuration(config.clone());
+ session
+ .session_configuration
+ .set_client_runtime_configuration(client_id, runtime_configuration);
+ let default_input_mode = runtime_config_options.default_mode.unwrap_or_default();
+ session
+ .current_input_modes
+ .insert(client_id, default_input_mode);
+
+ *session_data.write().unwrap() = Some(session);
+ session_state.write().unwrap().set_client_data(
+ client_id,
+ client_attributes.size,
+ is_web_client,
+ );
+
+ let should_enter_mobile = should_enter_mobile_on_connect(
+ &runtime_config_options,
+ is_web_client,
+ client_attributes.size,
+ );
+ if should_enter_mobile {
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
+ .unwrap();
+ }
+
+ let default_shell = runtime_config_options.default_shell.map(|shell| {
+ TerminalAction::RunCommand(RunCommand {
+ command: shell,
+ cwd: config.options.default_cwd.clone(),
+ use_terminal_title: true,
+ ..Default::default()
+ })
+ });
+ let cwd = cli_assets
+ .cwd
+ .or_else(|| runtime_config_options.default_cwd);
+
+ let spawn_tabs = |tab_layout,
+ floating_panes_layout,
+ tab_name,
+ swap_layouts,
+ should_focus_tab| {
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::NewTab(
+ cwd.clone(),
+ default_shell.clone(),
+ tab_layout,
+ floating_panes_layout,
+ tab_name,
+ swap_layouts,
+ None, // initial_panes
+ false, // block_on_first_terminal
+ should_focus_tab,
+ (client_id, is_web_client),
+ None,
+ ))
+ .unwrap()
+ };
+
+ if layout.has_tabs() {
+ let focused_tab_index = layout.focused_tab_index().unwrap_or(0);
+ for (tab_index, (tab_name, tab_layout, floating_panes_layout)) in
+ layout.tabs().into_iter().enumerate()
+ {
+ let should_focus_tab = tab_index == focused_tab_index;
+ spawn_tabs(
+ Some(tab_layout.clone()),
+ floating_panes_layout.clone(),
+ tab_name,
+ (
+ Some(layout.swap_tiled_layouts.clone()),
+ Some(layout.swap_floating_layouts.clone()),
+ ),
+ should_focus_tab,
+ );
+ }
+ } else {
+ let mut floating_panes =
+ layout.template.map(|t| t.1).clone().unwrap_or_default();
+ if should_launch_setup_wizard {
+ // we only do this here (and only once) because otherwise it will be
+ // intrusive
+ let setup_wizard = setup_wizard_floating_pane();
+ floating_panes.push(setup_wizard);
+ } else if should_show_release_notes(
+ runtime_config_options.show_release_notes,
+ layout_is_welcome_screen,
+ ) {
+ let about = about_floating_pane();
+ floating_panes.push(about);
+ } else if should_show_startup_tip(
+ runtime_config_options.show_startup_tips,
+ layout_is_welcome_screen,
+ ) {
+ let tip = tip_floating_pane();
+ floating_panes.push(tip);
+ }
+ spawn_tabs(
+ None,
+ floating_panes,
+ None,
+ (
+ Some(layout.swap_tiled_layouts.clone()),
+ Some(layout.swap_floating_layouts.clone()),
+ ),
+ true,
+ );
+ }
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::AddClient(client_id))
+ .unwrap();
+
+ if should_enter_mobile {
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
+ .unwrap();
+ }
+ },
+ ServerInstruction::AttachClient(
+ cli_assets,
+ tab_position_to_focus,
+ pane_id_to_focus,
+ is_web_client,
+ client_id,
+ ) => {
+ let mut rlock = session_data.write().unwrap();
+ let session_data = rlock.as_mut().unwrap();
+ let config = session_data.session_configuration.saved_config.clone();
+ let runtime_config_options = match cli_assets.configuration_options {
+ Some(configuration_options) => config.options.merge(configuration_options),
+ None => config.options.clone(),
+ };
+
+ let client_attributes = ClientAttributes {
+ size: cli_assets.terminal_window_size,
+ style: Style {
+ colors: config
+ .theme_config(runtime_config_options.theme.as_ref())
+ .unwrap_or_else(|| default_palette().into()),
+ rounded_corners: config.ui.pane_frames.rounded_corners,
+ hide_session_name: config.ui.pane_frames.hide_session_name,
+ },
+ };
+
+ let mut runtime_configuration = config.clone();
+ runtime_configuration.options = runtime_config_options.clone();
+ session_data
+ .session_configuration
+ .set_client_runtime_configuration(client_id, runtime_configuration);
+
+ let default_input_mode = config.options.default_mode.unwrap_or_default();
+ session_data
+ .current_input_modes
+ .insert(client_id, default_input_mode);
+
+ session_state.write().unwrap().set_client_data(
+ client_id,
+ client_attributes.size,
+ is_web_client,
+ );
+
+ let should_enter_mobile = should_enter_mobile_on_connect(
+ &runtime_config_options,
+ is_web_client,
+ client_attributes.size,
+ );
+ if should_enter_mobile {
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
+ .unwrap();
+ }
+
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::AddClient(
+ client_id,
+ is_web_client,
+ client_attributes.size,
+ tab_position_to_focus,
+ pane_id_to_focus,
+ ))
+ .unwrap();
+ session_data
+ .senders
+ .send_to_plugin(PluginInstruction::AddClient(client_id))
+ .unwrap();
+ let default_mode = config.options.default_mode.unwrap_or_default();
+ // ModeUpdate broadcast is handled by the screen thread via
+ // change_mode() -> update_input_modes()
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::ChangeMode(
+ default_mode,
+ Some(default_mode),
+ client_id,
+ None,
+ ))
+ .unwrap();
+
+ if should_enter_mobile {
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
+ .unwrap();
+ }
+ },
+ ServerInstruction::AttachWatcherClient(client_id, terminal_size, is_web_client) => {
+ // the client_id was inserted into clients upon ipc tunnel initialization
+ // now that it identified itself as a watcher, we need to convert it
+
+ // Convert to watcher in SessionState (needed for input filtering in route.rs)
+ session_state
+ .write()
+ .unwrap()
+ .convert_client_to_watcher(client_id, is_web_client);
+
+ // Also notify Screen to add this as a watcher client (for rendering) with the terminal size
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::AddWatcherClient(
+ client_id,
+ terminal_size,
+ ))
+ .unwrap();
+ },
+ ServerInstruction::UnblockInputThread => {
+ let client_ids = session_state.read().unwrap().client_ids();
+ for client_id in client_ids {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::UnblockInputThread,
+ session_state,
+ session_data
+ );
+ }
+ },
+ ServerInstruction::UnblockCliPipeInput(pipe_name) => {
+ let pipe = session_state.read().unwrap().get_pipe(&pipe_name);
+ match pipe {
+ Some(client_id) => {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::UnblockCliPipeInput {
+ pipe_name: pipe_name.clone()
+ },
+ session_state,
+ session_data
+ );
+ },
+ None => {
+ // send to all clients, this pipe might not have been associated yet
+ let client_ids = session_state.read().unwrap().client_ids();
+ for client_id in client_ids {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::UnblockCliPipeInput {
+ pipe_name: pipe_name.clone()
+ },
+ session_state,
+ session_data
+ );
+ }
+ },
+ }
+ },
+ ServerInstruction::CliPipeOutput(pipe_name, output) => {
+ let pipe = session_state.read().unwrap().get_pipe(&pipe_name);
+ match pipe {
+ Some(client_id) => {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::CliPipeOutput {
+ pipe_name: pipe_name.clone(),
+ output: output.clone()
+ },
+ session_state,
+ session_data
+ );
+ },
+ None => {
+ // send to all clients, this pipe might not have been associated yet
+ let client_ids = session_state.read().unwrap().client_ids();
+ for client_id in client_ids {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::CliPipeOutput {
+ pipe_name: pipe_name.clone(),
+ output: output.clone()
+ },
+ session_state,
+ session_data
+ );
+ }
+ },
+ }
+ },
+ ServerInstruction::ClientExit(client_id, completion_tx) => {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ },
+ );
+
+ // Check if this is a watcher
+ let is_watcher = session_state.read().unwrap().is_watcher(&client_id);
+ if is_watcher {
+ // Remove from SessionState watchers set
+ session_state.write().unwrap().remove_watcher(client_id);
+
+ // Also notify Screen to remove watcher
+ if let Some(session_data) = session_data.write().unwrap().as_ref() {
+ let _ = session_data
+ .senders
+ .send_to_screen(ScreenInstruction::RemoveWatcherClient(client_id));
+ }
+
+ os_input.remove_client(client_id).unwrap();
+ } else {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(client_id);
+ }
+ // Handle regular client removal
+ remove_client!(client_id, os_input, session_state, session_data);
+ drop(completion_tx); // prevent deadlock with route thread
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::RemoveClient(client_id))
+ .unwrap();
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::RemoveClient(client_id))
+ .unwrap();
+ if !session_state.read().unwrap().active_clients_are_connected() {
+ *session_data.write().unwrap() = None;
+ let client_ids_to_cleanup: Vec<ClientId> = session_state
+ .read()
+ .unwrap()
+ .clients
+ .keys()
+ .copied()
+ .collect();
+ // these are just the pipes
+ for client_id in client_ids_to_cleanup {
+ remove_client!(client_id, os_input, session_state, session_data);
+ }
+
+ let watcher_client_ids: Vec<ClientId> =
+ session_state.read().unwrap().watcher_client_ids();
+ for watcher_id in watcher_client_ids {
+ let _ = os_input.send_to_client(
+ watcher_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ },
+ );
+ }
+
+ break;
+ }
+ }
+ },
+ ServerInstruction::RemoveClient(client_id) => {
+ // Check if this is a watcher
+ let is_watcher = session_state.read().unwrap().is_watcher(&client_id);
+ if is_watcher {
+ // Remove from SessionState watchers set
+ session_state.write().unwrap().remove_watcher(client_id);
+
+ // Also notify Screen to remove watcher
+ if let Some(session_data) = session_data.write().unwrap().as_ref() {
+ let _ = session_data
+ .senders
+ .send_to_screen(ScreenInstruction::RemoveWatcherClient(client_id));
+ }
+
+ os_input.remove_client(client_id).unwrap();
+ } else {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(client_id);
+ }
+ // Handle regular client removal
+ remove_client!(client_id, os_input, session_state, session_data);
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::RemoveClient(client_id))
+ .unwrap();
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::RemoveClient(client_id))
+ .unwrap();
+ }
+ },
+ ServerInstruction::SendWebClientsForbidden(client_id) => {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::WebClientsForbidden,
+ },
+ );
+ remove_client!(client_id, os_input, session_state, session_data);
+ },
+ ServerInstruction::KillSession => {
+ let client_ids = session_state.read().unwrap().client_ids();
+ for client_id in client_ids {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ },
+ );
+ remove_client!(client_id, os_input, session_state, session_data);
+ }
+ break;
+ },
+ ServerInstruction::DisconnectAllClientsExcept(client_id) => {
+ let client_ids: Vec<ClientId> = session_state
+ .read()
+ .unwrap()
+ .client_ids()
+ .iter()
+ .copied()
+ .filter(|c| c != &client_id)
+ .collect();
+ for client_id in client_ids {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(client_id);
+ }
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::KickedByHost,
+ },
+ );
+ remove_client!(client_id, os_input, session_state, session_data);
+ }
+ },
+ ServerInstruction::DetachSession(client_ids, completion_tx) => {
+ for client_id in &client_ids {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(*client_id);
+ }
+ let _ = os_input.send_to_client(
+ *client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ },
+ );
+ remove_client!(*client_id, os_input, session_state, session_data);
+ }
+ drop(completion_tx); // we do this here explicitly to signal that the clients have
+ // already disconnected and to prevent a deadlock below caused
+ // by us having to wait for session_data to send cleanup
+ // signals to the various threads
+ for client_id in client_ids {
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::RemoveClient(client_id))
+ .unwrap();
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::RemoveClient(client_id))
+ .unwrap();
+ }
+ },
+ ServerInstruction::Render(serialized_output) => {
+ let client_ids = session_state.read().unwrap().client_ids();
+ // If `Some(_)`- unwrap it and forward it to the clients to render.
+ // If `None`- Send an exit instruction. This is the case when a user closes the last Tab/Pane.
+ if let Some(output) = &serialized_output {
+ for (client_id, client_render_instruction) in output.iter() {
+ send_to_client!(
+ *client_id,
+ os_input,
+ ServerToClientMsg::Render {
+ content: client_render_instruction.clone()
+ },
+ session_state,
+ session_data
+ );
+ }
+ } else {
+ // Session is exiting - disconnect all regular clients
+ for client_id in client_ids {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ },
+ );
+ remove_client!(client_id, os_input, session_state, session_data);
+ }
+
+ // Also disconnect all watchers
+ let watcher_ids: Vec<ClientId> = session_state
+ .read()
+ .unwrap()
+ .watchers
+ .keys()
+ .copied()
+ .collect();
+ for watcher_id in watcher_ids {
+ let _ = os_input.send_to_client(
+ watcher_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ },
+ );
+ remove_client!(watcher_id, os_input, session_state, session_data);
+ }
+ break;
+ }
+ },
+ ServerInstruction::Error(backtrace) => {
+ let client_ids = session_state.read().unwrap().client_ids();
+ for client_id in client_ids {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Error(backtrace.clone()),
+ },
+ );
+ remove_client!(client_id, os_input, session_state, session_data);
+ }
+ break;
+ },
+ ServerInstruction::ConnStatus(client_id) => {
+ let _ = os_input.send_to_client(client_id, ServerToClientMsg::Connected);
+ remove_client!(client_id, os_input, session_state, session_data);
+ },
+ ServerInstruction::Log(
+ lines_to_log,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything waiting
+ // for it
+ ) => {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::Log {
+ lines: lines_to_log
+ },
+ session_state,
+ session_data
+ );
+ },
+ ServerInstruction::LogError(
+ lines_to_log,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything waiting
+ // for it
+ ) => {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::LogError {
+ lines: lines_to_log
+ },
+ session_state,
+ session_data
+ );
+ },
+ ServerInstruction::SwitchSession(mut connect_to_session, client_id, completion_tx) => {
+ let current_session_name = envs::get_session_name();
+ if connect_to_session.name == current_session_name.ok() {
+ log::error!("Cannot attach to same session");
+ } else {
+ let layout_dir = session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .session_configuration
+ .get_client_configuration(&client_id)
+ .options
+ .layout_dir
+ .or_else(|| default_layout_dir());
+ if let Some(layout_dir) = layout_dir {
+ connect_to_session.apply_layout_dir(&layout_dir);
+ }
+
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::SwitchSession { connect_to_session },
+ session_state,
+ session_data
+ );
+ remove_client!(client_id, os_input, session_state, session_data);
+ drop(completion_tx); // do not deadlock with route thread
+
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::RemoveClient(client_id))
+ .unwrap();
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::RemoveClient(client_id))
+ .unwrap();
+ }
+ },
+ ServerInstruction::AssociatePipeWithClient { pipe_id, client_id } => {
+ session_state
+ .write()
+ .unwrap()
+ .associate_pipe_with_client(pipe_id, client_id);
+ },
+ ServerInstruction::ChangeMode(client_id, input_mode, completion) => {
+ let mut session_data = session_data.write().unwrap();
+ let session_data = session_data.as_mut().unwrap();
+ let base_mode = session_data
+ .session_configuration
+ .get_client_default_input_mode(&client_id);
+ session_data
+ .current_input_modes
+ .insert(client_id, input_mode);
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::ChangeMode(
+ input_mode,
+ Some(base_mode),
+ client_id,
+ completion,
+ ))
+ .unwrap();
+ },
+ ServerInstruction::ChangeModeForAllClients(input_mode) => {
+ session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .change_mode_for_all_clients(input_mode);
+ },
+ ServerInstruction::Reconfigure {
+ client_id,
+ config,
+ write_config_to_disk,
+ } => {
+ let (new_config, runtime_config_changed) = session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .session_configuration
+ .reconfigure_runtime_config(&client_id, config);
+ update_new_saved_config(
+ new_config,
+ write_config_to_disk,
+ runtime_config_changed,
+ &session_data,
+ client_id,
+ );
+ },
+ ServerInstruction::ConfigWrittenToDisk(new_config) => {
+ let changes = session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .session_configuration
+ .change_saved_config(new_config);
+ let config_was_written_to_disk = true;
+ session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .propagate_configuration_changes(changes, config_was_written_to_disk);
+ let client_ids = session_state.read().unwrap().client_ids();
+ for client_id in client_ids {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::ConfigFileUpdated,
+ session_state,
+ session_data
+ );
+ }
+ },
+ ServerInstruction::FailedToWriteConfigToDisk(_client_id, file_path) => {
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::FailedToWriteConfigToDisk { file_path })
+ .unwrap();
+ },
+ ServerInstruction::RebindKeys {
+ client_id,
+ keys_to_rebind,
+ keys_to_unbind,
+ write_config_to_disk,
+ } => {
+ let (new_config, runtime_config_changed) = session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .session_configuration
+ .rebind_keys(&client_id, keys_to_rebind, keys_to_unbind);
+
+ update_new_saved_config(
+ new_config,
+ write_config_to_disk,
+ runtime_config_changed,
+ &session_data,
+ client_id,
+ );
+ },
+ ServerInstruction::StartWebServer(client_id) => {
+ if cfg!(feature = "web_server_capability") {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::StartWebServer,
+ session_state,
+ session_data
+ );
+ } else {
+ // TODO: test this
+ log::error!("Cannot start web server: this instance of Zellij was compiled without web_server_capability");
+ }
+ },
+ ServerInstruction::ShareCurrentSession(_client_id) => {
+ if cfg!(feature = "web_server_capability") {
+ let successfully_changed = session_data
+ .write()
+ .ok()
+ .and_then(|mut s| s.as_mut().map(|s| s.web_sharing.set_sharing()))
+ .unwrap_or(false);
+ if successfully_changed {
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::SessionSharingStatusChange(true))
+ .unwrap();
+ }
+ } else {
+ log::error!("Cannot share session: this instance of Zellij was compiled without web_server_capability");
+ }
+ },
+ ServerInstruction::StopSharingCurrentSession(_client_id) => {
+ if cfg!(feature = "web_server_capability") {
+ let successfully_changed = session_data
+ .write()
+ .ok()
+ .and_then(|mut s| s.as_mut().map(|s| s.web_sharing.set_not_sharing()))
+ .unwrap_or(false);
+ if successfully_changed {
+ // disconnect existing web clients
+ let web_client_ids: Vec<ClientId> = session_state
+ .read()
+ .unwrap()
+ .web_client_ids()
+ .iter()
+ .copied()
+ .collect();
+ for client_id in web_client_ids {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::WebClientsForbidden,
+ },
+ );
+ remove_client!(client_id, os_input, session_state, session_data);
+ }
+ let web_watcher_client_ids: Vec<ClientId> = session_state
+ .read()
+ .unwrap()
+ .web_watcher_client_ids()
+ .iter()
+ .copied()
+ .collect();
+ for client_id in web_watcher_client_ids {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::WebClientsForbidden,
+ },
+ );
+ remove_watcher!(client_id, os_input, session_state);
+ }
+
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::SessionSharingStatusChange(false))
+ .unwrap();
+ }
+ } else {
+ // TODO: test this
+ log::error!("Cannot start web server: this instance of Zellij was compiled without web_server_capability");
+ }
+ },
+ ServerInstruction::WebServerStarted(base_url) => {
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::WebServerStarted(base_url))
+ .unwrap();
+ },
+ ServerInstruction::FailedToStartWebServer(error) => {
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::FailedToStartWebServer(error))
+ .unwrap();
+ },
+ ServerInstruction::ClearMouseHelpText(client_id) => {
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::ClearMouseHelpText(client_id))
+ .unwrap();
+ },
+ ServerInstruction::ForwardQueryToHost(token, query_bytes) => {
+ // Pick a regular (non-watcher) client to carry the
+ // forward. Preference is the most recently active
+ // client (whichever last sent input); falls back to
+ // any connected client. When the host terminals of
+ // attached clients differ, the recently-active one
+ // is the best proxy for "what the user is currently
+ // looking at".
+ let target_client_id = {
+ let mut session = session_state.write().unwrap();
+ let picked = session.pick_forward_target();
+ if let Some(cid) = picked {
+ session.mark_forward_in_flight(token, cid);
+ }
+ picked
+ };
+ if let Some(client_id) = target_client_id {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::ForwardQueryToHost { token, query_bytes },
+ session_state,
+ session_data
+ );
+ } else {
+ // No client to ask — synthesize an empty reply
+ // so `Screen`'s in-flight slot releases. Without
+ // this, a detached session (apps still running)
+ // accumulates forwards in `forward_queue` with
+ // no way for them to drain until someone
+ // reattaches.
+ log::warn!(
+ "No connected client to forward host query (token={}); returning empty reply",
+ token
+ );
+ if let Some(session) = session_data.read().unwrap().as_ref() {
+ let _ = session.senders.send_to_screen(
+ ScreenInstruction::ForwardedReplyFromHost {
+ token,
+ reply_bytes: Vec::new(),
+ },
+ );
+ }
+ }
+ },
+ ServerInstruction::KeyPassthroughChanged(
+ client_id,
+ _old_pane_id,
+ new_pane_id,
+ should_route,
+ entered_from_direction,
+ notify_guest,
+ ) => {
+ let mut session_data = session_data.write().unwrap();
+ if let Some(session_data) = session_data.as_mut() {
+ let previous_pane = session_data
+ .key_passthrough_clients
+ .get(&client_id)
+ .copied();
+
+ if should_route {
+ if previous_pane != Some(new_pane_id) {
+ if let Some(previous_pane_id) = previous_pane {
+ session_data.key_passthrough_clients.remove(&client_id);
+ let pane_still_active = session_data
+ .key_passthrough_clients
+ .values()
+ .any(|p| *p == previous_pane_id);
+ if !pane_still_active && notify_guest {
+ if let crate::panes::PaneId::Terminal(terminal_id) =
+ previous_pane_id
+ {
+ let frame = zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::FocusLost,
+ );
+ let _ = session_data.senders.send_to_pty_writer(
+ PtyWriteInstruction::Write(frame, terminal_id, None),
+ );
+ }
+ }
+ }
+ let pane_already_active = session_data
+ .key_passthrough_clients
+ .values()
+ .any(|p| *p == new_pane_id);
+ session_data
+ .key_passthrough_clients
+ .insert(client_id, new_pane_id);
+ if !pane_already_active {
+ if let crate::panes::PaneId::Terminal(terminal_id) = new_pane_id {
+ let frame = zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::FocusGained {
+ from_direction: entered_from_direction,
+ },
+ );
+ let _ = session_data.senders.send_to_pty_writer(
+ PtyWriteInstruction::Write(frame, terminal_id, None),
+ );
+ }
+ }
+ }
+ } else if previous_pane.is_some() {
+ session_data
+ .remove_key_passthrough_client_with_notify(client_id, notify_guest);
+ }
+ }
+ },
+ ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload_bytes) => {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::EmitNestedSessionFrame { payload_bytes },
+ session_state,
+ session_data
+ );
+ },
+ }
+ }
+
+ // Drop cached session data before exit.
+ *session_data.write().unwrap() = None;
+
+ drop(std::fs::remove_file(&socket_path));
+}
+
+fn init_session(
+ os_input: Box<dyn ServerOsApi>,
+ to_server: SenderWithContext<ServerInstruction>,
+ client_attributes: ClientAttributes,
+ config_options: Box<Options>,
+ layout: Box<Layout>,
+ cli_assets: CliAssets,
+ mut config: Config,
+ plugin_aliases: PluginAliases,
+ client_id: ClientId,
+) -> SessionMetaData {
+ config.options = config.options.merge(*config_options.clone());
+
+ let _ = SCROLL_BUFFER_SIZE.set(
+ config_options
+ .scroll_buffer_size
+ .unwrap_or(DEFAULT_SCROLL_BUFFER_SIZE),
+ );
+
+ let (to_screen, screen_receiver): ChannelWithContext<ScreenInstruction> = channels::unbounded();
+ let to_screen = SenderWithContext::new(to_screen);
+
+ let (to_screen_bounded, bounded_screen_receiver): ChannelWithContext<ScreenInstruction> =
+ channels::bounded(50);
+ let to_screen_bounded = SenderWithContext::new(to_screen_bounded);
+
+ let (to_plugin, plugin_receiver): ChannelWithContext<PluginInstruction> = channels::unbounded();
+ let to_plugin = SenderWithContext::new(to_plugin);
+ let (to_pty, pty_receiver): ChannelWithContext<PtyInstruction> = channels::unbounded();
+ let to_pty = SenderWithContext::new(to_pty);
+
+ let (to_pty_writer, pty_writer_receiver): ChannelWithContext<PtyWriteInstruction> =
+ channels::unbounded();
+ let to_pty_writer = SenderWithContext::new(to_pty_writer);
+
+ let (to_background_jobs, background_jobs_receiver): ChannelWithContext<BackgroundJob> =
+ channels::unbounded();
+ let to_background_jobs = SenderWithContext::new(to_background_jobs);
+
+ // Determine and initialize the data directory
+ let data_dir = cli_assets.data_dir.unwrap_or_else(get_default_data_dir);
+
+ let serialization_interval = config_options.serialization_interval;
+ let disable_session_metadata = config_options.disable_session_metadata.unwrap_or(false);
+ let web_server_ip = config_options
+ .web_server_ip
+ .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
+ let web_server_port = config_options.web_server_port.unwrap_or_else(|| 8082);
+ let has_certificate =
+ config_options.web_server_cert.is_some() && config_options.web_server_key.is_some();
+ let enforce_https_for_localhost = config_options.enforce_https_for_localhost.unwrap_or(false);
+
+ let default_shell = config_options.default_shell.clone().map(|command| {
+ TerminalAction::RunCommand(RunCommand {
+ command,
+ use_terminal_title: true,
+ ..Default::default()
+ })
+ });
+ let path_to_default_shell = config_options
+ .default_shell
+ .clone()
+ .unwrap_or_else(|| get_default_shell());
+
+ let default_mode = config_options.default_mode.unwrap_or_default();
+ let default_keybinds = config.keybinds.clone();
+
+ let pty_thread = thread::Builder::new()
+ .name("pty".to_string())
+ .spawn({
+ let layout = layout.clone();
+ let pty = Pty::new(
+ Bus::new(
+ vec![pty_receiver],
+ Some(&to_screen_bounded),
+ None,
+ Some(&to_plugin),
+ Some(&to_server),
+ Some(&to_pty_writer),
+ Some(&to_background_jobs),
+ Some(os_input.clone()),
+ ),
+ cli_assets.is_debug,
+ config_options.scrollback_editor.clone(),
+ config_options.post_command_discovery_hook.clone(),
+ );
+
+ move || pty_thread_main(pty, layout.clone()).fatal()
+ })
+ .unwrap();
+
+ let screen_thread = thread::Builder::new()
+ .name("screen".to_string())
+ .spawn({
+ let screen_bus = Bus::new(
+ vec![screen_receiver, bounded_screen_receiver],
+ Some(&to_screen), // there are certain occasions (eg. caching) where the screen
+ // needs to send messages to itself
+ Some(&to_pty),
+ Some(&to_plugin),
+ Some(&to_server),
+ Some(&to_pty_writer),
+ Some(&to_background_jobs),
+ Some(os_input.clone()),
+ );
+ let max_panes = cli_assets.max_panes;
+
+ let client_attributes_clone = client_attributes.clone();
+ let debug = cli_assets.is_debug;
+ let layout = layout.clone();
+ let config = config.clone();
+ move || {
+ screen_thread_main(
+ screen_bus,
+ max_panes,
+ client_attributes_clone,
+ config,
+ debug,
+ layout,
+ )
+ .fatal();
+ }
+ })
+ .unwrap();
+
+ let zellij_cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
+ let session_env_vars: std::collections::BTreeMap<String, String> = std::env::vars().collect();
+
+ let (available_layouts, available_layout_errors) = get_available_layouts(&config_options);
+
+ let plugin_thread = thread::Builder::new()
+ .name("wasm".to_string())
+ .spawn({
+ let plugin_bus = Bus::new(
+ vec![plugin_receiver],
+ Some(&to_screen_bounded),
+ Some(&to_pty),
+ Some(&to_plugin),
+ Some(&to_server),
+ Some(&to_pty_writer),
+ Some(&to_background_jobs),
+ None,
+ );
+ let engine = get_engine();
+
+ let layout = layout.clone();
+ let default_shell = default_shell.clone();
+ let layout_dir = config_options
+ .layout_dir
+ .clone()
+ .or_else(|| default_layout_dir());
+ let background_plugins = config.background_plugins.clone();
+ let session_env_vars = session_env_vars.clone();
+ move || {
+ plugin_thread_main(
+ plugin_bus,
+ engine,
+ data_dir,
+ layout,
+ layout_dir,
+ available_layouts,
+ available_layout_errors,
+ path_to_default_shell,
+ zellij_cwd,
+ session_env_vars,
+ default_shell,
+ plugin_aliases,
+ default_mode,
+ default_keybinds,
+ background_plugins,
+ client_id,
+ )
+ .fatal()
+ }
+ })
+ .unwrap();
+
+ let pty_writer_thread = thread::Builder::new()
+ .name("pty_writer".to_string())
+ .spawn({
+ let pty_writer_bus = Bus::new(
+ vec![pty_writer_receiver],
+ Some(&to_screen),
+ Some(&to_pty),
+ Some(&to_plugin),
+ Some(&to_server),
+ None,
+ Some(&to_background_jobs),
+ Some(os_input.clone()),
+ );
+ || pty_writer_main(pty_writer_bus).fatal()
+ })
+ .unwrap();
+
+ let background_jobs_thread = thread::Builder::new()
+ .name("background_jobs".to_string())
+ .spawn({
+ let background_jobs_bus = Bus::new(
+ vec![background_jobs_receiver],
+ Some(&to_screen),
+ Some(&to_pty),
+ Some(&to_plugin),
+ Some(&to_server),
+ Some(&to_pty_writer),
+ None,
+ Some(os_input.clone()),
+ );
+ let web_server_base_url = web_server_base_url(
+ web_server_ip,
+ web_server_port,
+ has_certificate,
+ enforce_https_for_localhost,
+ );
+ move || {
+ background_jobs_main(
+ background_jobs_bus,
+ serialization_interval,
+ disable_session_metadata,
+ web_server_base_url,
+ )
+ .fatal()
+ }
+ })
+ .unwrap();
+ if let Some(config_file_path) = cli_assets.config_file_path.clone() {
+ let layout_dir = config_options
+ .layout_dir
+ .clone()
+ .or_else(|| default_layout_dir());
+ let default_layout_name = config_options
+ .default_layout
+ .map(|l| format!("{}", l.display()));
+ report_changes_in_config_file(
+ config_file_path,
+ cli_assets.config_dir.as_deref(),
+ to_server.clone(),
+ );
+
+ // Watch layout directory for changes
+ if let Some(layout_dir_path) = layout_dir {
+ report_changes_in_layout_dir(
+ layout_dir_path,
+ default_layout_name,
+ to_plugin.clone(),
+ to_screen.clone(),
+ );
+ }
+ }
+
+ SessionMetaData {
+ senders: ThreadSenders {
+ to_screen: Some(to_screen),
+ to_pty: Some(to_pty),
+ to_plugin: Some(to_plugin),
+ to_pty_writer: Some(to_pty_writer),
+ to_background_jobs: Some(to_background_jobs),
+ to_server: Some(to_server),
+ should_silently_fail: false,
+ },
+ default_shell,
+ session_configuration: Default::default(),
+ current_input_modes: HashMap::new(),
+ screen_thread: Some(screen_thread),
+ pty_thread: Some(pty_thread),
+ plugin_thread: Some(plugin_thread),
+ pty_writer_thread: Some(pty_writer_thread),
+ background_jobs_thread: Some(background_jobs_thread),
+ #[cfg(feature = "web_server_capability")]
+ web_sharing: config.options.web_sharing.unwrap_or(WebSharing::Off),
+ #[cfg(not(feature = "web_server_capability"))]
+ web_sharing: WebSharing::Disabled,
+ key_passthrough_clients: HashMap::new(),
+ config_file_path: cli_assets.config_file_path,
+ }
+}
+
+fn setup_wizard_floating_pane() -> FloatingPaneLayout {
+ let mut setup_wizard_pane = FloatingPaneLayout::new();
+ let configuration = BTreeMap::from_iter([("is_setup_wizard".to_owned(), "true".to_owned())]);
+ setup_wizard_pane.run = Some(Run::Plugin(RunPluginOrAlias::Alias(PluginAlias::new(
+ "configuration",
+ &Some(configuration),
+ None,
+ ))));
+ setup_wizard_pane
+}
+
+fn about_floating_pane() -> FloatingPaneLayout {
+ let mut about_pane = FloatingPaneLayout::new();
+ let configuration = BTreeMap::from_iter([("is_release_notes".to_owned(), "true".to_owned())]);
+ about_pane.run = Some(Run::Plugin(RunPluginOrAlias::Alias(PluginAlias::new(
+ "about",
+ &Some(configuration),
+ None,
+ ))));
+ about_pane
+}
+
+fn tip_floating_pane() -> FloatingPaneLayout {
+ let mut about_pane = FloatingPaneLayout::new();
+ let configuration = BTreeMap::from_iter([("is_startup_tip".to_owned(), "true".to_owned())]);
+ about_pane.run = Some(Run::Plugin(RunPluginOrAlias::Alias(PluginAlias::new(
+ "about",
+ &Some(configuration),
+ None,
+ ))));
+ about_pane
+}
+
+fn should_show_release_notes(
+ should_show_release_notes_config: Option<bool>,
+ layout_is_welcome_screen: bool,
+) -> bool {
+ if layout_is_welcome_screen {
+ return false;
+ }
+ if let Some(should_show_release_notes_config) = should_show_release_notes_config {
+ if !should_show_release_notes_config {
+ // if we were explicitly told not to show release notes, we don't show them,
+ // otherwise we make sure we only show them if they were not seen AND we know
+ // we are able to write to the cache
+ return false;
+ }
+ }
+ if ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE.exists() {
+ return false;
+ } else {
+ if let Some(parent) = ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ if let Err(e) = std::fs::write(&*ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE, &[]) {
+ log::error!(
+ "Failed to write seen release notes indication to disk: {}",
+ e
+ );
+ return false;
+ }
+ return true;
+ }
+}
+
+fn should_show_startup_tip(
+ should_show_startup_tip_config: Option<bool>,
+ layout_is_welcome_screen: bool,
+) -> bool {
+ if layout_is_welcome_screen {
+ false
+ } else {
+ should_show_startup_tip_config.unwrap_or(true)
+ }
+}
+
+fn should_enter_mobile_on_connect(options: &Options, is_web_client: bool, viewport: Size) -> bool {
+ let mobile_layout = options.mobile_layout.unwrap_or_default();
+ if is_web_client {
+ mobile_layout.may_route_web_client_to_mobile()
+ } else {
+ mobile_layout.should_route_to_mobile(
+ is_web_client,
+ viewport.cols,
+ viewport.rows,
+ options.mobile_threshold_cols.unwrap_or(60),
+ options.mobile_threshold_rows.unwrap_or(30),
+ )
+ }
+}
+
+fn report_changes_in_config_file(
+ config_file_path: PathBuf,
+ config_dir: Option<&Path>,
+ to_server: SenderWithContext<ServerInstruction>,
+) {
+ let config_dir = config_dir.map(Path::to_path_buf);
+ global_async_runtime::get_tokio_runtime().spawn(async move {
+ watch_config_file_changes(config_file_path, config_dir.as_deref(), move |new_config| {
+ let to_server = to_server.clone();
+ async move {
+ let _ = to_server.send(ServerInstruction::ConfigWrittenToDisk(new_config));
+ }
+ })
+ .await;
+ });
+}
+
+fn report_changes_in_layout_dir(
+ layout_dir: PathBuf,
+ default_layout_name: Option<String>,
+ to_plugin: SenderWithContext<PluginInstruction>,
+ to_screen: SenderWithContext<ScreenInstruction>,
+) {
+ std::thread::spawn(move || {
+ let rt = crate::global_async_runtime::get_tokio_runtime();
+ rt.block_on(async move {
+ watch_layout_dir_changes(
+ layout_dir,
+ default_layout_name,
+ move |new_layouts, layout_errors| {
+ let to_plugin = to_plugin.clone();
+ let to_screen = to_screen.clone();
+ async move {
+ let _ = to_plugin.send(PluginInstruction::LayoutListUpdate(
+ new_layouts.clone(),
+ layout_errors.clone(),
+ ));
+ let _ = to_screen.send(ScreenInstruction::UpdateAvailableLayouts(
+ new_layouts,
+ layout_errors,
+ ));
+ }
+ },
+ )
+ .await;
+ });
+ });
+}
+
+fn update_new_saved_config(
+ new_config: Option<Config>,
+ write_config_to_disk: bool,
+ runtime_config_changed: bool,
+ session_data: &Arc<RwLock<Option<SessionMetaData>>>,
+ client_id: ClientId,
+) {
+ if let Some(new_config) = new_config {
+ if write_config_to_disk {
+ let clear_defaults = true;
+ let config_file_path = session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .config_file_path
+ .clone();
+
+ let Some(config_file_path) = config_file_path.as_ref() else {
+ log::error!("No config file path found.");
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::FailedToWriteConfigToDisk {
+ file_path: None,
+ })
+ .unwrap();
+ return;
+ };
+ match Config::write_config_to_disk(
+ new_config.to_string(clear_defaults),
+ &config_file_path,
+ ) {
+ Ok(written_config) => {
+ let changes = session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .session_configuration
+ .change_saved_config(written_config);
+ let config_was_written_to_disk = true;
+ session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .propagate_configuration_changes(changes, config_was_written_to_disk);
+ },
+ Err(e) => {
+ let error_path = e
+ .as_ref()
+ .map(|p| p.display().to_string())
+ .unwrap_or_else(String::new);
+ log::error!("Failed to write config to disk: {}", error_path);
+ session_data
+ .write()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_plugin(PluginInstruction::FailedToWriteConfigToDisk {
+ file_path: e,
+ })
+ .unwrap();
+ },
+ }
+ } else if runtime_config_changed {
+ let config_was_written_to_disk = false;
+ session_data
+ .write()
+ .unwrap()
+ .as_mut()
+ .unwrap()
+ .propagate_configuration_changes(
+ vec![(client_id, new_config)],
+ config_was_written_to_disk,
+ );
+ }
+ }
+}
+
+pub fn get_engine() -> Engine {
+ log::info!("Loading plugins using Wasmi interpreter");
+ Engine::default()
+}
+
+// TODO: move elsewhere
+fn get_available_layouts(config_options: &Options) -> (Vec<LayoutInfo>, Vec<LayoutWithError>) {
+ let layout_dir = config_options
+ .layout_dir
+ .clone()
+ .or_else(|| default_layout_dir());
+ let default_layout_name = config_options
+ .default_layout
+ .as_ref()
+ .map(|l| format!("{}", l.display()));
+ Layout::list_available_layouts(layout_dir, &default_layout_name)
+}