diff options
| author | Aram Drevekenin <aram@poor.dev> | 2025-06-23 19:19:37 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-23 19:19:37 +0200 |
| commit | c5ac7968809ddb1033b625562eae22019cccc2f4 (patch) | |
| tree | 932cf07d898dfbcc6ec6a9d4cf5353c06a47accc /zellij-utils/src | |
| parent | feat(ui): add optional tooltip with key hints to the compact bar (#4225) (diff) | |
| download | sl0ppij-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')
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(()) +} |
