aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-utils/src
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2025-06-23 19:19:37 +0200
committerGitHub <noreply@github.com>2025-06-23 19:19:37 +0200
commitc5ac7968809ddb1033b625562eae22019cccc2f4 (patch)
tree932cf07d898dfbcc6ec6a9d4cf5353c06a47accc /zellij-utils/src
parentfeat(ui): add optional tooltip with key hints to the compact bar (#4225) (diff)
downloadsl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.tar.gz
sl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.tar.xz
sl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.zip
Feature: web-client/server to share your sessions in the browser (#4242)
* work * moar work * notes * work * separate to terminal and control channels * stdin working * serve html web client initial * serve static assets loaded with include_dir * merge * enable_web_server config parameter * compile time flag to disable web server capability * rustfmt * add license to all xterm.js assets * mouse working except copy/paste * helpful comment * web client improvements - move script to js file - add favicon - add nerd font - change title TODO: investigate if font license embedded in otf is sufficient * get mouse to work properly * kitty keyboard support initial * fix wrong type in preload link * wip axum websocket handlers - upgrade axum to v0.8.1, enable ws feature - begin setup of websocket handlers - tidy up imports * replace control listener * handle terminal websocket with axum * cleanup Cargo.toml * kitty fixes and bracketed paste * fix(mouse): pane not found crash * initial session switching infra * add `web_client_font` option * session switching, creation and resurrection working through the session manager * move session module to zellij-utils and share logic with web-client * some cleanups * require restart for enable-web-server * use session name from router * write config to disk and watch for config changes * rename session name to ipc path * add basic panic handler, make render_to_client exit on channel close * use while let instead of loop * handle websocket close * add mouse motions * make clipboard work * add weblink handling and webgl rendering * add todo * fix: use session name instead of patch on session switch * use "default" layout for new sessions * ui indication for session being shared * share this session ui * plugin assets * Fix process crash on mac with notify watcher. Use poll watcher instead of recommended as a workaround. * make url session switching and creation work * start welcome screen on root url * scaffold control messages, set font from config * set dimensions on session start * bring back session name from url * send bytes on terminal websocket instead of json - create web client os input and id before websocket connection * draft ui * work * refactor ui * remove otf font, remove margins to avoid scrollbar * version query endpoint for server status * web session info query endpoint * refactor: move stuff around * add web client info to session metadata * make tests pass * populate real data in session list * remove unnecessary endpoint * add web_client node to config, add font option * remove web_client_font * allow disabling the web session through the config - WIP * formalize sharing/not-sharing configuration * fix tests * allow shutting down web server * display error when web clients are forbidden to attach * only show sessions that allow web clients if this is a web client * style(fmt): rustfmt * fix: query web server from Zellij rather than from each plugin * remove log spam * handle some error paths better in the web client * allow controlling the web server through the cli * allow configuring the web server's ip/port * fix tests and format code * use direct WebServerStatus event instead of piggy-backing on SessionInfo * plugin revamp initial * make plugin responsive * adjust plugin title * refactor: share plugin * refactor: share plugin * add cors middleware * some fixes for running without a compiled web server capability * display error when starting the share plugin without web server support * clarify config * add pipelines to compile zellij without web support * display error when unable to start web server * only query web server when share plugin is running * refactor(web-client): connection table * give zellij_server_listener access to the control channel * fixes and clarifications * refactor: consolidate generate_unique_session_name * give proper error when trying to attach to a forbidden session * change browser URL when switching sessions * add keyboard shortcut * enforce https when bound to non-loopback ip * initial authentication token implementation * background color from theme * initial web client theme config * basic token generation ui * refactor set config message creation * also set body background * allow editing scrollback for plugins too * set scrollback to 0 * properly parse colors in config * generate token from plugin * nice login modals * initial token management screen * implement token authentication * refactor(share): token management screen * style(fmt): rustfmt * fix(plugin): some minor bugs * refactor(share): main screen * refactor(share): token screen * refactor(share): main * refactor(share): ui components * fix(responsiveness): properly send usage_width to the render function * fix cli commands and add some verbosity * add support for settings ansi and selection colors * add cursor and cursor accent * basic web client tests * fix tests * refactor: web client * use session tokens for authentication * improve modals * move shutdown to ipc * refactor: ipc logic * serialize theme config for web client * update tests * refactor: move some stuff around to prepare for config hot reload * config live reloading for the web clients * change remember-me UI wording * improve xterm.js link handling * make sure terminal is focused on mousemove * remove deprecated sharing indication from compact-bar * gate deps and functionality behind the web_server_compatibility feature * feat(build): add --no-web flag in all the places * fix some other build flows * add new assets * update CI for no-web (untested) * make more dependencies optional * update axum-extra * add web client configuration options * gracefully close connections on server exit * tests for graceful connection closing * handle client-side reconnect when server is down * fix: make sure ipc bus folder exists before starting * add commands to manage login tokens from the cli * style(fmt): rustfmt * some cleanups * fix(ux): allow alt-right-click on the web client without opening the context menu * fix: prevent attaching to welcome screen * fix: reload config issues * fix long socket path on macos * normalize config conversion and fix color gap in browser * revoke session_token cookie if it is not valid * fix: visual bug with multiple clients in extremely small screen sizes * fix: only include rusqlite for the web server capability builds * update e2e snapshots * refactor(web): client side js * some cleanups * moar cleanups * fix(tests): wait for server instead of using a fixed timeout * debug CI * fix(tests): use spawn_blocking for running the test web server * fix(tests): wait for http rather than tcp port * fix(tests): properly pass config path - hopefully this is the issue... * success! bring back the rest of the tests * attempt to fix the macos CI issue * docs(changelog): add PR --------- Co-authored-by: Thomas Linford <linford.t@gmail.com>
Diffstat (limited to 'zellij-utils/src')
-rw-r--r--zellij-utils/src/cli.rs66
-rw-r--r--zellij-utils/src/consts.rs2
-rw-r--r--zellij-utils/src/data.rs149
-rw-r--r--zellij-utils/src/errors.rs13
-rw-r--r--zellij-utils/src/input/config.rs84
-rw-r--r--zellij-utils/src/input/mod.rs7
-rw-r--r--zellij-utils/src/input/options.rs87
-rw-r--r--zellij-utils/src/input/plugins.rs1
-rw-r--r--zellij-utils/src/input/web_client.rs343
-rw-r--r--zellij-utils/src/ipc.rs10
-rw-r--r--zellij-utils/src/kdl/mod.rs321
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap10
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap66
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap4
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap56
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info.snap4
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info_with_data.snap4
-rw-r--r--zellij-utils/src/lib.rs6
-rw-r--r--zellij-utils/src/logging.rs6
-rw-r--r--zellij-utils/src/plugin_api/event.proto43
-rw-r--r--zellij-utils/src/plugin_api/event.rs154
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.proto74
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.rs164
-rw-r--r--zellij-utils/src/plugin_api/plugin_permission.proto3
-rw-r--r--zellij-utils/src/plugin_api/plugin_permission.rs2
-rw-r--r--zellij-utils/src/sessions.rs624
-rw-r--r--zellij-utils/src/shared.rs43
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap7
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap7
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap7
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap45
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap45
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap15
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap7
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap45
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap45
-rw-r--r--zellij-utils/src/web_authentication_tokens.rs349
-rw-r--r--zellij-utils/src/web_server_commands.rs60
38 files changed, 2939 insertions, 39 deletions
diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs
index 3670f2a6..6a631f8b 100644
--- a/zellij-utils/src/cli.rs
+++ b/zellij-utils/src/cli.rs
@@ -4,7 +4,7 @@ use crate::{
consts::{ZELLIJ_CONFIG_DIR_ENV, ZELLIJ_CONFIG_FILE_ENV},
input::{layout::PluginUserConfiguration, options::CliOptions},
};
-use clap::{Parser, Subcommand};
+use clap::{Args, Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use url::Url;
@@ -48,6 +48,10 @@ pub struct CliArgs {
#[clap(long, value_parser, hide = true, overrides_with = "server")]
pub server: Option<PathBuf>,
+ /// Run a web server
+ #[clap(long, value_parser, hide = true, overrides_with = "server")]
+ pub web: Option<String>,
+
/// Specify name of a new session
#[clap(long, short, overrides_with = "session", value_parser = validate_session)]
pub session: Option<String>,
@@ -89,11 +93,71 @@ pub enum Command {
#[clap(name = "setup", value_parser)]
Setup(Setup),
+ /// Setup zellij and check its configuration
+ #[clap(name = "web", value_parser)]
+ Web(WebCli),
+
/// Explore existing zellij sessions
#[clap(flatten)]
Sessions(Sessions),
}
+#[derive(Debug, Clone, Args, Serialize, Deserialize)]
+pub struct WebCli {
+ /// Start the server (default unless other arguments are specified)
+ #[clap(long, value_parser, display_order = 1)]
+ pub start: bool,
+
+ /// Stop the server
+ #[clap(long, value_parser, exclusive(true), display_order = 2)]
+ pub stop: bool,
+
+ /// Get the server status
+ #[clap(long, value_parser, exclusive(true), display_order = 3)]
+ pub status: bool,
+
+ /// Run the server in the background
+ #[clap(
+ short,
+ long,
+ value_parser,
+ conflicts_with_all(&["stop", "status", "create-token", "revoke-token", "revoke-all-tokens"]),
+ display_order = 4
+ )]
+ pub daemonize: bool,
+ /// Create a login token for the web interface, will only be displayed once and cannot later be
+ /// retrieved. Returns the token name and the token.
+ #[clap(long, value_parser, exclusive(true), display_order = 5)]
+ pub create_token: bool,
+ /// Revoke a login token by its name
+ #[clap(
+ long,
+ value_parser,
+ exclusive(true),
+ value_name = "TOKEN NAME",
+ display_order = 6
+ )]
+ pub revoke_token: Option<String>,
+ /// Revoke all login tokens
+ #[clap(long, value_parser, exclusive(true), display_order = 7)]
+ pub revoke_all_tokens: bool,
+ /// List token names and their creation dates (cannot show actual tokens)
+ #[clap(long, value_parser, exclusive(true), display_order = 8)]
+ pub list_tokens: bool,
+}
+
+impl WebCli {
+ pub fn get_start(&self) -> bool {
+ self.start
+ || !(self.stop
+ || self.status
+ || self.create_token
+ || self.revoke_token.is_some()
+ || self.revoke_all_tokens
+ || self.list_tokens)
+ }
+}
+
#[derive(Debug, Subcommand, Clone, Serialize, Deserialize)]
pub enum SessionCommand {
/// Change the behaviour of zellij
diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs
index 66428917..ab1dfb31 100644
--- a/zellij-utils/src/consts.rs
+++ b/zellij-utils/src/consts.rs
@@ -131,6 +131,7 @@ mod not_wasm {
add_plugin!(assets, "configuration.wasm");
add_plugin!(assets, "plugin-manager.wasm");
add_plugin!(assets, "about.wasm");
+ add_plugin!(assets, "share.wasm");
add_plugin!(assets, "multiple-select.wasm");
assets
};
@@ -168,5 +169,6 @@ mod unix_only {
ipc_dir.push(VERSION);
ipc_dir
};
+ pub static ref WEBSERVER_SOCKET_PATH: PathBuf = ZELLIJ_SOCK_DIR.join("web_server_bus");
}
}
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index 6b9a49d7..c6b6dcad 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -3,12 +3,13 @@ use crate::input::config::ConversionError;
use crate::input::keybinds::Keybinds;
use crate::input::layout::{RunPlugin, SplitSize};
use crate::pane_size::PaneGeom;
-use crate::shared::colors as default_colors;
+use crate::shared::{colors as default_colors, eightbit_to_rgb};
use clap::ArgEnum;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt;
use std::fs::Metadata;
+use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::str::{self, FromStr};
use std::time::Duration;
@@ -938,10 +939,19 @@ pub enum Event {
FailedToChangeHostFolder(Option<String>), // String -> the error we got when changing
PastedText(String),
ConfigWasWrittenToDisk,
+ WebServerStatus(WebServerStatus),
+ FailedToStartWebServer(String),
BeforeClose,
InterceptedKeyPress(KeyWithModifier),
}
+#[derive(Debug, Clone, PartialEq, Eq, EnumDiscriminants, ToString, Serialize, Deserialize)]
+pub enum WebServerStatus {
+ Online(String), // String -> base url
+ Offline,
+ DifferentVersion(String), // version
+}
+
#[derive(
Debug,
PartialEq,
@@ -971,6 +981,7 @@ pub enum Permission {
MessageAndLaunchOtherPlugins,
Reconfigure,
FullHdAccess,
+ StartWebServer,
InterceptInput,
}
@@ -994,6 +1005,9 @@ impl PermissionType {
},
PermissionType::Reconfigure => "Change Zellij runtime configuration".to_owned(),
PermissionType::FullHdAccess => "Full access to the hard-drive".to_owned(),
+ PermissionType::StartWebServer => {
+ "Start a local web server to serve Zellij sessions".to_owned()
+ },
PermissionType::InterceptInput => "Intercept Input (keyboard & mouse)".to_owned(),
}
}
@@ -1101,6 +1115,48 @@ impl Default for PaletteColor {
}
}
+// these are used for the web client
+impl PaletteColor {
+ pub fn as_rgb_str(&self) -> String {
+ let (r, g, b) = match *self {
+ Self::Rgb((r, g, b)) => (r, g, b),
+ Self::EightBit(c) => eightbit_to_rgb(c),
+ };
+ format!("rgb({}, {}, {})", r, g, b)
+ }
+ pub fn from_rgb_str(rgb_str: &str) -> Self {
+ let trimmed = rgb_str.trim();
+
+ if !trimmed.starts_with("rgb(") || !trimmed.ends_with(')') {
+ return Self::default();
+ }
+
+ let inner = trimmed
+ .strip_prefix("rgb(")
+ .and_then(|s| s.strip_suffix(')'))
+ .unwrap_or("");
+
+ let parts: Vec<&str> = inner.split(',').collect();
+
+ if parts.len() != 3 {
+ return Self::default();
+ }
+
+ let mut rgb_values = [0u8; 3];
+ for (i, part) in parts.iter().enumerate() {
+ if let Some(rgb_val) = rgb_values.get_mut(i) {
+ if let Ok(parsed) = part.trim().parse::<u8>() {
+ *rgb_val = parsed;
+ } else {
+ return Self::default();
+ }
+ }
+ }
+
+ Self::Rgb((rgb_values[0], rgb_values[1], rgb_values[2]))
+ }
+}
+
impl FromStr for InputMode {
type Err = ConversionError;
@@ -1517,7 +1573,14 @@ pub struct ModeInfo {
pub session_name: Option<String>,
pub editor: Option<PathBuf>,
pub shell: Option<PathBuf>,
+ pub web_clients_allowed: Option<bool>,
+ pub web_sharing: Option<WebSharing>,
pub currently_marking_pane_group: Option<bool>,
+ pub is_web_client: Option<bool>,
+ // note: these are only the configured ip/port that will be bound if and when the server is up
+ pub web_server_ip: Option<IpAddr>,
+ pub web_server_port: Option<u16>,
+ pub web_server_capability: Option<bool>,
}
impl ModeInfo {
@@ -1564,6 +1627,8 @@ pub struct SessionInfo {
pub is_current_session: bool,
pub available_layouts: Vec<LayoutInfo>,
pub plugins: BTreeMap<u32, PluginInfo>,
+ pub web_clients_allowed: bool,
+ pub web_client_count: usize,
pub tab_history: BTreeMap<ClientId, Vec<usize>>,
}
@@ -2182,6 +2247,77 @@ impl OriginatingPlugin {
}
}
+#[derive(ArgEnum, Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WebSharing {
+ #[serde(alias = "on")]
+ On,
+ #[serde(alias = "off")]
+ Off,
+ #[serde(alias = "disabled")]
+ Disabled,
+}
+
+impl Default for WebSharing {
+ fn default() -> Self {
+ Self::Off
+ }
+}
+
+impl WebSharing {
+ pub fn is_on(&self) -> bool {
+ match self {
+ WebSharing::On => true,
+ _ => false,
+ }
+ }
+ pub fn web_clients_allowed(&self) -> bool {
+ match self {
+ WebSharing::On => true,
+ _ => false,
+ }
+ }
+ pub fn sharing_is_disabled(&self) -> bool {
+ match self {
+ WebSharing::Disabled => true,
+ _ => false,
+ }
+ }
+ pub fn set_sharing(&mut self) -> bool {
+ // returns true if successfully set sharing
+ match self {
+ WebSharing::On => true,
+ WebSharing::Off => {
+ *self = WebSharing::On;
+ true
+ },
+ WebSharing::Disabled => false,
+ }
+ }
+ pub fn set_not_sharing(&mut self) -> bool {
+ // returns true if successfully set not sharing
+ match self {
+ WebSharing::On => {
+ *self = WebSharing::Off;
+ true
+ },
+ WebSharing::Off => true,
+ WebSharing::Disabled => false,
+ }
+ }
+}
+
+impl FromStr for WebSharing {
+ type Err = String;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "On" | "on" => Ok(Self::On),
+ "Off" | "off" => Ok(Self::Off),
+ "Disabled" | "disabled" => Ok(Self::Disabled),
+ _ => Err(format!("No such option: {}", s)),
+ }
+ }
+}
+
type Context = BTreeMap<String, String>;
#[derive(Debug, Clone, EnumDiscriminants, ToString)]
@@ -2340,6 +2476,10 @@ pub enum PluginCommand {
// close_plugin_after_replace
OpenFileNearPlugin(FileToOpen, Context),
OpenFileFloatingNearPlugin(FileToOpen, Option<FloatingPaneCoordinates>, Context),
+ StartWebServer,
+ StopWebServer,
+ ShareCurrentSession,
+ StopSharingCurrentSession,
OpenFileInPlaceOfPlugin(FileToOpen, bool, Context), // bool -> close_plugin_after_replace
GroupAndUngroupPanes(Vec<PaneId>, Vec<PaneId>), // panes to group, panes to ungroup
HighlightAndUnhighlightPanes(Vec<PaneId>, Vec<PaneId>), // panes to highlight, panes to
@@ -2347,6 +2487,13 @@ pub enum PluginCommand {
CloseMultiplePanes(Vec<PaneId>),
FloatMultiplePanes(Vec<PaneId>),
EmbedMultiplePanes(Vec<PaneId>),
+ QueryWebServerStatus,
+ SetSelfMouseSelectionSupport(bool),
+ GenerateWebLoginToken(Option<String>), // String -> optional token label
+ RevokeWebLoginToken(String), // String -> token id (provided name or generated id)
+ ListWebLoginTokens,
+ RevokeAllWebLoginTokens,
+ RenameWebLoginToken(String, String), // (original_name, new_name)
InterceptKeyPresses,
ClearKeyPressesIntercepts,
}
diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs
index dbc9ba60..4359082f 100644
--- a/zellij-utils/src/errors.rs
+++ b/zellij-utils/src/errors.rs
@@ -382,6 +382,8 @@ pub enum ScreenContext {
EmbedMultiplePanes,
TogglePaneInGroup,
ToggleGroupMarking,
+ SessionSharingStatusChange,
+ SetMouseSelectionSupport,
InterceptKeyPresses,
ClearKeyPressesIntercepts,
}
@@ -447,6 +449,8 @@ pub enum PluginContext {
FailedToWriteConfigToDisk,
ListClientsToPlugin,
ChangePluginHostDir,
+ WebServerStarted,
+ FailedToStartWebServer,
}
/// Stack call representations corresponding to the different types of [`ClientInstruction`]s.
@@ -470,6 +474,7 @@ pub enum ClientContext {
CliPipeOutput,
QueryTerminalSize,
WriteConfigToDisk,
+ StartWebServer,
}
/// Stack call representations corresponding to the different types of [`ServerInstruction`]s.
@@ -498,6 +503,12 @@ pub enum ServerContext {
ConfigWrittenToDisk,
FailedToWriteConfigToDisk,
RebindKeys,
+ StartWebServer,
+ ShareCurrentSession,
+ StopSharingCurrentSession,
+ WebServerStarted,
+ FailedToStartWebServer,
+ SendWebClientsForbidden,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -520,8 +531,10 @@ pub enum BackgroundJobContext {
RunCommand,
WebRequest,
ReportPluginList,
+ ListWebSessions,
RenderToClients,
HighlightPanesWithMessage,
+ QueryZellijWebServerStatus,
Exit,
}
diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs
index 445d507a..287d0e5f 100644
--- a/zellij-utils/src/input/config.rs
+++ b/zellij-utils/src/input/config.rs
@@ -14,6 +14,7 @@ use super::layout::RunPluginOrAlias;
use super::options::Options;
use super::plugins::{PluginAliases, PluginsConfigError};
use super::theme::{Themes, UiConfig};
+use super::web_client::WebClientConfig;
use crate::cli::{CliArgs, Command};
use crate::envs::EnvironmentVariables;
use crate::{home, setup};
@@ -32,6 +33,7 @@ pub struct Config {
pub ui: UiConfig,
pub env: EnvironmentVariables,
pub background_plugins: HashSet<RunPluginOrAlias>,
+ pub web_client: WebClientConfig,
}
#[derive(Error, Debug)]
@@ -245,6 +247,9 @@ impl Config {
.map(|config_dir| config_dir.join(DEFAULT_CONFIG_FILE_NAME))
})
}
+ pub fn default_config_file_path() -> Option<PathBuf> {
+ home::find_default_config_dir().map(|config_dir| config_dir.join(DEFAULT_CONFIG_FILE_NAME))
+ }
pub fn write_config_to_disk(config: String, opts: &CliArgs) -> Result<Config, Option<PathBuf>> {
// if we fail, try to return the PathBuf of the file we were not able to write to
Config::from_kdl(&config, None)
@@ -404,11 +409,86 @@ impl Config {
}
}
+#[cfg(not(target_family = "wasm"))]
+pub async fn watch_config_file_changes<F, Fut>(config_file_path: PathBuf, on_config_change: F)
+where
+ F: Fn(Config) -> Fut + Send + 'static,
+ Fut: std::future::Future<Output = ()> + Send,
+{
+ // in a gist, what we do here is fire the `on_config_change` function whenever there is a
+ // change in the config file, we do this by:
+ // 1. Trying to watch the provided config file for changes
+ // 2. If the file is deleted or does not exist, we periodically poll for it (manually, not
+ // through filesystem events)
+ // 3. Once it exists, we start watching it for changes again
+ //
+ // we do this because the alternative is to watch its parent folder and this might cause the
+ // classic "too many open files" issue if there are a lot of files there and/or lots of Zellij
+ // instances
+ use crate::setup::Setup;
+ use notify::{self, Config as WatcherConfig, Event, PollWatcher, RecursiveMode, Watcher};
+ use std::time::Duration;
+ use tokio::sync::mpsc;
+ loop {
+ if config_file_path.exists() {
+ let (tx, mut rx) = mpsc::unbounded_channel();
+
+ let mut watcher = match PollWatcher::new(
+ move |res: Result<Event, notify::Error>| {
+ let _ = tx.send(res);
+ },
+ WatcherConfig::default().with_poll_interval(Duration::from_secs(1)),
+ ) {
+ Ok(watcher) => watcher,
+ Err(_) => break,
+ };
+
+ if watcher
+ .watch(&config_file_path, RecursiveMode::NonRecursive)
+ .is_err()
+ {
+ break;
+ }
+
+ while let Some(event_result) = rx.recv().await {
+ match event_result {
+ Ok(event) => {
+ if event.paths.contains(&config_file_path) {
+ if event.kind.is_remove() {
+ break;
+ } else if event.kind.is_create() || event.kind.is_modify() {
+ tokio::time::sleep(Duration::from_millis(100)).await;
+
+ if !config_file_path.exists() {
+ continue;
+ }
+
+ let mut cli_args_for_config = CliArgs::default();
+ cli_args_for_config.config = Some(PathBuf::from(&config_file_path));
+ if let Ok(new_config) = Setup::from_cli_args(&cli_args_for_config)
+ .map_err(|e| e.to_string())
+ {
+ on_config_change(new_config.0).await;
+ }
+ }
+ }
+ },
+ Err(_) => break,
+ }
+ }
+ }
+
+ while !config_file_path.exists() {
+ tokio::time::sleep(Duration::from_secs(3)).await;
+ }
+ }
+}
+
#[cfg(test)]
mod config_test {
use super::*;
- use crate::data::{InputMode, Palette, PaletteColor, PluginTag, StyleDeclaration, Styling};
- use crate::input::layout::{RunPlugin, RunPluginLocation};
+ use crate::data::{InputMode, Palette, PaletteColor, StyleDeclaration, Styling};
+ use crate::input::layout::RunPlugin;
use crate::input::options::{Clipboard, OnForceClose};
use crate::input::theme::{FrameConfig, Theme, Themes, UiConfig};
use std::collections::{BTreeMap, HashMap};
diff --git a/zellij-utils/src/input/mod.rs b/zellij-utils/src/input/mod.rs
index 89ecee82..2ff8c206 100644
--- a/zellij-utils/src/input/mod.rs
+++ b/zellij-utils/src/input/mod.rs
@@ -8,6 +8,7 @@ pub mod options;
pub mod permission;
pub mod plugins;
pub mod theme;
+pub mod web_client;
#[cfg(not(target_family = "wasm"))]
pub use not_wasm::*;
@@ -45,7 +46,13 @@ mod not_wasm {
session_name,
editor: None,
shell: None,
+ web_clients_allowed: None,
+ web_sharing: None,
currently_marking_pane_group: None,
+ is_web_client: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_capability: None,
}
}
diff --git a/zellij-utils/src/input/options.rs b/zellij-utils/src/input/options.rs
index 5b8b1f51..3e167022 100644
--- a/zellij-utils/src/input/options.rs
+++ b/zellij-utils/src/input/options.rs
@@ -1,11 +1,13 @@
//! Handles cli and configuration options
use crate::cli::Command;
-use crate::data::InputMode;
+use crate::data::{InputMode, WebSharing};
use clap::{ArgEnum, Args};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::str::FromStr;
+use std::net::IpAddr;
+
#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize, ArgEnum)]
pub enum OnForceClose {
#[serde(alias = "quit")]
@@ -162,6 +164,38 @@ pub struct Options {
#[serde(default)]
pub support_kitty_keyboard_protocol: Option<bool>,
+ /// Whether to make sure a local web server is running when a new Zellij session starts.
+ /// This web server will allow creating new sessions and attaching to existing ones that have
+ /// opted in to being shared in the browser.
+ ///
+ /// Note: a local web server can still be manually started from within a Zellij session or from the CLI.
+ /// If this is not desired, one can use a version of Zellij compiled without
+ /// web_server_capability
+ ///
+ /// Possible values:
+ /// - true
+ /// - false
+ /// Default: false
+ #[clap(long, value_parser)]
+ #[serde(default)]
+ pub web_server: Option<bool>,
+
+ /// Whether to allow new sessions to be shared through a local web server, assuming one is
+ /// running (see the `web_server` option for more details).
+ ///
+ /// Note: if Zellij was compiled without web_server_capability, this option will be locked to
+ /// "disabled"
+ ///
+ /// Possible values:
+ /// - "on" (new sessions will allow web sharing through the local web server if it
+ /// is online)
+ /// - "off" (new sessions will not allow web sharing unless they explicitly opt-in to it)
+ /// - "disabled" (new sessions will not allow web sharing and will not be able to opt-in to it)
+ /// Default: "off"
+ #[clap(long, value_parser)]
+ #[serde(default)]
+ pub web_sharing: Option<WebSharing>,
+
/// Whether to stack panes when resizing beyond a certain size
/// default is true
#[clap(long, value_parser)]
@@ -185,6 +219,14 @@ pub struct Options {
#[clap(long, value_parser)]
#[serde(default)]
pub advanced_mouse_actions: Option<bool>,
+
+ // these are intentionally excluded from the CLI options as they must be specified in the
+ // configuration file
+ pub web_server_ip: Option<IpAddr>,
+ pub web_server_port: Option<u16>,
+ pub web_server_cert: Option<PathBuf>,
+ pub web_server_key: Option<PathBuf>,
+ pub enforce_https_for_localhost: Option<bool>,
}
#[derive(ArgEnum, Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
@@ -263,10 +305,21 @@ impl Options {
let support_kitty_keyboard_protocol = other
.support_kitty_keyboard_protocol
.or(self.support_kitty_keyboard_protocol);
+ let web_server = other.web_server.or(self.web_server);
+ let web_sharing = other.web_sharing.or(self.web_sharing);
let stacked_resize = other.stacked_resize.or(self.stacked_resize);
let show_startup_tips = other.show_startup_tips.or(self.show_startup_tips);
let show_release_notes = other.show_release_notes.or(self.show_release_notes);
let advanced_mouse_actions = other.advanced_mouse_actions.or(self.advanced_mouse_actions);
+ let web_server_ip = other.web_server_ip.or(self.web_server_ip);
+ let web_server_port = other.web_server_port.or(self.web_server_port);
+ let web_server_cert = other
+ .web_server_cert
+ .or_else(|| self.web_server_cert.clone());
+ let web_server_key = other.web_server_key.or_else(|| self.web_server_key.clone());
+ let enforce_https_for_localhost = other
+ .enforce_https_for_localhost
+ .or(self.enforce_https_for_localhost);
Options {
simplified_ui,
@@ -296,10 +349,17 @@ impl Options {
serialization_interval,
disable_session_metadata,
support_kitty_keyboard_protocol,
+ web_server,
+ web_sharing,
stacked_resize,
show_startup_tips,
show_release_notes,
advanced_mouse_actions,
+ web_server_ip,
+ web_server_port,
+ web_server_cert,
+ web_server_key,
+ enforce_https_for_localhost,
}
}
@@ -358,10 +418,21 @@ impl Options {
let support_kitty_keyboard_protocol = other
.support_kitty_keyboard_protocol
.or(self.support_kitty_keyboard_protocol);
+ let web_server = other.web_server.or(self.web_server);
+ let web_sharing = other.web_sharing.or(self.web_sharing);
let stacked_resize = other.stacked_resize.or(self.stacked_resize);
let show_startup_tips = other.show_startup_tips.or(self.show_startup_tips);
let show_release_notes = other.show_release_notes.or(self.show_release_notes);
let advanced_mouse_actions = other.advanced_mouse_actions.or(self.advanced_mouse_actions);
+ let web_server_ip = other.web_server_ip.or(self.web_server_ip);
+ let web_server_port = other.web_server_port.or(self.web_server_port);
+ let web_server_cert = other
+ .web_server_cert
+ .or_else(|| self.web_server_cert.clone());
+ let web_server_key = other.web_server_key.or_else(|| self.web_server_key.clone());
+ let enforce_https_for_localhost = other
+ .enforce_https_for_localhost
+ .or(self.enforce_https_for_localhost);
Options {
simplified_ui,
@@ -391,10 +462,17 @@ impl Options {
serialization_interval,
disable_session_metadata,
support_kitty_keyboard_protocol,
+ web_server,
+ web_sharing,
stacked_resize,
show_startup_tips,
show_release_notes,
advanced_mouse_actions,
+ web_server_ip,
+ web_server_port,
+ web_server_cert,
+ web_server_key,
+ enforce_https_for_localhost,
}
}
@@ -460,10 +538,17 @@ impl From<CliOptions> for Options {
styled_underlines: opts.styled_underlines,
serialization_interval: opts.serialization_interval,
support_kitty_keyboard_protocol: opts.support_kitty_keyboard_protocol,
+ web_server: opts.web_server,
+ web_sharing: opts.web_sharing,
stacked_resize: opts.stacked_resize,
show_startup_tips: opts.show_startup_tips,
show_release_notes: opts.show_release_notes,
advanced_mouse_actions: opts.advanced_mouse_actions,
+ web_server_ip: opts.web_server_ip,
+ web_server_port: opts.web_server_port,
+ web_server_cert: opts.web_server_cert,
+ web_server_key: opts.web_server_key,
+ enforce_https_for_localhost: opts.enforce_https_for_localhost,
..Default::default()
}
}
diff --git a/zellij-utils/src/input/plugins.rs b/zellij-utils/src/input/plugins.rs
index be57adb9..c4755205 100644
--- a/zellij-utils/src/input/plugins.rs
+++ b/zellij-utils/src/input/plugins.rs
@@ -65,6 +65,7 @@ impl PluginConfig {
|| tag == "configuration"
|| tag == "plugin-manager"
|| tag == "about"
+ || tag == "share"
|| tag == "multiple-select"
{
Some(PluginConfig {
diff --git a/zellij-utils/src/input/web_client.rs b/zellij-utils/src/input/web_client.rs
new file mode 100644
index 00000000..1159b4d8
--- /dev/null
+++ b/zellij-utils/src/input/web_client.rs
@@ -0,0 +1,343 @@
+use kdl::{KdlDocument, KdlNode, KdlValue};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ data::PaletteColor, kdl_children_or_error, kdl_first_entry_as_string, kdl_get_child,
+ kdl_get_child_entry_bool_value, kdl_get_child_entry_string_value,
+};
+
+use super::config::ConfigError;
+
+#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
+pub struct WebClientTheme {
+ pub background: Option<String>,
+ pub foreground: Option<String>,
+ pub black: Option<String>,
+ pub blue: Option<String>,
+ pub bright_black: Option<String>,
+ pub bright_blue: Option<String>,
+ pub bright_cyan: Option<String>,
+ pub bright_green: Option<String>,
+ pub bright_magenta: Option<String>,
+ pub bright_red: Option<String>,
+ pub bright_white: Option<String>,
+ pub bright_yellow: Option<String>,
+ pub cursor: Option<String>,
+ pub cursor_accent: Option<String>,
+ pub cyan: Option<String>,
+ pub green: Option<String>,
+ pub magenta: Option<String>,
+ pub red: Option<String>,
+ pub selection_background: Option<String>,
+ pub selection_foreground: Option<String>,
+ pub selection_inactive_background: Option<String>,
+ pub white: Option<String>,
+ pub yellow: Option<String>,
+}
+
+impl WebClientTheme {
+ pub fn from_kdl(kdl: &KdlNode) -> Result<Self, ConfigError> {
+ let mut theme = WebClientTheme::default();
+ let colors = kdl_children_or_error!(kdl, "empty theme");
+
+ // Helper function to extract colors
+ let extract_color = |name: &str| -> Result<Option<String>, ConfigError> {
+ if colors.get(name).is_some() {
+ let color = PaletteColor::try_from((name, colors))?;
+ Ok(Some(color.as_rgb_str()))
+ } else {
+ Ok(None)
+ }
+ };
+
+ theme.background = extract_color("background")?;
+ theme.foreground = extract_color("foreground")?;
+ theme.black = extract_color("black")?;
+ theme.blue = extract_color("blue")?;
+ theme.bright_black = extract_color("bright_black")?;
+ theme.bright_blue = extract_color("bright_blue")?;
+ theme.bright_cyan = extract_color("bright_cyan")?;
+ theme.bright_green = extract_color("bright_green")?;
+ theme.bright_magenta = extract_color("bright_magenta")?;
+ theme.bright_red = extract_color("bright_red")?;
+ theme.bright_white = extract_color("bright_white")?;
+ theme.bright_yellow = extract_color("bright_yellow")?;
+ theme.cursor = extract_color("cursor")?;
+ theme.cursor_accent = extract_color("cursor_accent")?;
+ theme.cyan = extract_color("cyan")?;
+ theme.green = extract_color("green")?;
+ theme.magenta = extract_color("magenta")?;
+ theme.red = extract_color("red")?;
+ theme.selection_background = extract_color("selection_background")?;
+ theme.selection_foreground = extract_color("selection_foreground")?;
+ theme.selection_inactive_background = extract_color("selection_inactive_background")?;
+ theme.white = extract_color("white")?;
+ theme.yellow = extract_color("yellow")?;
+
+ Ok(theme)
+ }
+
+ pub fn to_kdl(&self) -> KdlNode {
+ macro_rules! add_color_nodes {
+ ($theme_children:expr, $self:expr, $($field:ident),+ $(,)?) => {
+ $(
+ if let Some(color) = &$self.$field {
+ let node = PaletteColor::from_rgb_str(color).to_kdl(stringify!($field));
+ $theme_children.nodes_mut().push(node);
+ }
+ )+
+ };
+ }
+ let mut theme_node = KdlNode::new("theme");
+ let mut theme_children = KdlDocument::new();
+
+ add_color_nodes!(
+ theme_children,
+ self,
+ background,
+ foreground,
+ black,
+ blue,
+ bright_black,
+ bright_blue,
+ bright_cyan,
+ bright_green,
+ bright_magenta,
+ bright_red,
+ bright_white,
+ bright_yellow,
+ cursor,
+ cursor_accent,
+ cyan,
+ green,
+ magenta,
+ red,
+ selection_background,
+ selection_foreground,
+ selection_inactive_background,
+ white,
+ yellow,
+ );
+
+ theme_node.set_children(theme_children);
+ theme_node
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum CursorInactiveStyle {
+ Outline,
+ Block,
+ Bar,
+ Underline,
+ NoStyle,
+}
+
+impl std::fmt::Display for CursorInactiveStyle {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ CursorInactiveStyle::Block => write!(f, "block"),
+ CursorInactiveStyle::Bar => write!(f, "bar"),
+ CursorInactiveStyle::Underline => write!(f, "underline"),
+ CursorInactiveStyle::Outline => write!(f, "outline"),
+ CursorInactiveStyle::NoStyle => write!(f, "none"),
+ }
+ }
+}
+
+impl CursorInactiveStyle {
+ pub fn from_kdl(kdl: &KdlNode) -> Result<Self, ConfigError> {
+ match kdl_first_entry_as_string!(kdl) {
+ Some("block") => Ok(CursorInactiveStyle::Block),
+ Some("bar") => Ok(CursorInactiveStyle::Bar),
+ Some("underline") => Ok(CursorInactiveStyle::Underline),
+ Some("outline") => Ok(CursorInactiveStyle::Outline),
+ Some("no_style") => Ok(CursorInactiveStyle::NoStyle),
+ _ => Err(ConfigError::new_kdl_error(
+ format!("Must be 'block', 'bar', 'underline', 'outline' or 'no_style'"),
+ kdl.span().offset(),
+ kdl.span().len(),
+ )),
+ }
+ }
+ pub fn to_kdl(&self) -> KdlNode {
+ let mut cursor_inactive_style_node = KdlNode::new("cursor_inactive_style");
+ match self {
+ CursorInactiveStyle::Block => {
+ cursor_inactive_style_node.push(KdlValue::String("block".to_owned()));
+ },
+ CursorInactiveStyle::Bar => {
+ cursor_inactive_style_node.push(KdlValue::String("bar".to_owned()));
+ },
+ CursorInactiveStyle::Underline => {
+ cursor_inactive_style_node.push(KdlValue::String("underline".to_owned()));
+ },
+ CursorInactiveStyle::Outline => {
+ cursor_inactive_style_node.push(KdlValue::String("outline".to_owned()));
+ },
+ CursorInactiveStyle::NoStyle => {
+ cursor_inactive_style_node.push(KdlValue::String("no_style".to_owned()));
+ },
+ }
+ cursor_inactive_style_node
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum CursorStyle {
+ Block,
+ Bar,
+ Underline,
+}
+
+impl std::fmt::Display for CursorStyle {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ CursorStyle::Block => write!(f, "block"),
+ CursorStyle::Bar => write!(f, "bar"),
+ CursorStyle::Underline => write!(f, "underline"),
+ }
+ }
+}
+
+impl CursorStyle {
+ pub fn from_kdl(kdl: &KdlNode) -> Result<Self, ConfigError> {
+ match kdl_first_entry_as_string!(kdl) {
+ Some("block") => Ok(CursorStyle::Block),
+ Some("bar") => Ok(CursorStyle::Bar),
+ Some("underline") => Ok(CursorStyle::Underline),
+ _ => Err(ConfigError::new_kdl_error(
+ format!("Must be 'block', 'bar' or 'underline'"),
+ kdl.span().offset(),
+ kdl.span().len(),
+ )),
+ }
+ }
+ pub fn to_kdl(&self) -> KdlNode {
+ let mut cursor_style_node = KdlNode::new("cursor_style");
+ match self {
+ CursorStyle::Block => {
+ cursor_style_node.push(KdlValue::String("block".to_owned()));
+ },
+ CursorStyle::Bar => {
+ cursor_style_node.push(KdlValue::String("bar".to_owned()));
+ },
+ CursorStyle::Underline => {
+ cursor_style_node.push(KdlValue::String("underline".to_owned()));
+ },
+ }
+ cursor_style_node
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct WebClientConfig {
+ pub font: String,
+ pub theme: Option<WebClientTheme>,
+ pub cursor_blink: bool,
+ pub cursor_inactive_style: Option<CursorInactiveStyle>,
+ pub cursor_style: Option<CursorStyle>,
+ pub mac_option_is_meta: bool,
+}
+
+impl Default for WebClientConfig {
+ fn default() -> Self {
+ WebClientConfig {
+ font: "monospace".to_string(),
+ theme: None,
+ cursor_blink: false,
+ cursor_inactive_style: None,
+ cursor_style: None,
+ mac_option_is_meta: true, // TODO: yes? no?
+ }
+ }
+}
+
+impl WebClientConfig {
+ pub fn from_kdl(kdl: &KdlNode) -> Result<Self, ConfigError> {
+ let mut web_client_config = WebClientConfig::default();
+
+ if let Some(font) = kdl_get_child_entry_string_value!(kdl, "font") {
+ web_client_config.font = font.to_owned();
+ }
+
+ if let Some(theme_node) = kdl_get_child!(kdl, "theme") {
+ web_client_config.theme = Some(WebClientTheme::from_kdl(theme_node)?);
+ }
+
+ if let Some(cursor_blink) = kdl_get_child_entry_bool_value!(kdl, "cursor_blink") {
+ web_client_config.cursor_blink = cursor_blink;
+ }
+
+ if let Some(cursor_inactive_style_node) = kdl_get_child!(kdl, "cursor_inactive_style") {
+ web_client_config.cursor_inactive_style =
+ Some(CursorInactiveStyle::from_kdl(cursor_inactive_style_node)?);
+ }
+
+ if let Some(cursor_style_node) = kdl_get_child!(kdl, "cursor_style") {
+ web_client_config.cursor_style = Some(CursorStyle::from_kdl(cursor_style_node)?);
+ }
+
+ if let Some(mac_option_is_meta) = kdl_get_child_entry_bool_value!(kdl, "mac_option_is_meta")
+ {
+ web_client_config.mac_option_is_meta = mac_option_is_meta;
+ }
+
+ Ok(web_client_config)
+ }
+
+ pub fn to_kdl(&self) -> KdlNode {
+ let mut web_client_node = KdlNode::new("web_client");
+ let mut web_client_children = KdlDocument::new();
+
+ let mut font_node = KdlNode::new("font");
+ font_node.push(KdlValue::String(self.font.clone()));
+ web_client_children.nodes_mut().push(font_node);
+
+ if let Some(theme_node) = self.theme.as_ref().map(|t| t.to_kdl()) {
+ web_client_children.nodes_mut().push(theme_node);
+ }
+
+ if self.cursor_blink {
+ // this defaults to false, so we only need to add it if it's true
+ let mut cursor_blink_node = KdlNode::new("cursor_blink");
+ cursor_blink_node.push(KdlValue::Bool(true));
+ web_client_children.nodes_mut().push(cursor_blink_node);
+ }
+
+ if let Some(cursor_inactive_style_node) =
+ self.cursor_inactive_style.as_ref().map(|c| c.to_kdl())
+ {
+ web_client_children
+ .nodes_mut()
+ .push(cursor_inactive_style_node);
+ }
+
+ if let Some(cursor_style_node) = self.cursor_style.as_ref().map(|c| c.to_kdl()) {
+ web_client_children.nodes_mut().push(cursor_style_node);
+ }
+
+ if !self.mac_option_is_meta {
+ // this defaults to true, so we only need to add it if it's false
+ let mut mac_option_is_meta_node = KdlNode::new("mac_option_is_meta");
+ mac_option_is_meta_node.push(KdlValue::Bool(false));
+ web_client_children
+ .nodes_mut()
+ .push(mac_option_is_meta_node);
+ }
+
+ web_client_node.set_children(web_client_children);
+ web_client_node
+ }
+
+ pub fn merge(&self, other: WebClientConfig) -> Self {
+ let mut merged = self.clone();
+ merged.font = other.font;
+ merged.theme = other.theme;
+ merged.cursor_blink = other.cursor_blink;
+ merged.cursor_inactive_style = other.cursor_inactive_style;
+ merged.cursor_style = other.cursor_style;
+ merged.mac_option_is_meta = other.mac_option_is_meta;
+ merged
+ }
+}
diff --git a/zellij-utils/src/ipc.rs b/zellij-utils/src/ipc.rs
index 8725c88c..35699182 100644
--- a/zellij-utils/src/ipc.rs
+++ b/zellij-utils/src/ipc.rs
@@ -79,6 +79,7 @@ pub enum ClientToServerMsg {
Box<Layout>,
Box<PluginAliases>,
bool, // should launch setup wizard
+ bool, // is_web_client
),
AttachClient(
ClientAttributes,
@@ -86,6 +87,7 @@ pub enum ClientToServerMsg {
Options, // represents the runtime configuration
Option<usize>, // tab position to focus
Option<(u32, bool)>, // (pane_id, is_plugin) => pane id to focus
+ bool, // is_web_client
),
Action(Action, Option<u32>, Option<ClientId>), // u32 is the terminal id
Key(KeyWithModifier, Vec<u8>, bool), // key, raw_bytes, is_kitty_keyboard_protocol
@@ -94,6 +96,8 @@ pub enum ClientToServerMsg {
ConnStatus,
ConfigWrittenToDisk(Config),
FailedToWriteConfigToDisk(Option<PathBuf>),
+ WebServerStarted(String), // String -> base_url
+ FailedToStartWebServer(String),
}
// Types of messages sent from the server to the client
@@ -110,6 +114,7 @@ pub enum ServerToClientMsg {
CliPipeOutput(String, String), // String -> pipe name, String -> Output
QueryTerminalSize,
WriteConfigToDisk { config: String },
+ StartWebServer,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -119,6 +124,7 @@ pub enum ExitReason {
ForceDetached,
CannotAttach,
Disconnect,
+ WebClientsForbidden,
Error(String),
}
@@ -135,6 +141,10 @@ impl Display for ExitReason {
f,
"Session attached to another client. Use --force flag to force connect."
),
+ Self::WebClientsForbidden => write!(
+ f,
+ "Web clients are not allowed in this session - cannot attach"
+ ),
Self::Disconnect => {
let session_tip = match crate::envs::get_session_name() {
Ok(name) => format!("`zellij attach {}`", name),
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index e54fb2f5..09b6d957 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -2,7 +2,7 @@ mod kdl_layout_parser;
use crate::data::{
BareKey, Direction, FloatingPaneCoordinates, InputMode, KeyWithModifier, LayoutInfo,
MultiplayerColors, Palette, PaletteColor, PaneInfo, PaneManifest, PermissionType, Resize,
- SessionInfo, StyleDeclaration, Styling, TabInfo, DEFAULT_STYLES,
+ SessionInfo, StyleDeclaration, Styling, TabInfo, WebSharing, DEFAULT_STYLES,
};
use crate::envs::EnvironmentVariables;
use crate::home::{find_default_config_dir, get_layout_dir};
@@ -15,8 +15,10 @@ use crate::input::options::{Clipboard, OnForceClose, Options};
use crate::input::permission::{GrantedPermission, PermissionCache};
use crate::input::plugins::PluginAliases;
use crate::input::theme::{FrameConfig, Theme, Themes, UiConfig};
+use crate::input::web_client::WebClientConfig;
use kdl_layout_parser::KdlLayoutParser;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
+use std::net::{IpAddr, Ipv4Addr};
use strum::IntoEnumIterator;
use uuid::Uuid;
@@ -2295,6 +2297,18 @@ impl Options {
"support_kitty_keyboard_protocol"
)
.map(|(v, _)| v);
+ let web_server =
+ kdl_property_first_arg_as_bool_or_error!(kdl_options, "web_server").map(|(v, _)| v);
+ let web_sharing =
+ match kdl_property_first_arg_as_string_or_error!(kdl_options, "web_sharing") {
+ Some((string, entry)) => Some(WebSharing::from_str(string).map_err(|_| {
+ kdl_parsing_error!(
+ format!("Invalid value for web_sharing: '{}'", string),
+ entry
+ )
+ })?),
+ None => None,
+ };
let stacked_resize =
kdl_property_first_arg_as_bool_or_error!(kdl_options, "stacked_resize").map(|(v, _)| v);
let show_startup_tips =
@@ -2306,6 +2320,29 @@ impl Options {
let advanced_mouse_actions =
kdl_property_first_arg_as_bool_or_error!(kdl_options, "advanced_mouse_actions")
.map(|(v, _)| v);
+ let web_server_ip =
+ match kdl_property_first_arg_as_string_or_error!(kdl_options, "web_server_ip") {
+ Some((string, entry)) => Some(IpAddr::from_str(string).map_err(|_| {
+ kdl_parsing_error!(
+ format!("Invalid value for web_server_ip: '{}'", string),
+ entry
+ )
+ })?),
+ None => None,
+ };
+ let web_server_port =
+ kdl_property_first_arg_as_i64_or_error!(kdl_options, "web_server_port")
+ .map(|(web_server_port, _entry)| web_server_port as u16);
+ let web_server_cert =
+ kdl_property_first_arg_as_string_or_error!(kdl_options, "web_server_cert")
+ .map(|(string, _entry)| PathBuf::from(string));
+ let web_server_key =
+ kdl_property_first_arg_as_string_or_error!(kdl_options, "web_server_key")
+ .map(|(string, _entry)| PathBuf::from(string));
+ let enforce_https_for_localhost =
+ kdl_property_first_arg_as_bool_or_error!(kdl_options, "enforce_https_for_localhost")
+ .map(|(v, _)| v);
+
Ok(Options {
simplified_ui,
theme,
@@ -2334,10 +2371,17 @@ impl Options {
serialization_interval,
disable_session_metadata,
support_kitty_keyboard_protocol,
+ web_server,
+ web_sharing,
stacked_resize,
show_startup_tips,
show_release_notes,
advanced_mouse_actions,
+ web_server_ip,
+ web_server_port,
+ web_server_cert,
+ web_server_key,
+ enforce_https_for_localhost,
})
}
pub fn from_string(stringified_keybindings: &String) -> Result<Self, ConfigError> {
@@ -3137,6 +3181,169 @@ impl Options {
None
}
}
+ fn web_server_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
+ "// Whether to make sure a local web server is running when a new Zellij session starts.",
+ "// This web server will allow creating new sessions and attaching to existing ones that have",
+ "// opted in to being shared in the browser.",
+ "// When enabled, navigate to http://127.0.0.1:8082",
+ "// (Requires restart)",
+ "// ",
+ "// Note: a local web server can still be manually started from within a Zellij session or from the CLI.",
+ "// If this is not desired, one can use a version of Zellij compiled without",
+ "// `web_server_capability`",
+ "// ",
+ "// Possible values:",
+ "// - true",
+ "// - false",
+ "// Default: false",
+ "// ",
+ );
+
+ let create_node = |node_value: bool| -> KdlNode {
+ let mut node = KdlNode::new("web_server");
+ node.push(KdlValue::Bool(node_value));
+ node
+ };
+ if let Some(web_server) = self.web_server {
+ let mut node = create_node(web_server);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(false);
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
+ fn web_sharing_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
+ "// Whether to allow sessions started in the terminal to be shared through a local web server, assuming one is",
+ "// running (see the `web_server` option for more details).",
+ "// (Requires restart)",
+ "// ",
+ "// Note: This is an administrative separation and not intended as a security measure.",
+ "// ",
+ "// Possible values:",
+ "// - \"on\" (allow web sharing through the local web server if it",
+ "// is online)",
+ "// - \"off\" (do not allow web sharing unless sessions explicitly opt-in to it)",
+ "// - \"disabled\" (do not allow web sharing and do not permit sessions started in the terminal to opt-in to it)",
+ "// Default: \"off\"",
+ "// ",
+ );
+
+ let create_node = |node_value: &str| -> KdlNode {
+ let mut node = KdlNode::new("web_sharing");
+ node.push(node_value.to_owned());
+ node
+ };
+ if let Some(web_sharing) = &self.web_sharing {
+ let mut node = match web_sharing {
+ WebSharing::On => create_node("on"),
+ WebSharing::Off => create_node("off"),
+ WebSharing::Disabled => create_node("disabled"),
+ };
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node("off");
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
+ fn web_server_cert_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}",
+ "// A path to a certificate file to be used when setting up the web client to serve the",
+ "// connection over HTTPs",
+ "// ",
+ );
+ let create_node = |node_value: &str| -> KdlNode {
+ let mut node = KdlNode::new("web_server_cert");
+ node.push(node_value.to_owned());
+ node
+ };
+ if let Some(web_server_cert) = &self.web_server_cert {
+ let mut node = create_node(&web_server_cert.display().to_string());
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node("/path/to/cert.pem");
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
+ fn web_server_key_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}",
+ "// A path to a key file to be used when setting up the web client to serve the",
+ "// connection over HTTPs",
+ "// ",
+ );
+ let create_node = |node_value: &str| -> KdlNode {
+ let mut node = KdlNode::new("web_server_key");
+ node.push(node_value.to_owned());
+ node
+ };
+ if let Some(web_server_key) = &self.web_server_key {
+ let mut node = create_node(&web_server_key.display().to_string());
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node("/path/to/key.pem");
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
+ fn enforce_https_for_localhost_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}\n{}\n{}\n{}\n{}",
+ "/// Whether to enforce https connections to the web server when it is bound to localhost",
+ "/// (127.0.0.0/8)",
+ "///",
+ "/// Note: https is ALWAYS enforced when bound to non-local interfaces",
+ "///",
+ "/// Default: false",
+ "// ",
+ );
+
+ let create_node = |node_value: bool| -> KdlNode {
+ let mut node = KdlNode::new("enforce_https_for_localhost");
+ node.push(KdlValue::Bool(node_value));
+ node
+ };
+ if let Some(enforce_https_for_localhost) = self.enforce_https_for_localhost {
+ let mut node = create_node(enforce_https_for_localhost);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(false);
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
fn stacked_resize_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
let comment_text = format!(
"{}\n{}\n{}\n{}",
@@ -3242,6 +3449,62 @@ impl Options {
None
}
}
+ fn web_server_ip_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}\n{}",
+ " ",
+ "// The ip address the web server should listen on when it starts",
+ "// Default: \"127.0.0.1\"",
+ "// (Requires restart)",
+ );
+
+ let create_node = |node_value: IpAddr| -> KdlNode {
+ let mut node = KdlNode::new("web_server_ip");
+ node.push(KdlValue::String(node_value.to_string()));
+ node
+ };
+ if let Some(web_server_ip) = self.web_server_ip {
+ let mut node = create_node(web_server_ip);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
+ fn web_server_port_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}\n{}",
+ " ",
+ "// The port the web server should listen on when it starts",
+ "// Default: 8082",
+ "// (Requires restart)",
+ );
+
+ let create_node = |node_value: u16| -> KdlNode {
+ let mut node = KdlNode::new("web_server_port");
+ node.push(KdlValue::Base10(node_value as i64));
+ node
+ };
+ if let Some(web_server_port) = self.web_server_port {
+ let mut node = create_node(web_server_port);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(8082);
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
pub fn to_kdl(&self, add_comments: bool) -> Vec<KdlNode> {
let mut nodes = vec![];
if let Some(simplified_ui_node) = self.simplified_ui_to_kdl(add_comments) {
@@ -3329,6 +3592,23 @@ impl Options {
{
nodes.push(support_kitty_keyboard_protocol);
}
+ if let Some(web_server) = self.web_server_to_kdl(add_comments) {
+ nodes.push(web_server);
+ }
+ if let Some(web_sharing) = self.web_sharing_to_kdl(add_comments) {
+ nodes.push(web_sharing);
+ }
+ if let Some(web_server_cert) = self.web_server_cert_to_kdl(add_comments) {
+ nodes.push(web_server_cert);
+ }
+ if let Some(web_server_key) = self.web_server_key_to_kdl(add_comments) {
+ nodes.push(web_server_key);
+ }
+ if let Some(enforce_https_for_localhost) =
+ self.enforce_https_for_localhost_to_kdl(add_comments)
+ {
+ nodes.push(enforce_https_for_localhost);
+ }
if let Some(stacked_resize) = self.stacked_resize_to_kdl(add_comments) {
nodes.push(stacked_resize);
}
@@ -3341,6 +3621,12 @@ impl Options {
if let Some(advanced_mouse_actions) = self.advanced_mouse_actions_to_kdl(add_comments) {
nodes.push(advanced_mouse_actions);
}
+ if let Some(web_server_ip) = self.web_server_ip_to_kdl(add_comments) {
+ nodes.push(web_server_ip);
+ }
+ if let Some(web_server_port) = self.web_server_port_to_kdl(add_comments) {
+ nodes.push(web_server_port);
+ }
nodes
}
}
@@ -3830,6 +4116,10 @@ impl Config {
let config_env = EnvironmentVariables::from_kdl(&env_config)?;
config.env = config.env.merge(config_env);
}
+ if let Some(web_client_config) = kdl_config.get("web_client") {
+ let config_web_client = WebClientConfig::from_kdl(&web_client_config)?;
+ config.web_client = config.web_client.merge(config_web_client);
+ }
Ok(config)
}
pub fn to_string(&self, add_comments: bool) -> String {
@@ -3857,6 +4147,8 @@ impl Config {
document.nodes_mut().push(env);
}
+ document.nodes_mut().push(self.web_client.to_kdl());
+
document
.nodes_mut()
.append(&mut self.options.to_kdl(add_comments));
@@ -4499,6 +4791,17 @@ impl SessionInfo {
.collect()
})
.ok_or("Failed to parse available_layouts")?;
+ let web_client_count = kdl_document
+ .get("web_client_count")
+ .and_then(|n| n.entries().iter().next())
+ .and_then(|e| e.value().as_i64())
+ .map(|c| c as usize)
+ .unwrap_or(0);
+ let web_clients_allowed = kdl_document
+ .get("web_clients_allowed")
+ .and_then(|n| n.entries().iter().next())
+ .and_then(|e| e.value().as_bool())
+ .unwrap_or(false);
let is_current_session = name == current_session_name;
let mut tab_history = BTreeMap::new();
if let Some(kdl_tab_history) = kdl_document.get("tab_history").and_then(|p| p.children()) {
@@ -4530,6 +4833,8 @@ impl SessionInfo {
connected_clients,
is_current_session,
available_layouts,
+ web_client_count,
+ web_clients_allowed,
plugins: Default::default(), // we do not serialize plugin information
tab_history,
})
@@ -4556,6 +4861,12 @@ impl SessionInfo {
let mut panes = KdlNode::new("panes");
panes.set_children(self.panes.encode_to_kdl());
+ let mut web_client_count = KdlNode::new("web_client_count");
+ web_client_count.push(self.web_client_count as i64);
+
+ let mut web_clients_allowed = KdlNode::new("web_clients_allowed");
+ web_clients_allowed.push(self.web_clients_allowed);
+
let mut available_layouts = KdlNode::new("available_layouts");
let mut available_layouts_children = KdlDocument::new();
for layout_info in &self.available_layouts {
@@ -4594,6 +4905,8 @@ impl SessionInfo {
kdl_document.nodes_mut().push(tabs);
kdl_document.nodes_mut().push(panes);
kdl_document.nodes_mut().push(connected_clients);
+ kdl_document.nodes_mut().push(web_clients_allowed);
+ kdl_document.nodes_mut().push(web_client_count);
kdl_document.nodes_mut().push(available_layouts);
kdl_document.nodes_mut().push(tab_history);
kdl_document.fmt();
@@ -5151,6 +5464,8 @@ fn serialize_and_deserialize_session_info_with_data() {
LayoutInfo::File("layout3".to_owned()),
],
plugins: Default::default(),
+ web_client_count: 2,
+ web_clients_allowed: true,
tab_history: Default::default(),
};
let serialized = session_info.to_string();
@@ -5914,6 +6229,8 @@ fn config_options_to_string() {
serialization_interval 1
disable_session_metadata true
support_kitty_keyboard_protocol false
+ web_server true
+ web_sharing "disabled"
"##;
let document: KdlDocument = fake_config.parse().unwrap();
let deserialized = Options::from_kdl(&document).unwrap();
@@ -5959,6 +6276,8 @@ fn config_options_to_string_with_comments() {
serialization_interval 1
disable_session_metadata true
support_kitty_keyboard_protocol false
+ web_server true
+ web_sharing "disabled"
"##;
let document: KdlDocument = fake_config.parse().unwrap();
let deserialized = Options::from_kdl(&document).unwrap();
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
index e3b5eaa4..b4f43c49 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
@@ -121,6 +121,13 @@ keybinds clear-defaults=true {
}
SwitchToMode "normal"
}
+ bind "s" {
+ LaunchOrFocusPlugin "zellij:share" {
+ floating true
+ move_to_focused_tab true
+ }
+ SwitchToMode "normal"
+ }
bind "w" {
LaunchOrFocusPlugin "session-manager" {
floating true
@@ -257,3 +264,6 @@ plugins {
}
load_plugins {
}
+web_client {
+ font "monospace"
+}
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
index c7295555..e8770241 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
@@ -121,6 +121,13 @@ keybinds clear-defaults=true {
}
SwitchToMode "normal"
}
+ bind "s" {
+ LaunchOrFocusPlugin "zellij:share" {
+ floating true
+ move_to_focused_tab true
+ }
+ SwitchToMode "normal"
+ }
bind "w" {
LaunchOrFocusPlugin "session-manager" {
floating true
@@ -264,6 +271,9 @@ plugins {
// eg. "https://example.com/my-plugin.wasm"
load_plugins {
}
+web_client {
+ font "monospace"
+}
// Use a simplified UI without special fonts (arrow glyphs)
// Options:
@@ -441,6 +451,52 @@ load_plugins {
// Default: true (if the host terminal supports it)
//
// support_kitty_keyboard_protocol false
+// Whether to make sure a local web server is running when a new Zellij session starts.
+// This web server will allow creating new sessions and attaching to existing ones that have
+// opted in to being shared in the browser.
+// When enabled, navigate to http://127.0.0.1:8082
+// (Requires restart)
+//
+// Note: a local web server can still be manually started from within a Zellij session or from the CLI.
+// If this is not desired, one can use a version of Zellij compiled without
+// `web_server_capability`
+//
+// Possible values:
+// - true
+// - false
+// Default: false
+//
+// web_server false
+// Whether to allow sessions started in the terminal to be shared through a local web server, assuming one is
+// running (see the `web_server` option for more details).
+// (Requires restart)
+//
+// Note: This is an administrative separation and not intended as a security measure.
+//
+// Possible values:
+// - "on" (allow web sharing through the local web server if it
+// is online)
+// - "off" (do not allow web sharing unless sessions explicitly opt-in to it)
+// - "disabled" (do not allow web sharing and do not permit sessions started in the terminal to opt-in to it)
+// Default: "off"
+//
+// web_sharing "off"
+// A path to a certificate file to be used when setting up the web client to serve the
+// connection over HTTPs
+//
+// web_server_cert "/path/to/cert.pem"
+// A path to a key file to be used when setting up the web client to serve the
+// connection over HTTPs
+//
+// web_server_key "/path/to/key.pem"
+/// Whether to enforce https connections to the web server when it is bound to localhost
+/// (127.0.0.0/8)
+///
+/// Note: https is ALWAYS enforced when bound to non-local interfaces
+///
+/// Default: false
+//
+// enforce_https_for_localhost false
// Whether to stack panes when resizing beyond a certain size
// Default: true
@@ -460,3 +516,13 @@ load_plugins {
// Whether to enable mouse hover effects and pane grouping functionality
// default is true
// advanced_mouse_actions false
+
+// The ip address the web server should listen on when it starts
+// Default: "127.0.0.1"
+// (Requires restart)
+// web_server_ip "127.0.0.1"
+
+// The port the web server should listen on when it starts
+// Default: 8082
+// (Requires restart)
+// web_server_port 8082
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap
index e7f95a87..6c78ea96 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap
@@ -1,6 +1,5 @@
---
source: zellij-utils/src/kdl/mod.rs
-assertion_line: 4182
expression: fake_document.to_string()
---
simplified_ui true
@@ -30,4 +29,5 @@ styled_underlines false
serialization_interval 1
disable_session_metadata true
support_kitty_keyboard_protocol false
-
+web_server true
+web_sharing "disabled"
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
index d6750cad..1d9ec280 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
@@ -179,6 +179,52 @@ disable_session_metadata true
// Default: true (if the host terminal supports it)
//
support_kitty_keyboard_protocol false
+// Whether to make sure a local web server is running when a new Zellij session starts.
+// This web server will allow creating new sessions and attaching to existing ones that have
+// opted in to being shared in the browser.
+// When enabled, navigate to http://127.0.0.1:8082
+// (Requires restart)
+//
+// Note: a local web server can still be manually started from within a Zellij session or from the CLI.
+// If this is not desired, one can use a version of Zellij compiled without
+// `web_server_capability`
+//
+// Possible values:
+// - true
+// - false
+// Default: false
+//
+web_server true
+// Whether to allow sessions started in the terminal to be shared through a local web server, assuming one is
+// running (see the `web_server` option for more details).
+// (Requires restart)
+//
+// Note: This is an administrative separation and not intended as a security measure.
+//
+// Possible values:
+// - "on" (allow web sharing through the local web server if it
+// is online)
+// - "off" (do not allow web sharing unless sessions explicitly opt-in to it)
+// - "disabled" (do not allow web sharing and do not permit sessions started in the terminal to opt-in to it)
+// Default: "off"
+//
+web_sharing "disabled"
+// A path to a certificate file to be used when setting up the web client to serve the
+// connection over HTTPs
+//
+// web_server_cert "/path/to/cert.pem"
+// A path to a key file to be used when setting up the web client to serve the
+// connection over HTTPs
+//
+// web_server_key "/path/to/key.pem"
+/// Whether to enforce https connections to the web server when it is bound to localhost
+/// (127.0.0.0/8)
+///
+/// Note: https is ALWAYS enforced when bound to non-local interfaces
+///
+/// Default: false
+//
+// enforce_https_for_localhost false
// Whether to stack panes when resizing beyond a certain size
// Default: true
@@ -198,3 +244,13 @@ support_kitty_keyboard_protocol false
// Whether to enable mouse hover effects and pane grouping functionality
// default is true
// advanced_mouse_actions false
+
+// The ip address the web server should listen on when it starts
+// Default: "127.0.0.1"
+// (Requires restart)
+// web_server_ip "127.0.0.1"
+
+// The port the web server should listen on when it starts
+// Default: 8082
+// (Requires restart)
+// web_server_port 8082
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info.snap
index aaaa6ba7..088504af 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info.snap
@@ -1,6 +1,5 @@
---
source: zellij-utils/src/kdl/mod.rs
-assertion_line: 5000
expression: serialized
---
name ""
@@ -9,8 +8,9 @@ tabs {
panes {
}
connected_clients 0
+web_clients_allowed false
+web_client_count 0
available_layouts {
}
tab_history {
}
-
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info_with_data.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info_with_data.snap
index a0401386..cd4ff687 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info_with_data.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__serialize_and_deserialize_session_info_with_data.snap
@@ -1,6 +1,5 @@
---
source: zellij-utils/src/kdl/mod.rs
-assertion_line: 5111
expression: serialized
---
name "my session name"
@@ -90,6 +89,8 @@ panes {
}
}
connected_clients 2
+web_clients_allowed true
+web_client_count 2
available_layouts {
layout1 source="file"
layout2 source="built-in"
@@ -97,4 +98,3 @@ available_layouts {
}
tab_history {
}
-
diff --git a/zellij-utils/src/lib.rs b/zellij-utils/src/lib.rs
index ec717390..be2e0459 100644
--- a/zellij-utils/src/lib.rs
+++ b/zellij-utils/src/lib.rs
@@ -24,6 +24,12 @@ pub mod downloader; // Requires async_std
pub mod ipc; // Requires interprocess
#[cfg(not(target_family = "wasm"))]
pub mod logging; // Requires log4rs
+#[cfg(not(target_family = "wasm"))]
+pub mod sessions;
+#[cfg(all(not(target_family = "wasm"), feature = "web_server_capability"))]
+pub mod web_authentication_tokens;
+#[cfg(all(not(target_family = "wasm"), feature = "web_server_capability"))]
+pub mod web_server_commands;
// TODO(hartan): Remove this re-export for the next minor release.
pub use ::prost;
diff --git a/zellij-utils/src/logging.rs b/zellij-utils/src/logging.rs
index 4580483c..197b8c9c 100644
--- a/zellij-utils/src/logging.rs
+++ b/zellij-utils/src/logging.rs
@@ -73,6 +73,12 @@ pub fn configure_logger() {
let config = Config::builder()
.appender(Appender::builder().build("logFile", Box::new(log_file)))
.appender(Appender::builder().build("logPlugin", Box::new(log_plugin)))
+ // reduce the verbosity of isahc, otherwise it logs on every failed web request
+ .logger(
+ Logger::builder()
+ .appender("logFile")
+ .build("isahc", LevelFilter::Error),
+ )
.logger(
Logger::builder()
.appender("logPlugin")
diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto
index 760aee0b..52c1801c 100644
--- a/zellij-utils/src/plugin_api/event.proto
+++ b/zellij-utils/src/plugin_api/event.proto
@@ -54,8 +54,10 @@ enum EventType {
FailedToChangeHostFolder = 28;
PastedText = 29;
ConfigWasWrittenToDisk = 30;
- BeforeClose = 31;
- InterceptedKeyPress = 32;
+ WebServerStatus = 31;
+ BeforeClose = 32;
+ FailedToStartWebServer = 34;
+ InterceptedKeyPress = 35;
}
message EventNameList {
@@ -90,14 +92,31 @@ message Event {
HostFolderChangedPayload host_folder_changed_payload = 24;
FailedToChangeHostFolderPayload failed_to_change_host_folder_payload = 25;
PastedTextPayload pasted_text_payload = 26;
- key.Key intercepted_key_payload = 27;
+ WebServerStatusPayload web_server_status_payload = 27;
+ FailedToStartWebServerPayload failed_to_start_web_server_payload = 28;
+ key.Key intercepted_key_payload = 29;
}
}
+message FailedToStartWebServerPayload {
+ string error = 1;
+}
+
message PastedTextPayload {
string pasted_text = 1;
}
+enum WebServerStatusIndication {
+ Online = 0;
+ Offline = 1;
+ DifferentVersion = 2;
+}
+
+message WebServerStatusPayload {
+ WebServerStatusIndication web_server_status_indication = 1;
+ optional string payload = 2;
+}
+
message FailedToChangeHostFolderPayload {
optional string error_message = 1;
}
@@ -260,7 +279,9 @@ message SessionManifest {
bool is_current_session = 5;
repeated LayoutInfo available_layouts = 6;
repeated PluginInfo plugins = 7;
- repeated ClientTabHistory tab_history = 8;
+ bool web_clients_allowed = 8;
+ uint32 web_client_count = 9;
+ repeated ClientTabHistory tab_history = 10;
}
message ClientTabHistory {
@@ -343,7 +364,19 @@ message ModeUpdatePayload {
optional input_mode.InputMode base_mode = 6;
optional string editor = 7;
optional string shell = 8;
- optional bool currently_marking_pane_group = 9;
+ optional bool web_clients_allowed = 9;
+ optional WebSharing web_sharing = 10;
+ optional bool currently_marking_pane_group = 11;
+ optional bool is_web_client = 12;
+ optional string web_server_ip = 13;
+ optional uint32 web_server_port = 14;
+ optional bool web_server_capability = 15;
+}
+
+enum WebSharing {
+ On = 0;
+ Off = 1;
+ Disabled = 2;
}
message InputModeKeybinds {
diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs
index a853e12d..e363b86c 100644
--- a/zellij-utils/src/plugin_api/event.rs
+++ b/zellij-utils/src/plugin_api/event.rs
@@ -10,7 +10,9 @@ pub use super::generated_api::api::{
PaneId as ProtobufPaneId, PaneInfo as ProtobufPaneInfo,
PaneManifest as ProtobufPaneManifest, PaneType as ProtobufPaneType,
PluginInfo as ProtobufPluginInfo, ResurrectableSession as ProtobufResurrectableSession,
- SessionManifest as ProtobufSessionManifest, TabInfo as ProtobufTabInfo, *,
+ SessionManifest as ProtobufSessionManifest, TabInfo as ProtobufTabInfo,
+ WebServerStatusPayload as ProtobufWebServerStatusPayload, WebSharing as ProtobufWebSharing,
+ *,
},
input_mode::InputMode as ProtobufInputMode,
key::Key as ProtobufKey,
@@ -20,7 +22,7 @@ pub use super::generated_api::api::{
use crate::data::{
ClientInfo, CopyDestination, Event, EventType, FileMetadata, InputMode, KeyWithModifier,
LayoutInfo, ModeInfo, Mouse, PaneId, PaneInfo, PaneManifest, PermissionStatus,
- PluginCapabilities, PluginInfo, SessionInfo, Style, TabInfo,
+ PluginCapabilities, PluginInfo, SessionInfo, Style, TabInfo, WebServerStatus, WebSharing,
};
use crate::errors::prelude::*;
@@ -28,7 +30,9 @@ use crate::input::actions::Action;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom;
+use std::net::IpAddr;
use std::path::PathBuf;
+use std::str::FromStr;
use std::time::Duration;
impl TryFrom<ProtobufEvent> for Event {
@@ -359,10 +363,24 @@ impl TryFrom<ProtobufEvent> for Event {
None => Ok(Event::ConfigWasWrittenToDisk),
_ => Err("Malformed payload for the ConfigWasWrittenToDisk Event"),
},
+ Some(ProtobufEventType::WebServerStatus) => match protobuf_event.payload {
+ Some(ProtobufEventPayload::WebServerStatusPayload(web_server_status)) => {
+ Ok(Event::WebServerStatus(web_server_status.try_into()?))
+ },
+ _ => Err("Malformed payload for the WebServerStatus Event"),
+ },
Some(ProtobufEventType::BeforeClose) => match protobuf_event.payload {
None => Ok(Event::BeforeClose),
_ => Err("Malformed payload for the BeforeClose Event"),
},
+ Some(ProtobufEventType::FailedToStartWebServer) => match protobuf_event.payload {
+ Some(ProtobufEventPayload::FailedToStartWebServerPayload(
+ failed_to_start_web_server_payload,
+ )) => Ok(Event::FailedToStartWebServer(
+ failed_to_start_web_server_payload.error,
+ )),
+ _ => Err("Malformed payload for the FailedToStartWebServer Event"),
+ },
Some(ProtobufEventType::InterceptedKeyPress) => match protobuf_event.payload {
Some(ProtobufEventPayload::KeyPayload(protobuf_key)) => {
Ok(Event::InterceptedKeyPress(protobuf_key.try_into()?))
@@ -743,10 +761,22 @@ impl TryFrom<Event> for ProtobufEvent {
name: ProtobufEventType::ConfigWasWrittenToDisk as i32,
payload: None,
}),
+ Event::WebServerStatus(web_server_status) => Ok(ProtobufEvent {
+ name: ProtobufEventType::WebServerStatus as i32,
+ payload: Some(event::Payload::WebServerStatusPayload(
+ ProtobufWebServerStatusPayload::try_from(web_server_status)?,
+ )),
+ }),
Event::BeforeClose => Ok(ProtobufEvent {
name: ProtobufEventType::BeforeClose as i32,
payload: None,
}),
+ Event::FailedToStartWebServer(error) => Ok(ProtobufEvent {
+ name: ProtobufEventType::FailedToStartWebServer as i32,
+ payload: Some(event::Payload::FailedToStartWebServerPayload(
+ FailedToStartWebServerPayload { error },
+ )),
+ }),
Event::InterceptedKeyPress(key) => Ok(ProtobufEvent {
name: ProtobufEventType::InterceptedKeyPress as i32,
payload: Some(event::Payload::KeyPayload(key.try_into()?)),
@@ -789,6 +819,8 @@ impl TryFrom<SessionInfo> for ProtobufSessionManifest {
.into_iter()
.map(|p| ProtobufPluginInfo::from(p))
.collect(),
+ web_clients_allowed: session_info.web_clients_allowed,
+ web_client_count: session_info.web_client_count as u32,
tab_history: session_info
.tab_history
.into_iter()
@@ -878,6 +910,8 @@ impl TryFrom<ProtobufSessionManifest> for SessionInfo {
.filter_map(|l| LayoutInfo::try_from(l).ok())
.collect(),
plugins,
+ web_clients_allowed: protobuf_session_manifest.web_clients_allowed,
+ web_client_count: protobuf_session_manifest.web_client_count as usize,
tab_history,
})
}
@@ -1249,11 +1283,29 @@ impl TryFrom<ProtobufModeUpdatePayload> for ModeInfo {
.editor
.map(|e| PathBuf::from(e));
let shell = protobuf_mode_update_payload.shell.map(|s| PathBuf::from(s));
+ let web_clients_allowed = protobuf_mode_update_payload.web_clients_allowed;
+ let web_sharing = protobuf_mode_update_payload
+ .web_sharing
+ .and_then(|w| ProtobufWebSharing::from_i32(w))
+ .map(|w| w.into());
let capabilities = PluginCapabilities {
arrow_fonts: protobuf_mode_update_payload.arrow_fonts_support,
};
let currently_marking_pane_group =
protobuf_mode_update_payload.currently_marking_pane_group;
+ let is_web_client = protobuf_mode_update_payload.is_web_client;
+
+ let web_server_ip = protobuf_mode_update_payload
+ .web_server_ip
+ .as_ref()
+ .and_then(|web_server_ip| IpAddr::from_str(web_server_ip).ok());
+
+ let web_server_port = protobuf_mode_update_payload
+ .web_server_port
+ .map(|w| w as u16);
+
+ let web_server_capability = protobuf_mode_update_payload.web_server_capability;
+
let mode_info = ModeInfo {
mode: current_mode,
keybinds,
@@ -1263,7 +1315,13 @@ impl TryFrom<ProtobufModeUpdatePayload> for ModeInfo {
base_mode,
editor,
shell,
+ web_clients_allowed,
+ web_sharing,
currently_marking_pane_group,
+ is_web_client,
+ web_server_ip,
+ web_server_port,
+ web_server_capability,
};
Ok(mode_info)
}
@@ -1281,7 +1339,13 @@ impl TryFrom<ModeInfo> for ProtobufModeUpdatePayload {
let session_name = mode_info.session_name;
let editor = mode_info.editor.map(|e| e.display().to_string());
let shell = mode_info.shell.map(|s| s.display().to_string());
+ let web_clients_allowed = mode_info.web_clients_allowed;
+ let web_sharing = mode_info.web_sharing.map(|w| w as i32);
let currently_marking_pane_group = mode_info.currently_marking_pane_group;
+ let is_web_client = mode_info.is_web_client;
+ let web_server_ip = mode_info.web_server_ip.map(|i| format!("{}", i));
+ let web_server_port = mode_info.web_server_port.map(|p| p as u32);
+ let web_server_capability = mode_info.web_server_capability;
let mut protobuf_input_mode_keybinds: Vec<ProtobufInputModeKeybinds> = vec![];
for (input_mode, input_mode_keybinds) in mode_info.keybinds {
let mode: ProtobufInputMode = input_mode.try_into()?;
@@ -1315,7 +1379,13 @@ impl TryFrom<ModeInfo> for ProtobufModeUpdatePayload {
base_mode: base_mode.map(|b_m| b_m as i32),
editor,
shell,
+ web_clients_allowed,
+ web_sharing,
currently_marking_pane_group,
+ is_web_client,
+ web_server_ip,
+ web_server_port,
+ web_server_capability,
})
}
}
@@ -1385,7 +1455,9 @@ impl TryFrom<ProtobufEventType> for EventType {
ProtobufEventType::FailedToChangeHostFolder => EventType::FailedToChangeHostFolder,
ProtobufEventType::PastedText => EventType::PastedText,
ProtobufEventType::ConfigWasWrittenToDisk => EventType::ConfigWasWrittenToDisk,
+ ProtobufEventType::WebServerStatus => EventType::WebServerStatus,
ProtobufEventType::BeforeClose => EventType::BeforeClose,
+ ProtobufEventType::FailedToStartWebServer => EventType::FailedToStartWebServer,
ProtobufEventType::InterceptedKeyPress => EventType::InterceptedKeyPress,
})
}
@@ -1426,7 +1498,9 @@ impl TryFrom<EventType> for ProtobufEventType {
EventType::FailedToChangeHostFolder => ProtobufEventType::FailedToChangeHostFolder,
EventType::PastedText => ProtobufEventType::PastedText,
EventType::ConfigWasWrittenToDisk => ProtobufEventType::ConfigWasWrittenToDisk,
+ EventType::WebServerStatus => ProtobufEventType::WebServerStatus,
EventType::BeforeClose => ProtobufEventType::BeforeClose,
+ EventType::FailedToStartWebServer => ProtobufEventType::FailedToStartWebServer,
EventType::InterceptedKeyPress => ProtobufEventType::InterceptedKeyPress,
})
}
@@ -1568,7 +1642,13 @@ fn serialize_mode_update_event_with_non_default_values() {
base_mode: Some(InputMode::Locked),
editor: Some(PathBuf::from("my_awesome_editor")),
shell: Some(PathBuf::from("my_awesome_shell")),
+ web_clients_allowed: Some(true),
+ web_sharing: Some(WebSharing::default()),
currently_marking_pane_group: Some(false),
+ is_web_client: Some(false),
+ web_server_ip: IpAddr::from_str("127.0.0.1").ok(),
+ web_server_port: Some(8082),
+ web_server_capability: Some(true),
});
let protobuf_event: ProtobufEvent = mode_update_event.clone().try_into().unwrap();
let serialized_protobuf_event = protobuf_event.encode_to_vec();
@@ -2016,6 +2096,8 @@ fn serialize_session_update_event_with_non_default_values() {
LayoutInfo::File("layout3".to_owned()),
],
plugins,
+ web_clients_allowed: false,
+ web_client_count: 1,
tab_history,
};
let session_info_2 = SessionInfo {
@@ -2032,6 +2114,8 @@ fn serialize_session_update_event_with_non_default_values() {
LayoutInfo::File("layout3".to_owned()),
],
plugins: Default::default(),
+ web_clients_allowed: false,
+ web_client_count: 0,
tab_history: Default::default(),
};
let session_infos = vec![session_info_1, session_info_2];
@@ -2081,3 +2165,69 @@ impl TryFrom<PaneId> for ProtobufPaneId {
}
}
}
+
+impl Into<ProtobufWebSharing> for WebSharing {
+ fn into(self) -> ProtobufWebSharing {
+ match self {
+ WebSharing::On => ProtobufWebSharing::On,
+ WebSharing::Off => ProtobufWebSharing::Off,
+ WebSharing::Disabled => ProtobufWebSharing::Disabled,
+ }
+ }
+}
+
+impl Into<WebSharing> for ProtobufWebSharing {
+ fn into(self) -> WebSharing {
+ match self {
+ ProtobufWebSharing::On => WebSharing::On,
+ ProtobufWebSharing::Off => WebSharing::Off,
+ ProtobufWebSharing::Disabled => WebSharing::Disabled,
+ }
+ }
+}
+
+impl TryFrom<WebServerStatus> for ProtobufWebServerStatusPayload {
+ type Error = &'static str;
+ fn try_from(web_server_status: WebServerStatus) -> Result<Self, &'static str> {
+ match web_server_status {
+ WebServerStatus::Online(url) => Ok(ProtobufWebServerStatusPayload {
+ web_server_status_indication: WebServerStatusIndication::Online as i32,
+ payload: Some(url),
+ }),
+ WebServerStatus::DifferentVersion(version) => Ok(ProtobufWebServerStatusPayload {
+ web_server_status_indication: WebServerStatusIndication::DifferentVersion as i32,
+ payload: Some(format!("{}", version)),
+ }),
+ WebServerStatus::Offline => Ok(ProtobufWebServerStatusPayload {
+ web_server_status_indication: WebServerStatusIndication::Offline as i32,
+ payload: None,
+ }),
+ }
+ }
+}
+
+impl TryFrom<ProtobufWebServerStatusPayload> for WebServerStatus {
+ type Error = &'static str;
+ fn try_from(
+ protobuf_web_server_status: ProtobufWebServerStatusPayload,
+ ) -> Result<Self, &'static str> {
+ match WebServerStatusIndication::from_i32(
+ protobuf_web_server_status.web_server_status_indication,
+ ) {
+ Some(WebServerStatusIndication::Online) => {
+ let payload = protobuf_web_server_status
+ .payload
+ .ok_or("payload_not_found")?;
+ Ok(WebServerStatus::Online(payload))
+ },
+ Some(WebServerStatusIndication::DifferentVersion) => {
+ let payload = protobuf_web_server_status
+ .payload
+ .ok_or("payload_not_found")?;
+ Ok(WebServerStatus::DifferentVersion(payload))
+ },
+ Some(WebServerStatusIndication::Offline) => Ok(WebServerStatus::Offline),
+ None => Err("Unknown status"),
+ }
+ }
+}
diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto
index b8b6bad1..4c1db71f 100644
--- a/zellij-utils/src/plugin_api/plugin_command.proto
+++ b/zellij-utils/src/plugin_api/plugin_command.proto
@@ -140,13 +140,24 @@ enum CommandName {
OpenFileNearPlugin = 124;
OpenFileFloatingNearPlugin = 125;
OpenFileInPlaceOfPlugin = 126;
- GroupAndUngroupPanes = 127;
- HighlightAndUnhighlightPanes = 128;
- CloseMultiplePanes = 129;
- FloatMultiplePanes = 130;
- EmbedMultiplePanes = 131;
- InterceptKeyPresses = 132;
- ClearKeyPressesIntercepts = 133;
+ StartWebServer = 127;
+ GroupAndUngroupPanes = 128;
+ HighlightAndUnhighlightPanes = 129;
+ CloseMultiplePanes = 130;
+ FloatMultiplePanes = 131;
+ EmbedMultiplePanes = 132;
+ ShareCurrentSession = 133;
+ StopSharingCurrentSession = 134;
+ StopWebServer = 135;
+ QueryWebServerStatus = 136;
+ SetSelfMouseSelectionSupport = 137;
+ GenerateWebLoginToken = 138;
+ RevokeWebLoginToken = 139;
+ ListWebLoginTokens = 140;
+ RevokeAllWebLoginTokens = 141;
+ RenameWebLoginToken = 142;
+ InterceptKeyPresses = 143;
+ ClearKeyPressesIntercepts = 144;
}
message PluginCommand {
@@ -248,9 +259,30 @@ message PluginCommand {
CloseMultiplePanesPayload close_multiple_panes_payload = 104;
FloatMultiplePanesPayload float_multiple_panes_payload = 105;
EmbedMultiplePanesPayload embed_multiple_panes_payload = 106;
+ SetSelfMouseSelectionSupportPayload set_self_mouse_selection_support_payload = 107;
+ GenerateWebLoginTokenPayload generate_web_login_token_payload = 108;
+ RevokeWebLoginTokenPayload revoke_web_login_token_payload = 109;
+ RenameWebLoginTokenPayload rename_web_login_token_payload = 110;
}
}
+message RenameWebLoginTokenPayload {
+ string old_name = 1;
+ string new_name = 2;
+}
+
+message RevokeWebLoginTokenPayload {
+ string token_label = 1;
+}
+
+message GenerateWebLoginTokenPayload {
+ optional string token_label = 1;
+}
+
+message SetSelfMouseSelectionSupportPayload {
+ bool support_mouse_selection = 1;
+}
+
message EmbedMultiplePanesPayload {
repeated PaneId pane_ids = 1;
}
@@ -628,3 +660,31 @@ enum FixedOrPercent {
Fixed = 0;
Percent = 1;
}
+
+message CreateTokenResponse {
+ optional string token = 1;
+ optional string token_label = 2;
+ optional string error = 3;
+}
+
+message RevokeTokenResponse {
+ bool successfully_revoked = 1;
+ optional string error = 2;
+}
+
+message ListTokensResponse {
+ // tokens/creation_times should be synchronized
+ repeated string tokens = 1;
+ repeated string creation_times = 2;
+ optional string error = 3;
+}
+
+message RevokeAllWebTokensResponse {
+ bool successfully_revoked = 1;
+ optional string error = 2;
+}
+
+message RenameWebTokenResponse {
+ bool successfully_renamed = 1;
+ optional string error = 2;
+}
diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs
index 30a6a03d..7fb583b7 100644
--- a/zellij-utils/src/plugin_api/plugin_command.rs
+++ b/zellij-utils/src/plugin_api/plugin_command.rs
@@ -6,15 +6,17 @@ pub use super::generated_api::api::{
plugin_command::Payload, BreakPanesToNewTabPayload, BreakPanesToTabWithIndexPayload,
ChangeFloatingPanesCoordinatesPayload, ChangeHostFolderPayload,
ClearScreenForPaneIdPayload, CliPipeOutputPayload, CloseMultiplePanesPayload,
- CloseTabWithIndexPayload, CommandName, ContextItem, EditScrollbackForPaneWithIdPayload,
- EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload,
+ CloseTabWithIndexPayload, CommandName, ContextItem,
+ CreateTokenResponse as ProtobufCreateTokenResponse, CreateTokenResponse,
+ EditScrollbackForPaneWithIdPayload, EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload,
FixedOrPercent as ProtobufFixedOrPercent,
FixedOrPercentValue as ProtobufFixedOrPercentValue, FloatMultiplePanesPayload,
- FloatingPaneCoordinates as ProtobufFloatingPaneCoordinates, GroupAndUngroupPanesPayload,
- HidePaneWithIdPayload, HighlightAndUnhighlightPanesPayload, HttpVerb as ProtobufHttpVerb,
- IdAndNewName, KeyToRebind, KeyToUnbind, KillSessionsPayload, LoadNewPluginPayload,
- MessageToPluginPayload, MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload,
- MovePayload, NewPluginArgs as ProtobufNewPluginArgs, NewTabsWithLayoutInfoPayload,
+ FloatingPaneCoordinates as ProtobufFloatingPaneCoordinates, GenerateWebLoginTokenPayload,
+ GroupAndUngroupPanesPayload, HidePaneWithIdPayload, HighlightAndUnhighlightPanesPayload,
+ HttpVerb as ProtobufHttpVerb, IdAndNewName, KeyToRebind, KeyToUnbind, KillSessionsPayload,
+ ListTokensResponse, LoadNewPluginPayload, MessageToPluginPayload,
+ MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, MovePayload,
+ NewPluginArgs as ProtobufNewPluginArgs, NewTabsWithLayoutInfoPayload,
OpenCommandPaneFloatingNearPluginPayload, OpenCommandPaneInPlaceOfPluginPayload,
OpenCommandPaneNearPluginPayload, OpenCommandPanePayload,
OpenFileFloatingNearPluginPayload, OpenFileInPlaceOfPluginPayload,
@@ -23,10 +25,12 @@ pub use super::generated_api::api::{
PageScrollDownInPaneIdPayload, PageScrollUpInPaneIdPayload, PaneId as ProtobufPaneId,
PaneIdAndFloatingPaneCoordinates, PaneType as ProtobufPaneType,
PluginCommand as ProtobufPluginCommand, PluginMessagePayload, RebindKeysPayload,
- ReconfigurePayload, ReloadPluginPayload, RequestPluginPermissionPayload,
- RerunCommandPanePayload, ResizePaneIdWithDirectionPayload, ResizePayload,
- RunCommandPayload, ScrollDownInPaneIdPayload, ScrollToBottomInPaneIdPayload,
- ScrollToTopInPaneIdPayload, ScrollUpInPaneIdPayload, SetFloatingPanePinnedPayload,
+ ReconfigurePayload, ReloadPluginPayload, RenameWebLoginTokenPayload,
+ RenameWebTokenResponse, RequestPluginPermissionPayload, RerunCommandPanePayload,
+ ResizePaneIdWithDirectionPayload, ResizePayload, RevokeAllWebTokensResponse,
+ RevokeTokenResponse, RevokeWebLoginTokenPayload, RunCommandPayload,
+ ScrollDownInPaneIdPayload, ScrollToBottomInPaneIdPayload, ScrollToTopInPaneIdPayload,
+ ScrollUpInPaneIdPayload, SetFloatingPanePinnedPayload, SetSelfMouseSelectionSupportPayload,
SetTimeoutPayload, ShowPaneWithIdPayload, StackPanesPayload, SubscribePayload,
SwitchSessionPayload, SwitchTabToPayload, TogglePaneEmbedOrEjectForPaneIdPayload,
TogglePaneIdFullscreenPayload, UnsubscribePayload, WebRequestPayload,
@@ -1545,6 +1549,27 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
},
_ => Err("Mismatched payload for OpenFileInPlaceOfPlugin"),
},
+ Some(CommandName::StartWebServer) => {
+ if protobuf_plugin_command.payload.is_some() {
+ Err("StartWebServer should not have a payload")
+ } else {
+ Ok(PluginCommand::StartWebServer)
+ }
+ },
+ Some(CommandName::StopWebServer) => {
+ if protobuf_plugin_command.payload.is_some() {
+ Err("StopWebServer should not have a payload")
+ } else {
+ Ok(PluginCommand::StopWebServer)
+ }
+ },
+ Some(CommandName::QueryWebServerStatus) => {
+ if protobuf_plugin_command.payload.is_some() {
+ Err("QueryWebServerStatus should not have a payload")
+ } else {
+ Ok(PluginCommand::QueryWebServerStatus)
+ }
+ },
Some(CommandName::GroupAndUngroupPanes) => match protobuf_plugin_command.payload {
Some(Payload::GroupAndUngroupPanesPayload(group_and_ungroup_panes_payload)) => {
Ok(PluginCommand::GroupAndUngroupPanes(
@@ -1617,6 +1642,67 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
},
_ => Err("Mismatched payload for EmbedMultiplePanes"),
},
+ Some(CommandName::ShareCurrentSession) => {
+ if protobuf_plugin_command.payload.is_some() {
+ Err("ShareCurrentSession should not have a payload")
+ } else {
+ Ok(PluginCommand::ShareCurrentSession)
+ }
+ },
+ Some(CommandName::StopSharingCurrentSession) => {
+ if protobuf_plugin_command.payload.is_some() {
+ Err("StopSharingCurrentSession should not have a payload")
+ } else {
+ Ok(PluginCommand::StopSharingCurrentSession)
+ }
+ },
+ Some(CommandName::SetSelfMouseSelectionSupport) => {
+ match protobuf_plugin_command.payload {
+ Some(Payload::SetSelfMouseSelectionSupportPayload(
+ set_self_mouse_selection_support_payload,
+ )) => Ok(PluginCommand::SetSelfMouseSelectionSupport(
+ set_self_mouse_selection_support_payload.support_mouse_selection,
+ )),
+ _ => Err("SetSelfMouseSelectionSupport requires a payload"),
+ }
+ },
+ Some(CommandName::GenerateWebLoginToken) => match protobuf_plugin_command.payload {
+ Some(Payload::GenerateWebLoginTokenPayload(generate_web_login_token_payload)) => {
+ Ok(PluginCommand::GenerateWebLoginToken(
+ generate_web_login_token_payload.token_label,
+ ))
+ },
+ _ => Err("GenerateWebLoginToken requires a payload"),
+ },
+ Some(CommandName::RevokeWebLoginToken) => match protobuf_plugin_command.payload {
+ Some(Payload::RevokeWebLoginTokenPayload(revoke_web_login_token_payload)) => Ok(
+ PluginCommand::RevokeWebLoginToken(revoke_web_login_token_payload.token_label),
+ ),
+ _ => Err("RevokeWebLoginToken requires a payload"),
+ },
+ Some(CommandName::ListWebLoginTokens) => {
+ if protobuf_plugin_command.payload.is_some() {
+ Err("ListWebLoginTokens should not have a payload")
+ } else {
+ Ok(PluginCommand::ListWebLoginTokens)
+ }
+ },
+ Some(CommandName::RevokeAllWebLoginTokens) => {
+ if protobuf_plugin_command.payload.is_some() {
+ Err("RevokeAllWebLoginTokens should not have a payload")
+ } else {
+ Ok(PluginCommand::RevokeAllWebLoginTokens)
+ }
+ },
+ Some(CommandName::RenameWebLoginToken) => match protobuf_plugin_command.payload {
+ Some(Payload::RenameWebLoginTokenPayload(rename_web_login_token_payload)) => {
+ Ok(PluginCommand::RenameWebLoginToken(
+ rename_web_login_token_payload.old_name,
+ rename_web_login_token_payload.new_name,
+ ))
+ },
+ _ => Err("RenameWebLoginToken requires a payload"),
+ },
Some(CommandName::InterceptKeyPresses) => match protobuf_plugin_command.payload {
Some(_) => Err("InterceptKeyPresses should have no payload, found a payload"),
None => Ok(PluginCommand::InterceptKeyPresses),
@@ -2656,6 +2742,18 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
)),
})
},
+ PluginCommand::StartWebServer => Ok(ProtobufPluginCommand {
+ name: CommandName::StartWebServer as i32,
+ payload: None,
+ }),
+ PluginCommand::StopWebServer => Ok(ProtobufPluginCommand {
+ name: CommandName::StopWebServer as i32,
+ payload: None,
+ }),
+ PluginCommand::QueryWebServerStatus => Ok(ProtobufPluginCommand {
+ name: CommandName::QueryWebServerStatus as i32,
+ payload: None,
+ }),
PluginCommand::HighlightAndUnhighlightPanes(
panes_to_highlight,
panes_to_unhighlight,
@@ -2698,6 +2796,50 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
},
)),
}),
+ PluginCommand::ShareCurrentSession => Ok(ProtobufPluginCommand {
+ name: CommandName::ShareCurrentSession as i32,
+ payload: None,
+ }),
+ PluginCommand::StopSharingCurrentSession => Ok(ProtobufPluginCommand {
+ name: CommandName::StopSharingCurrentSession as i32,
+ payload: None,
+ }),
+ PluginCommand::SetSelfMouseSelectionSupport(support_mouse_selection) => {
+ Ok(ProtobufPluginCommand {
+ name: CommandName::SetSelfMouseSelectionSupport as i32,
+ payload: Some(Payload::SetSelfMouseSelectionSupportPayload(
+ SetSelfMouseSelectionSupportPayload {
+ support_mouse_selection,
+ },
+ )),
+ })
+ },
+ PluginCommand::GenerateWebLoginToken(token_label) => Ok(ProtobufPluginCommand {
+ name: CommandName::GenerateWebLoginToken as i32,
+ payload: Some(Payload::GenerateWebLoginTokenPayload(
+ GenerateWebLoginTokenPayload { token_label },
+ )),
+ }),
+ PluginCommand::RevokeWebLoginToken(token_label) => Ok(ProtobufPluginCommand {
+ name: CommandName::RevokeWebLoginToken as i32,
+ payload: Some(Payload::RevokeWebLoginTokenPayload(
+ RevokeWebLoginTokenPayload { token_label },
+ )),
+ }),
+ PluginCommand::ListWebLoginTokens => Ok(ProtobufPluginCommand {
+ name: CommandName::ListWebLoginTokens as i32,
+ payload: None,
+ }),
+ PluginCommand::RevokeAllWebLoginTokens => Ok(ProtobufPluginCommand {
+ name: CommandName::RevokeAllWebLoginTokens as i32,
+ payload: None,
+ }),
+ PluginCommand::RenameWebLoginToken(old_name, new_name) => Ok(ProtobufPluginCommand {
+ name: CommandName::RenameWebLoginToken as i32,
+ payload: Some(Payload::RenameWebLoginTokenPayload(
+ RenameWebLoginTokenPayload { old_name, new_name },
+ )),
+ }),
PluginCommand::InterceptKeyPresses => Ok(ProtobufPluginCommand {
name: CommandName::InterceptKeyPresses as i32,
payload: None,
diff --git a/zellij-utils/src/plugin_api/plugin_permission.proto b/zellij-utils/src/plugin_api/plugin_permission.proto
index 72c5b0f9..0f53bb55 100644
--- a/zellij-utils/src/plugin_api/plugin_permission.proto
+++ b/zellij-utils/src/plugin_api/plugin_permission.proto
@@ -14,5 +14,6 @@ enum PermissionType {
MessageAndLaunchOtherPlugins = 8;
Reconfigure = 9;
FullHdAccess = 10;
- InterceptInput = 11;
+ StartWebServer = 11;
+ InterceptInput = 12;
}
diff --git a/zellij-utils/src/plugin_api/plugin_permission.rs b/zellij-utils/src/plugin_api/plugin_permission.rs
index ad64b499..926f993f 100644
--- a/zellij-utils/src/plugin_api/plugin_permission.rs
+++ b/zellij-utils/src/plugin_api/plugin_permission.rs
@@ -26,6 +26,7 @@ impl TryFrom<ProtobufPermissionType> for PermissionType {
},
ProtobufPermissionType::Reconfigure => Ok(PermissionType::Reconfigure),
ProtobufPermissionType::FullHdAccess => Ok(PermissionType::FullHdAccess),
+ ProtobufPermissionType::StartWebServer => Ok(PermissionType::StartWebServer),
ProtobufPermissionType::InterceptInput => Ok(PermissionType::InterceptInput),
}
}
@@ -54,6 +55,7 @@ impl TryFrom<PermissionType> for ProtobufPermissionType {
},
PermissionType::Reconfigure => Ok(ProtobufPermissionType::Reconfigure),
PermissionType::FullHdAccess => Ok(ProtobufPermissionType::FullHdAccess),
+ PermissionType::StartWebServer => Ok(ProtobufPermissionType::StartWebServer),
PermissionType::InterceptInput => Ok(ProtobufPermissionType::InterceptInput),
}
}
diff --git a/zellij-utils/src/sessions.rs b/zellij-utils/src/sessions.rs
new file mode 100644
index 00000000..5ecc73db
--- /dev/null
+++ b/zellij-utils/src/sessions.rs
@@ -0,0 +1,624 @@
+use crate::{
+ consts::{
+ session_info_folder_for_session, session_layout_cache_file_name,
+ ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR,
+ },
+ envs,
+ input::layout::Layout,
+ ipc::{ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ServerToClientMsg},
+};
+use anyhow;
+use humantime::format_duration;
+use interprocess::local_socket::LocalSocketStream;
+use std::collections::HashMap;
+use std::os::unix::fs::FileTypeExt;
+use std::time::{Duration, SystemTime};
+use std::{fs, io, process};
+use suggest::Suggest;
+
+pub fn get_sessions() -> Result<Vec<(String, Duration)>, io::ErrorKind> {
+ match fs::read_dir(&*ZELLIJ_SOCK_DIR) {
+ Ok(files) => {
+ let mut sessions = Vec::new();
+ files.for_each(|file| {
+ let file = file.unwrap();
+ let file_name = file.file_name().into_string().unwrap();
+ let ctime = std::fs::metadata(&file.path())
+ .ok()
+ .and_then(|f| f.created().ok())
+ .and_then(|d| d.elapsed().ok())
+ .unwrap_or_default();
+ let duration = Duration::from_secs(ctime.as_secs());
+ if file.file_type().unwrap().is_socket() && assert_socket(&file_name) {
+ sessions.push((file_name, duration));
+ }
+ });
+ Ok(sessions)
+ },
+ Err(err) if io::ErrorKind::NotFound != err.kind() => Err(err.kind()),
+ Err(_) => Ok(Vec::with_capacity(0)),
+ }
+}
+
+pub fn get_resurrectable_sessions() -> Vec<(String, Duration, Layout)> {
+ match fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) {
+ Ok(files_in_session_info_folder) => {
+ let files_that_are_folders = files_in_session_info_folder
+ .filter_map(|f| f.ok().map(|f| f.path()))
+ .filter(|f| f.is_dir());
+ files_that_are_folders
+ .filter_map(|folder_name| {
+ let layout_file_name =
+ session_layout_cache_file_name(&folder_name.display().to_string());
+ let raw_layout = match std::fs::read_to_string(&layout_file_name) {
+ Ok(raw_layout) => raw_layout,
+ Err(_e) => {
+ return None;
+ },
+ };
+ let ctime = match std::fs::metadata(&layout_file_name)
+ .and_then(|metadata| metadata.created())
+ {
+ Ok(created) => Some(created),
+ Err(_e) => None,
+ };
+ let layout = match Layout::from_kdl(
+ &raw_layout,
+ Some(layout_file_name.display().to_string()),
+ None,
+ None,
+ ) {
+ Ok(layout) => layout,
+ Err(e) => {
+ log::error!("Failed to parse resurrection layout file: {}", e);
+ return None;
+ },
+ };
+ let elapsed_duration = ctime
+ .map(|ctime| {
+ Duration::from_secs(ctime.elapsed().ok().unwrap_or_default().as_secs())
+ })
+ .unwrap_or_default();
+ let session_name = folder_name
+ .file_name()
+ .map(|f| std::path::PathBuf::from(f).display().to_string())?;
+ Some((session_name, elapsed_duration, layout))
+ })
+ .collect()
+ },
+ Err(e) => {
+ log::error!(
+ "Failed to read session_info cache folder: \"{:?}\": {:?}",
+ &*ZELLIJ_SESSION_INFO_CACHE_DIR,
+ e
+ );
+ vec![]
+ },
+ }
+}
+
+pub fn get_resurrectable_session_names() -> Vec<String> {
+ match fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) {
+ Ok(files_in_session_info_folder) => {
+ let files_that_are_folders = files_in_session_info_folder
+ .filter_map(|f| f.ok().map(|f| f.path()))
+ .filter(|f| f.is_dir());
+ files_that_are_folders
+ .filter_map(|folder_name| {
+ let folder = folder_name.display().to_string();
+ let resurrection_layout_file = session_layout_cache_file_name(&folder);
+ if std::path::Path::new(&resurrection_layout_file).exists() {
+ folder_name
+ .file_name()
+ .map(|f| format!("{}", f.to_string_lossy()))
+ } else {
+ None
+ }
+ })
+ .collect()
+ },
+ Err(e) => {
+ log::error!(
+ "Failed to read session_info cache folder: \"{:?}\": {:?}",
+ &*ZELLIJ_SESSION_INFO_CACHE_DIR,
+ e
+ );
+ vec![]
+ },
+ }
+}
+
+pub fn get_sessions_sorted_by_mtime() -> anyhow::Result<Vec<String>> {
+ match fs::read_dir(&*ZELLIJ_SOCK_DIR) {
+ Ok(files) => {
+ let mut sessions_with_mtime: Vec<(String, SystemTime)> = Vec::new();
+ for file in files {
+ let file = file?;
+ let file_name = file.file_name().into_string().unwrap();
+ let file_modified_at = file.metadata()?.modified()?;
+ if file.file_type()?.is_socket() && assert_socket(&file_name) {
+ sessions_with_mtime.push((file_name, file_modified_at));
+ }
+ }
+ sessions_with_mtime.sort_by_key(|x| x.1); // the oldest one will be the first
+
+ let sessions = sessions_with_mtime.iter().map(|x| x.0.clone()).collect();
+ Ok(sessions)
+ },
+ Err(err) if io::ErrorKind::NotFound != err.kind() => Err(err.into()),
+ Err(_) => Ok(Vec::with_capacity(0)),
+ }
+}
+
+fn assert_socket(name: &str) -> bool {
+ let path = &*ZELLIJ_SOCK_DIR.join(name);
+ match LocalSocketStream::connect(path) {
+ Ok(stream) => {
+ let mut sender = IpcSenderWithContext::new(stream);
+ let _ = sender.send(ClientToServerMsg::ConnStatus);
+ let mut receiver: IpcReceiverWithContext<ServerToClientMsg> = sender.get_receiver();
+ match receiver.recv() {
+ Some((ServerToClientMsg::Connected, _)) => true,
+ None | Some((_, _)) => false,
+ }
+ },
+ Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => {
+ drop(fs::remove_file(path));
+ false
+ },
+ Err(_) => false,
+ }
+}
+
+pub fn print_sessions(
+ mut sessions: Vec<(String, Duration, bool)>,
+ no_formatting: bool,
+ short: bool,
+ reverse: bool,
+) {
+ // (session_name, timestamp, is_dead)
+ let curr_session = envs::get_session_name().unwrap_or_else(|_| "".into());
+ sessions.sort_by(|a, b| {
+ if reverse {
+ // sort by `Duration` ascending (newest would be first)
+ a.1.cmp(&b.1)
+ } else {
+ b.1.cmp(&a.1)
+ }
+ });
+ sessions
+ .iter()
+ .for_each(|(session_name, timestamp, is_dead)| {
+ if short {
+ println!("{}", session_name);
+ return;
+ }
+ if no_formatting {
+ let suffix = if curr_session == *session_name {
+ format!("(current)")
+ } else if *is_dead {
+ format!("(EXITED - attach to resurrect)")
+ } else {
+ String::new()
+ };
+ let timestamp = format!("[Created {} ago]", format_duration(*timestamp));
+ println!("{} {} {}", session_name, timestamp, suffix);
+ } else {
+ let formatted_session_name = format!("\u{1b}[32;1m{}\u{1b}[m", session_name);
+ let suffix = if curr_session == *session_name {
+ format!("(current)")
+ } else if *is_dead {
+ format!("(\u{1b}[31;1mEXITED\u{1b}[m - attach to resurrect)")
+ } else {
+ String::new()
+ };
+ let timestamp = format!(
+ "[Created \u{1b}[35;1m{}\u{1b}[m ago]",
+ format_duration(*timestamp)
+ );
+ println!("{} {} {}", formatted_session_name, timestamp, suffix);
+ }
+ })
+}
+
+pub fn print_sessions_with_index(sessions: Vec<String>) {
+ let curr_session = envs::get_session_name().unwrap_or_else(|_| "".into());
+ for (i, session) in sessions.iter().enumerate() {
+ let suffix = if curr_session == *session {
+ " (current)"
+ } else {
+ ""
+ };
+ println!("{}: {}{}", i, session, suffix);
+ }
+}
+
+pub enum ActiveSession {
+ None,
+ One(String),
+ Many,
+}
+
+pub fn get_active_session() -> ActiveSession {
+ match get_sessions() {
+ Ok(sessions) if sessions.is_empty() => ActiveSession::None,
+ Ok(mut sessions) if sessions.len() == 1 => ActiveSession::One(sessions.pop().unwrap().0),
+ Ok(_) => ActiveSession::Many,
+ Err(e) => {
+ eprintln!("Error occurred: {:?}", e);
+ process::exit(1);
+ },
+ }
+}
+
+pub fn kill_session(name: &str) {
+ let path = &*ZELLIJ_SOCK_DIR.join(name);
+ match LocalSocketStream::connect(path) {
+ Ok(stream) => {
+ let _ = IpcSenderWithContext::new(stream).send(ClientToServerMsg::KillSession);
+ },
+ Err(e) => {
+ eprintln!("Error occurred: {:?}", e);
+ process::exit(1);
+ },
+ };
+}
+
+pub fn delete_session(name: &str, force: bool) {
+ if force {
+ let path = &*ZELLIJ_SOCK_DIR.join(name);
+ let _ = LocalSocketStream::connect(path).map(|stream| {
+ IpcSenderWithContext::new(stream)
+ .send(ClientToServerMsg::KillSession)
+ .ok();
+ });
+ }
+ if let Err(e) = std::fs::remove_dir_all(session_info_folder_for_session(name)) {
+ if e.kind() == std::io::ErrorKind::NotFound {
+ eprintln!("Session: {:?} not found.", name);
+ process::exit(2);
+ } else {
+ log::error!("Failed to remove session {:?}: {:?}", name, e);
+ }
+ } else {
+ println!("Session: {:?} successfully deleted.", name);
+ }
+}
+
+pub fn list_sessions(no_formatting: bool, short: bool, reverse: bool) {
+ let exit_code = match get_sessions() {
+ Ok(running_sessions) => {
+ let resurrectable_sessions = get_resurrectable_sessions();
+ let mut all_sessions: HashMap<String, (Duration, bool)> = resurrectable_sessions
+ .iter()
+ .map(|(name, timestamp, _layout)| (name.clone(), (timestamp.clone(), true)))
+ .collect();
+ for (session_name, duration) in running_sessions {
+ all_sessions.insert(session_name.clone(), (duration, false));
+ }
+ if all_sessions.is_empty() {
+ eprintln!("No active zellij sessions found.");
+ 1
+ } else {
+ print_sessions(
+ all_sessions
+ .iter()
+ .map(|(name, (timestamp, is_dead))| {
+ (name.clone(), timestamp.clone(), *is_dead)
+ })
+ .collect(),
+ no_formatting,
+ short,
+ reverse,
+ );
+ 0
+ }
+ },
+ Err(e) => {
+ eprintln!("Error occurred: {:?}", e);
+ 1
+ },
+ };
+ process::exit(exit_code);
+}
+
+#[derive(Debug, Clone)]
+pub enum SessionNameMatch {
+ AmbiguousPrefix(Vec<String>),
+ UniquePrefix(String),
+ Exact(String),
+ None,
+}
+
+pub fn match_session_name(prefix: &str) -> Result<SessionNameMatch, io::ErrorKind> {
+ let sessions = get_sessions()?;
+
+ let filtered_sessions: Vec<_> = sessions
+ .iter()
+ .filter(|s| s.0.starts_with(prefix))
+ .collect();
+
+ if filtered_sessions.iter().any(|s| s.0 == prefix) {
+ return Ok(SessionNameMatch::Exact(prefix.to_string()));
+ }
+
+ Ok({
+ match &filtered_sessions[..] {
+ [] => SessionNameMatch::None,
+ [s] => SessionNameMatch::UniquePrefix(s.0.to_string()),
+ _ => SessionNameMatch::AmbiguousPrefix(
+ filtered_sessions.into_iter().map(|s| s.0.clone()).collect(),
+ ),
+ }
+ })
+}
+
+pub fn session_exists(name: &str) -> Result<bool, io::ErrorKind> {
+ match match_session_name(name) {
+ Ok(SessionNameMatch::Exact(_)) => Ok(true),
+ Ok(_) => Ok(false),
+ Err(e) => Err(e),
+ }
+}
+
+// if the session is resurrecable, the returned layout is the one to be used to resurrect it
+pub fn resurrection_layout(session_name_to_resurrect: &str) -> Option<Layout> {
+ let resurrectable_sessions = get_resurrectable_sessions();
+ resurrectable_sessions
+ .iter()
+ .find_map(|(name, _timestamp, layout)| {
+ if name == session_name_to_resurrect {
+ Some(layout.clone())
+ } else {
+ None
+ }
+ })
+}
+
+pub fn assert_session(name: &str) {
+ match session_exists(name) {
+ Ok(result) => {
+ if result {
+ return;
+ } else {
+ println!("No session named {:?} found.", name);
+ if let Some(sugg) = get_sessions()
+ .unwrap()
+ .iter()
+ .map(|s| s.0.clone())
+ .collect::<Vec<_>>()
+ .suggest(name)
+ {
+ println!(" help: Did you mean `{}`?", sugg);
+ }
+ }
+ },
+ Err(e) => {
+ eprintln!("Error occurred: {:?}", e);
+ },
+ };
+ process::exit(1);
+}
+
+pub fn assert_dead_session(name: &str, force: bool) {
+ match session_exists(name) {
+ Ok(exists) => {
+ if exists && !force {
+ println!(
+ "A session by the name {:?} exists and is active, use --force to delete it.",
+ name
+ )
+ } else if exists && force {
+ println!("A session by the name {:?} exists and is active, but will be force killed and deleted.", name);
+ return;
+ } else {
+ return;
+ }
+ },
+ Err(e) => {
+ eprintln!("Error occurred: {:?}", e);
+ },
+ };
+ process::exit(1);
+}
+
+pub fn assert_session_ne(name: &str) {
+ if name.trim().is_empty() {
+ eprintln!("Session name cannot be empty. Please provide a specific session name.");
+ process::exit(1);
+ }
+ if name == "." || name == ".." {
+ eprintln!("Invalid session name: \"{}\".", name);
+ process::exit(1);
+ }
+ if name.contains('/') {
+ eprintln!("Session name cannot contain '/'.");
+ process::exit(1);
+ }
+
+ match session_exists(name) {
+ Ok(result) if !result => {
+ let resurrectable_sessions = get_resurrectable_session_names();
+ if resurrectable_sessions.iter().find(|s| s == &name).is_some() {
+ println!("Session with name {:?} already exists, but is dead. Use the attach command to resurrect it or, the delete-session command to kill it or specify a different name.", name);
+ } else {
+ return
+ }
+ }
+ Ok(_) => println!("Session with name {:?} already exists. Use attach command to connect to it or specify a different name.", name),
+ Err(e) => eprintln!("Error occurred: {:?}", e),
+ };
+ process::exit(1);
+}
+
+pub fn generate_unique_session_name() -> Option<String> {
+ let sessions = get_sessions().map(|sessions| {
+ sessions
+ .iter()
+ .map(|s| s.0.clone())
+ .collect::<Vec<String>>()
+ });
+ let dead_sessions = get_resurrectable_session_names();
+ let Ok(sessions) = sessions else {
+ eprintln!("Failed to list existing sessions: {:?}", sessions);
+ return None;
+ };
+
+ let name = get_name_generator()
+ .take(1000)
+ .find(|name| !sessions.contains(name) && !dead_sessions.contains(name));
+
+ if let Some(name) = name {
+ return Some(name);
+ } else {
+ return None;
+ }
+}
+
+/// Create a new random name generator
+///
+/// Used to provide a memorable handle for a session when users don't specify a session name when the session is
+/// created.
+///
+/// Uses the list of adjectives and nouns defined below, with the intention of avoiding unfortunate
+/// and offensive combinations. Care should be taken when adding or removing to either list due to the birthday paradox/
+/// hash collisions, e.g. with 4096 unique names, the likelihood of a collision in 10 session names is 1%.
+pub fn get_name_generator() -> impl Iterator<Item = String> {
+ names::Generator::new(&ADJECTIVES, &NOUNS, names::Name::Plain)
+}
+
+const ADJECTIVES: &[&'static str] = &[
+ "adamant",
+ "adept",
+ "adventurous",
+ "arcadian",
+ "auspicious",
+ "awesome",
+ "blossoming",
+ "brave",
+ "charming",
+ "chatty",
+ "circular",
+ "considerate",
+ "cubic",
+ "curious",
+ "delighted",
+ "didactic",
+ "diligent",
+ "effulgent",
+ "erudite",
+ "excellent",
+ "exquisite",
+ "fabulous",
+ "fascinating",
+ "friendly",
+ "glowing",
+ "gracious",
+ "gregarious",
+ "hopeful",
+ "implacable",
+ "inventive",
+ "joyous",
+ "judicious",
+ "jumping",
+ "kind",
+ "likable",
+ "loyal",
+ "lucky",
+ "marvellous",
+ "mellifluous",
+ "nautical",
+ "oblong",
+ "outstanding",
+ "polished",
+ "polite",
+ "profound",
+ "quadratic",
+ "quiet",
+ "rectangular",
+ "remarkable",
+ "rusty",
+ "sensible",
+ "sincere",
+ "sparkling",
+ "splendid",
+ "stellar",
+ "tenacious",
+ "tremendous",
+ "triangular",
+ "undulating",
+ "unflappable",
+ "unique",
+ "verdant",
+ "vitreous",
+ "wise",
+ "zippy",
+];
+
+const NOUNS: &[&'static str] = &[
+ "aardvark",
+ "accordion",
+ "apple",
+ "apricot",
+ "bee",
+ "brachiosaur",
+ "cactus",
+ "capsicum",
+ "clarinet",
+ "cowbell",
+ "crab",
+ "cuckoo",
+ "cymbal",
+ "diplodocus",
+ "donkey",
+ "drum",
+ "duck",
+ "echidna",
+ "elephant",
+ "foxglove",
+ "galaxy",
+ "glockenspiel",
+ "goose",
+ "hill",
+ "horse",
+ "iguanadon",
+ "jellyfish",
+ "kangaroo",
+ "lake",
+ "lemon",
+ "lemur",
+ "magpie",
+ "megalodon",
+ "mountain",
+ "mouse",
+ "muskrat",
+ "newt",
+ "oboe",
+ "ocelot",
+ "orange",
+ "panda",
+ "peach",
+ "pepper",
+ "petunia",
+ "pheasant",
+ "piano",
+ "pigeon",
+ "platypus",
+ "quasar",
+ "rhinoceros",
+ "river",
+ "rustacean",
+ "salamander",
+ "sitar",
+ "stegosaurus",
+ "tambourine",
+ "tiger",
+ "tomato",
+ "triceratops",
+ "ukulele",
+ "viola",
+ "weasel",
+ "xylophone",
+ "yak",
+ "zebra",
+];
diff --git a/zellij-utils/src/shared.rs b/zellij-utils/src/shared.rs
index 3ef24995..6a5a2c93 100644
--- a/zellij-utils/src/shared.rs
+++ b/zellij-utils/src/shared.rs
@@ -1,10 +1,12 @@
//! Some general utility functions.
+use std::net::{IpAddr, Ipv4Addr};
use std::{iter, str::from_utf8};
use crate::data::{Palette, PaletteColor, PaletteSource, ThemeHue};
use crate::envs::get_session_name;
-use colorsys::Rgb;
+use crate::input::options::Options;
+use colorsys::{Ansi256, Rgb};
use strip_ansi_escapes::strip;
use unicode_width::UnicodeWidthStr;
@@ -87,6 +89,10 @@ pub fn _hex_to_rgb(hex: &str) -> (u8, u8, u8) {
.into()
}
+pub fn eightbit_to_rgb(c: u8) -> (u8, u8, u8) {
+ Ansi256::new(c).as_rgb().into()
+}
+
pub fn default_palette() -> Palette {
Palette {
source: PaletteSource::Default,
@@ -149,3 +155,38 @@ pub fn version_number(mut version: &str) -> usize {
version_number
}
+
+pub fn web_server_base_url(
+ web_server_ip: IpAddr,
+ web_server_port: u16,
+ has_certificate: bool,
+ enforce_https_for_localhost: bool,
+) -> String {
+ let is_loopback = match web_server_ip {
+ IpAddr::V4(ipv4) => ipv4.is_loopback(),
+ IpAddr::V6(ipv6) => ipv6.is_loopback(),
+ };
+
+ let url_prefix = if is_loopback && !enforce_https_for_localhost && !has_certificate {
+ "http"
+ } else {
+ "https"
+ };
+ format!("{}://{}:{}", url_prefix, web_server_ip, web_server_port)
+}
+
+pub fn web_server_base_url_from_config(config_options: Options) -> String {
+ 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);
+ web_server_base_url(
+ web_server_ip,
+ web_server_port,
+ has_certificate,
+ enforce_https_for_localhost,
+ )
+}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
index 50be8410..0bc15ee0 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
@@ -32,8 +32,15 @@ Options {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
index 201b5e64..37d0e455 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
@@ -32,8 +32,15 @@ Options {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
index 94e7a87e..adff6294 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
@@ -30,8 +30,15 @@ Options {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
index 8a09cae9..c29499cb 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
@@ -4441,6 +4441,36 @@ Config {
bare_key: Char(
's',
),
+ key_modifiers: {},
+ }: [
+ LaunchOrFocusPlugin(
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "share",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ true,
+ true,
+ false,
+ false,
+ ),
+ SwitchToMode(
+ Normal,
+ ),
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 's',
+ ),
key_modifiers: {
Ctrl,
},
@@ -5912,10 +5942,17 @@ Config {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
},
themes: {},
plugins: PluginAliases {
@@ -6054,4 +6091,12 @@ Config {
},
env: {},
background_plugins: {},
+ web_client: WebClientConfig {
+ font: "monospace",
+ theme: None,
+ cursor_blink: false,
+ cursor_inactive_style: None,
+ cursor_style: None,
+ mac_option_is_meta: true,
+ },
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
index e4ac2827..f92ee67f 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
@@ -4441,6 +4441,36 @@ Config {
bare_key: Char(
's',
),
+ key_modifiers: {},
+ }: [
+ LaunchOrFocusPlugin(
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "share",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ true,
+ true,
+ false,
+ false,
+ ),
+ SwitchToMode(
+ Normal,
+ ),
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 's',
+ ),
key_modifiers: {
Ctrl,
},
@@ -5912,10 +5942,17 @@ Config {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
},
themes: {},
plugins: PluginAliases {
@@ -6058,4 +6095,12 @@ Config {
"MY_ENV_VAR": "from layout",
},
background_plugins: {},
+ web_client: WebClientConfig {
+ font: "monospace",
+ theme: None,
+ cursor_blink: false,
+ cursor_inactive_style: None,
+ cursor_style: None,
+ mac_option_is_meta: true,
+ },
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
index 14e792a3..7539c152 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
@@ -117,10 +117,17 @@ Config {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
},
themes: {},
plugins: PluginAliases {
@@ -259,4 +266,12 @@ Config {
},
env: {},
background_plugins: {},
+ web_client: WebClientConfig {
+ font: "monospace",
+ theme: None,
+ cursor_blink: false,
+ cursor_inactive_style: None,
+ cursor_style: None,
+ mac_option_is_meta: true,
+ },
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
index ff8ddd98..d9ee9183 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
@@ -32,8 +32,15 @@ Options {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
index 0f330251..2a416240 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
@@ -4441,6 +4441,36 @@ Config {
bare_key: Char(
's',
),
+ key_modifiers: {},
+ }: [
+ LaunchOrFocusPlugin(
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "share",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ true,
+ true,
+ false,
+ false,
+ ),
+ SwitchToMode(
+ Normal,
+ ),
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 's',
+ ),
key_modifiers: {
Ctrl,
},
@@ -5912,10 +5942,17 @@ Config {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
},
themes: {
"other-theme-from-config": Theme {
@@ -7825,4 +7862,12 @@ Config {
},
env: {},
background_plugins: {},
+ web_client: WebClientConfig {
+ font: "monospace",
+ theme: None,
+ cursor_blink: false,
+ cursor_inactive_style: None,
+ cursor_style: None,
+ mac_option_is_meta: true,
+ },
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
index 263c68ee..766e7348 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
@@ -4441,6 +4441,36 @@ Config {
bare_key: Char(
's',
),
+ key_modifiers: {},
+ }: [
+ LaunchOrFocusPlugin(
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "share",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ true,
+ true,
+ false,
+ false,
+ ),
+ SwitchToMode(
+ Normal,
+ ),
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 's',
+ ),
key_modifiers: {
Ctrl,
},
@@ -5912,10 +5942,17 @@ Config {
serialization_interval: None,
disable_session_metadata: None,
support_kitty_keyboard_protocol: None,
+ web_server: None,
+ web_sharing: None,
stacked_resize: None,
show_startup_tips: None,
show_release_notes: None,
advanced_mouse_actions: None,
+ web_server_ip: None,
+ web_server_port: None,
+ web_server_cert: None,
+ web_server_key: None,
+ enforce_https_for_localhost: None,
},
themes: {},
plugins: PluginAliases {
@@ -6054,4 +6091,12 @@ Config {
},
env: {},
background_plugins: {},
+ web_client: WebClientConfig {
+ font: "monospace",
+ theme: None,
+ cursor_blink: false,
+ cursor_inactive_style: None,
+ cursor_style: None,
+ mac_option_is_meta: true,
+ },
}
diff --git a/zellij-utils/src/web_authentication_tokens.rs b/zellij-utils/src/web_authentication_tokens.rs
new file mode 100644
index 00000000..40329688
--- /dev/null
+++ b/zellij-utils/src/web_authentication_tokens.rs
@@ -0,0 +1,349 @@
+// TODO: GATE THIS WHOLE FILE AND RELEVANT DEPS BEHIND web_server_capability
+use crate::consts::ZELLIJ_PROJ_DIR;
+use rusqlite::Connection;
+use sha2::{Digest, Sha256};
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+use uuid::Uuid;
+
+#[derive(Debug)]
+pub struct TokenInfo {
+ pub name: String,
+ pub created_at: String,
+}
+
+#[derive(Debug)]
+pub enum TokenError {
+ Database(rusqlite::Error),
+ Io(std::io::Error),
+ InvalidPath,
+ DuplicateName(String),
+ TokenNotFound(String),
+ InvalidToken,
+}
+
+impl std::fmt::Display for TokenError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ TokenError::Database(e) => write!(f, "Database error: {}", e),
+ TokenError::Io(e) => write!(f, "IO error: {}", e),
+ TokenError::InvalidPath => write!(f, "Invalid path"),
+ TokenError::DuplicateName(name) => write!(f, "Token name '{}' already exists", name),
+ TokenError::TokenNotFound(name) => write!(f, "Token '{}' not found", name),
+ TokenError::InvalidToken => write!(f, "Invalid token"),
+ }
+ }
+}
+
+impl std::error::Error for TokenError {}
+
+impl From<rusqlite::Error> for TokenError {
+ fn from(error: rusqlite::Error) -> Self {
+ match error {
+ rusqlite::Error::SqliteFailure(ffi_error, _)
+ if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
+ {
+ TokenError::DuplicateName("unknown".to_string())
+ },
+ _ => TokenError::Database(error),
+ }
+ }
+}
+
+impl From<std::io::Error> for TokenError {
+ fn from(error: std::io::Error) -> Self {
+ TokenError::Io(error)
+ }
+}
+
+type Result<T> = std::result::Result<T, TokenError>;
+
+fn get_db_path() -> Result<PathBuf> {
+ if cfg!(debug_assertions) {
+ // tests db
+ let data_dir = ZELLIJ_PROJ_DIR.data_dir();
+ std::fs::create_dir_all(&data_dir)?;
+ Ok(data_dir.join("tokens_for_dev.db"))
+ } else {
+ // prod db
+ let data_dir = ZELLIJ_PROJ_DIR.data_dir();
+ std::fs::create_dir_all(data_dir)?;
+ Ok(data_dir.join("tokens.db"))
+ }
+}
+
+fn init_db(conn: &Connection) -> Result<()> {
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ token_hash TEXT UNIQUE NOT NULL,
+ name TEXT UNIQUE NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )",
+ [],
+ )?;
+
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS session_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_token_hash TEXT UNIQUE NOT NULL,
+ auth_token_hash TEXT NOT NULL,
+ remember_me BOOLEAN NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME NOT NULL,
+ FOREIGN KEY (auth_token_hash) REFERENCES tokens(token_hash)
+ )",
+ [],
+ )?;
+
+ Ok(())
+}
+
+fn hash_token(token: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(token.as_bytes());
+ format!("{:x}", hasher.finalize())
+}
+
+pub fn create_token(name: Option<String>) -> Result<(String, String)> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let token = Uuid::new_v4().to_string();
+ let token_hash = hash_token(&token);
+
+ let token_name = if let Some(n) = name {
+ n.to_string()
+ } else {
+ let count: i64 = conn.query_row("SELECT COUNT(*) FROM tokens", [], |row| row.get(0))?;
+ format!("token_{}", count + 1)
+ };
+
+ match conn.execute(
+ "INSERT INTO tokens (token_hash, name) VALUES (?1, ?2)",
+ [&token_hash, &token_name],
+ ) {
+ Err(rusqlite::Error::SqliteFailure(ffi_error, _))
+ if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
+ {
+ Err(TokenError::DuplicateName(token_name))
+ },
+ Err(e) => Err(TokenError::Database(e)),
+ Ok(_) => Ok((token, token_name)),
+ }
+}
+
+pub fn create_session_token(auth_token: &str, remember_me: bool) -> Result<String> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ cleanup_expired_sessions()?;
+
+ let auth_token_hash = hash_token(auth_token);
+
+ let count: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM tokens WHERE token_hash = ?1",
+ [&auth_token_hash],
+ |row| row.get(0),
+ )?;
+
+ if count == 0 {
+ return Err(TokenError::InvalidToken);
+ }
+
+ let session_token = Uuid::new_v4().to_string();
+ let session_token_hash = hash_token(&session_token);
+
+ let expires_at = if remember_me {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+ let four_weeks = 4 * 7 * 24 * 60 * 60;
+ format!("datetime({}, 'unixepoch')", now + four_weeks)
+ } else {
+ // For session-only: very short expiration (e.g., 5 minutes)
+ // The browser will handle the session aspect via cookie expiration
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+ let short_duration = 5 * 60; // 5 minutes
+ format!("datetime({}, 'unixepoch')", now + short_duration)
+ };
+
+ conn.execute(
+ &format!("INSERT INTO session_tokens (session_token_hash, auth_token_hash, remember_me, expires_at) VALUES (?1, ?2, ?3, {})", expires_at),
+ [&session_token_hash, &auth_token_hash, &(remember_me as i64).to_string()],
+ )?;
+
+ Ok(session_token)
+}
+
+pub fn validate_session_token(session_token: &str) -> Result<bool> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let session_token_hash = hash_token(session_token);
+
+ let count: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM session_tokens WHERE session_token_hash = ?1 AND expires_at > datetime('now')",
+ [&session_token_hash],
+ |row| row.get(0),
+ )?;
+
+ Ok(count > 0)
+}
+
+pub fn cleanup_expired_sessions() -> Result<usize> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let rows_affected = conn.execute(
+ "DELETE FROM session_tokens WHERE expires_at <= datetime('now')",
+ [],
+ )?;
+
+ Ok(rows_affected)
+}
+
+pub fn revoke_session_token(session_token: &str) -> Result<bool> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let session_token_hash = hash_token(session_token);
+ let rows_affected = conn.execute(
+ "DELETE FROM session_tokens WHERE session_token_hash = ?1",
+ [&session_token_hash],
+ )?;
+
+ Ok(rows_affected > 0)
+}
+
+pub fn revoke_sessions_for_auth_token(auth_token: &str) -> Result<usize> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let auth_token_hash = hash_token(auth_token);
+ let rows_affected = conn.execute(
+ "DELETE FROM session_tokens WHERE auth_token_hash = ?1",
+ [&auth_token_hash],
+ )?;
+
+ Ok(rows_affected)
+}
+
+pub fn revoke_token(name: &str) -> Result<bool> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let token_hash = match conn.query_row(
+ "SELECT token_hash FROM tokens WHERE name = ?1",
+ [&name],
+ |row| row.get::<_, String>(0),
+ ) {
+ Ok(hash) => Some(hash),
+ Err(rusqlite::Error::QueryReturnedNoRows) => None,
+ Err(e) => return Err(TokenError::Database(e)),
+ };
+
+ if let Some(token_hash) = token_hash {
+ conn.execute(
+ "DELETE FROM session_tokens WHERE auth_token_hash = ?1",
+ [&token_hash],
+ )?;
+ }
+
+ let rows_affected = conn.execute("DELETE FROM tokens WHERE name = ?1", [&name])?;
+ Ok(rows_affected > 0)
+}
+
+pub fn revoke_all_tokens() -> Result<usize> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ conn.execute("DELETE FROM session_tokens", [])?;
+ let rows_affected = conn.execute("DELETE FROM tokens", [])?;
+ Ok(rows_affected)
+}
+
+pub fn rename_token(old_name: &str, new_name: &str) -> Result<()> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let count: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM tokens WHERE name = ?1",
+ [&old_name],
+ |row| row.get(0),
+ )?;
+
+ if count == 0 {
+ return Err(TokenError::TokenNotFound(old_name.to_string()));
+ }
+
+ match conn.execute(
+ "UPDATE tokens SET name = ?1 WHERE name = ?2",
+ [&new_name, &old_name],
+ ) {
+ Err(rusqlite::Error::SqliteFailure(ffi_error, _))
+ if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
+ {
+ Err(TokenError::DuplicateName(new_name.to_string()))
+ },
+ Err(e) => Err(TokenError::Database(e)),
+ Ok(_) => Ok(()),
+ }
+}
+
+pub fn list_tokens() -> Result<Vec<TokenInfo>> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let mut stmt = conn.prepare("SELECT name, created_at FROM tokens ORDER BY created_at")?;
+ let rows = stmt.query_map([], |row| {
+ Ok(TokenInfo {
+ name: row.get::<_, String>(0)?,
+ created_at: row.get::<_, String>(1)?,
+ })
+ })?;
+
+ let mut tokens = Vec::new();
+ for token in rows {
+ tokens.push(token?);
+ }
+ Ok(tokens)
+}
+
+pub fn delete_db() -> Result<()> {
+ let db_path = get_db_path()?;
+ if db_path.exists() {
+ std::fs::remove_file(db_path)?;
+ }
+ Ok(())
+}
+
+pub fn validate_token(token: &str) -> Result<bool> {
+ let db_path = get_db_path()?;
+ let conn = Connection::open(db_path)?;
+ init_db(&conn)?;
+
+ let token_hash = hash_token(token);
+
+ let count: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM tokens WHERE token_hash = ?1",
+ [&token_hash],
+ |row| row.get(0),
+ )?;
+ Ok(count > 0)
+}
diff --git a/zellij-utils/src/web_server_commands.rs b/zellij-utils/src/web_server_commands.rs
new file mode 100644
index 00000000..e2125eac
--- /dev/null
+++ b/zellij-utils/src/web_server_commands.rs
@@ -0,0 +1,60 @@
+use crate::consts::WEBSERVER_SOCKET_PATH;
+use crate::errors::prelude::*;
+use crate::input::config::Config;
+use interprocess::local_socket::LocalSocketStream;
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::io::{self, BufWriter, Write};
+use std::os::unix::fs::FileTypeExt;
+
+pub fn shutdown_all_webserver_instances() -> Result<()> {
+ let entries = fs::read_dir(&*WEBSERVER_SOCKET_PATH)?;
+
+ for entry in entries {
+ let entry = entry?;
+ let path = entry.path();
+
+ if let Some(file_name) = path.file_name() {
+ if let Some(_file_name_str) = file_name.to_str() {
+ let metadata = entry.metadata()?;
+ let file_type = metadata.file_type();
+
+ if file_type.is_socket() {
+ match create_webserver_sender(path.to_str().unwrap_or("")) {
+ Ok(mut sender) => {
+ let _ = send_webserver_instruction(
+ &mut sender,
+ InstructionForWebServer::ShutdownWebServer,
+ );
+ },
+ Err(_) => {
+ // no-op
+ },
+ }
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub enum InstructionForWebServer {
+ ShutdownWebServer,
+ ConfigWrittenToDisk(Config),
+}
+
+pub fn create_webserver_sender(path: &str) -> Result<BufWriter<LocalSocketStream>> {
+ let stream = LocalSocketStream::connect(path)?;
+ Ok(BufWriter::new(stream))
+}
+
+pub fn send_webserver_instruction(
+ sender: &mut BufWriter<LocalSocketStream>,
+ instruction: InstructionForWebServer,
+) -> Result<()> {
+ rmp_serde::encode::write(sender, &instruction)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+ sender.flush()?;
+ Ok(())
+}