diff options
| author | Thomas Linford <tlinford@users.noreply.github.com> | 2026-01-21 15:47:47 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-21 15:47:47 +0100 |
| commit | 85f3b9df8ec0cbdd607cd721dde97c9d0769ff85 (patch) | |
| tree | ed9f2774ccc1b43569055b1cc1de4e262824cecc | |
| parent | Fix query webserver from IPC rather than HTTPS (#4620) (diff) | |
| download | sl0ppij-85f3b9df8ec0cbdd607cd721dde97c9d0769ff85.tar.gz sl0ppij-85f3b9df8ec0cbdd607cd721dde97c9d0769ff85.tar.xz sl0ppij-85f3b9df8ec0cbdd607cd721dde97c9d0769ff85.zip | |
feat(web): add base_url option (#4409)
* feat(web): add base_url option
* fix tests
* update changelog
13 files changed, 196 insertions, 99 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 916215ec..00064c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * fix: allow specifying "0%" coordinates when opening panes wit "zellij run --floating" (https://github.com/zellij-org/zellij/pull/4609) * feat: allow opening panes as `borderless` or toggling/changing the borderless status of existing panes (https://github.com/zellij-org/zellij/pull/4615 and https://github.com/zellij-org/zellij/pull/4618) * fix: query webserver through ipc rather than https (https://github.com/zellij-org/zellij/pull/4620) +* feat: add web client `base_url` option (https://github.com/zellij-org/zellij/pull/4409) ## [0.43.1] - 2025-08-08 diff --git a/zellij-client/assets/auth.js b/zellij-client/assets/auth.js index 8a8de4bf..14c971b8 100644 --- a/zellij-client/assets/auth.js +++ b/zellij-client/assets/auth.js @@ -2,7 +2,7 @@ * Authentication logic and token management */ -import { is_https } from './utils.js'; +import { getBaseUrl } from "./utils.js"; /** * Wait for user to provide a security token @@ -11,17 +11,20 @@ import { is_https } from './utils.js'; async function waitForSecurityToken() { let token = null; let remember = null; - + while (!token) { let result = await getSecurityToken(); if (result) { token = result.token; remember = result.remember; } else { - await showErrorModal("Error", "Must provide security token in order to log in."); + await showErrorModal( + "Error", + "Must provide security token in order to log in." + ); } } - + return { token, remember }; } @@ -33,43 +36,52 @@ async function waitForSecurityToken() { * @returns {Promise<string|null>} Client ID or null on failure */ export async function getClientId(token, rememberMe, hasAuthenticationCookie) { - let url_prefix = is_https() ? "https" : "http"; - + const baseUrl = getBaseUrl(); + if (!hasAuthenticationCookie) { - let login_res = await fetch(`${url_prefix}://${window.location.host}/command/login`, { - method: 'POST', + let login_res = await fetch(`${baseUrl}/command/login`, { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ auth_token: token, - remember_me: rememberMe ? true : false + remember_me: rememberMe ? true : false, }), - credentials: 'include' + credentials: "include", }); if (login_res.status === 401) { - await showErrorModal("Error", "Unauthorized or revoked login token."); + await showErrorModal( + "Error", + "Unauthorized or revoked login token." + ); return null; } else if (!login_res.ok) { - await showErrorModal("Error", `Error ${login_res.status} connecting to server.`); + await showErrorModal( + "Error", + `Error ${login_res.status} connecting to server.` + ); return null; } } - - let data = await fetch(`${url_prefix}://${window.location.host}/session`, { + + let data = await fetch(`${baseUrl}/session`, { method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({}), }); - + if (data.status === 401) { await showErrorModal("Error", "Unauthorized or revoked login token."); return null; } else if (!data.ok) { - await showErrorModal("Error", `Error ${data.status} connecting to server.`); + await showErrorModal( + "Error", + `Error ${data.status} connecting to server.` + ); return null; } else { let body = await data.json(); @@ -85,17 +97,21 @@ export async function initAuthentication() { let token = null; let remember = null; let hasAuthenticationCookie = window.is_authenticated; - + if (!hasAuthenticationCookie) { const tokenResult = await waitForSecurityToken(); token = tokenResult.token; remember = tokenResult.remember; } - + let webClientId; - + while (!webClientId) { - webClientId = await getClientId(token, remember, hasAuthenticationCookie); + webClientId = await getClientId( + token, + remember, + hasAuthenticationCookie + ); if (!webClientId) { hasAuthenticationCookie = false; const tokenResult = await waitForSecurityToken(); @@ -103,6 +119,6 @@ export async function initAuthentication() { remember = tokenResult.remember; } } - + return webClientId; } diff --git a/zellij-client/assets/connection.js b/zellij-client/assets/connection.js index 7284d873..c507df5e 100644 --- a/zellij-client/assets/connection.js +++ b/zellij-client/assets/connection.js @@ -2,7 +2,7 @@ * Connection-related utility functions and management */ -import { is_https } from './utils.js'; +import { getBaseUrl } from "./utils.js"; // Connection state let reconnectionAttempt = 0; @@ -27,10 +27,10 @@ export function getReconnectionDelay(attempt) { */ export async function checkConnection() { try { - let url_prefix = is_https() ? "https" : "http"; - const response = await fetch(`${url_prefix}://${window.location.host}/info/version`, { - method: 'GET', - timeout: 5000 + const baseUrl = getBaseUrl(); + const response = await fetch(`${baseUrl}/info/version`, { + method: "GET", + timeout: 5000, }); return response.ok; } catch (error) { @@ -46,27 +46,30 @@ export async function handleReconnection() { if (isReconnecting || !hasConnectedBefore || isPageUnloading) { return; } - + isReconnecting = true; let currentModal = null; - + while (isReconnecting) { reconnectionAttempt++; const delaySeconds = getReconnectionDelay(reconnectionAttempt); - - const result = await showReconnectionModal(reconnectionAttempt, delaySeconds); - - if (result.action === 'cancel') { + + const result = await showReconnectionModal( + reconnectionAttempt, + delaySeconds + ); + + if (result.action === "cancel") { if (result.cleanup) result.cleanup(); isReconnecting = false; reconnectionAttempt = 0; return; } - - if (result.action === 'reconnect') { + + if (result.action === "reconnect") { currentModal = result.modal; const connectionOk = await checkConnection(); - + if (connectionOk) { if (result.cleanup) result.cleanup(); isReconnecting = false; @@ -85,11 +88,11 @@ export async function handleReconnection() { * Initialize connection handlers and event listeners */ export function initConnectionHandlers() { - window.addEventListener('beforeunload', () => { + window.addEventListener("beforeunload", () => { isPageUnloading = true; }); - window.addEventListener('pagehide', () => { + window.addEventListener("pagehide", () => { isPageUnloading = true; }); } diff --git a/zellij-client/assets/index.html b/zellij-client/assets/index.html index e057c8c5..41d4400b 100644 --- a/zellij-client/assets/index.html +++ b/zellij-client/assets/index.html @@ -1,39 +1,40 @@ <!DOCTYPE html> <html lang="en" style="height: 100%"> <head> - <link rel="icon" href="/assets/favicon.ico" /> + <base href="BASE_URL" /> + <link rel="icon" href="assets/favicon.ico" /> <!-- preload the font to avoid rendering issue on first load not 100% perfect but much better than without check this for some additional context: https://github.com/xtermjs/xterm.js/issues/5164 --> - <link href="/assets/style.css" rel="stylesheet" /> + <link href="assets/style.css" rel="stylesheet" /> <!-- xterm.css --> - <link rel="stylesheet" href="/assets/xterm.css" /> + <link rel="stylesheet" href="assets/xterm.css" /> <!-- xterm.js --> - <script src="/assets/xterm.js"></script> - <script src="/assets/addon-fit.js"></script> - <script src="/assets/addon-clipboard.js"></script> - <script src="/assets/addon-web-links.js"></script> - <script src="/assets/addon-webgl.js"></script> + <script src="assets/xterm.js"></script> + <script src="assets/addon-fit.js"></script> + <script src="assets/addon-clipboard.js"></script> + <script src="assets/addon-web-links.js"></script> + <script src="assets/addon-webgl.js"></script> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Zellij Web Client</title> </head> <body> - <div id="terminal" tabindex=0></div> + <div id="terminal" tabindex="0"></div> <script> - window.is_authenticated = IS_AUTHENTICATED; + window.is_authenticated = IS_AUTHENTICATED; </script> - <script src="/assets/modals.js"></script> + <script src="assets/modals.js"></script> <!-- Module files - order matters --> - <script type="module" src="/assets/utils.js"></script> - <script type="module" src="/assets/connection.js"></script> - <script type="module" src="/assets/auth.js"></script> - <script type="module" src="/assets/keyboard.js"></script> - <script type="module" src="/assets/links.js"></script> - <script type="module" src="/assets/terminal.js"></script> - <script type="module" src="/assets/input.js"></script> - <script type="module" src="/assets/websockets.js"></script> - <script type="module" src="/assets/index.js"></script> + <script type="module" src="assets/utils.js"></script> + <script type="module" src="assets/connection.js"></script> + <script type="module" src="assets/auth.js"></script> + <script type="module" src="assets/keyboard.js"></script> + <script type="module" src="assets/links.js"></script> + <script type="module" src="assets/terminal.js"></script> + <script type="module" src="assets/input.js"></script> + <script type="module" src="assets/websockets.js"></script> + <script type="module" src="assets/index.js"></script> </body> </html> diff --git a/zellij-client/assets/utils.js b/zellij-client/assets/utils.js index d66646cc..26b0c6bd 100644 --- a/zellij-client/assets/utils.js +++ b/zellij-client/assets/utils.js @@ -16,3 +16,31 @@ export function isMac() { } return navigator.platform.toUpperCase().includes("MAC"); } + +/** + * Get the base URL from the base href tag + * @returns {string} Base URL + */ +export function getBaseUrl() { + const baseElement = document.querySelector("base"); + if (baseElement && baseElement.href) { + return baseElement.href.replace(/\/$/, ""); // Remove trailing slash + } + // Fallback to current origin if no base href + return window.location.origin; +} + +/** + * Get the base URL from the base href tag and convert to WebSocket URL + * @returns {string} WebSocket base URL + */ +export function getWebSocketBaseUrl() { + const baseElement = document.querySelector("base"); + if (baseElement && baseElement.href) { + const baseUrl = baseElement.href.replace(/\/$/, ""); // Remove trailing slash + // Convert http/https to ws/wss for WebSocket + return baseUrl.replace(/^https?/, is_https() ? "wss" : "ws"); + } + // Fallback to current origin if no base href + return window.location.origin.replace(/^https?/, is_https() ? "wss" : "ws"); +} diff --git a/zellij-client/assets/websockets.js b/zellij-client/assets/websockets.js index 6e5006c8..c87e3331 100644 --- a/zellij-client/assets/websockets.js +++ b/zellij-client/assets/websockets.js @@ -2,8 +2,8 @@ * WebSocket management for terminal and control connections */ -import { is_https } from './utils.js'; -import { handleReconnection, markConnectionEstablished } from './connection.js'; +import { handleReconnection, markConnectionEstablished } from "./connection.js"; +import { getWebSocketBaseUrl } from "./utils.js"; /** * Initialize both terminal and control WebSocket connections @@ -14,72 +14,79 @@ import { handleReconnection, markConnectionEstablished } from './connection.js'; * @param {function} sendAnsiKey - Function to send ANSI key sequences * @returns {object} Object containing WebSocket instances and cleanup function */ -export function initWebSockets(webClientId, sessionName, term, fitAddon, sendAnsiKey) { +export function initWebSockets( + webClientId, + sessionName, + term, + fitAddon, + sendAnsiKey +) { let ownWebClientId = ""; let wsTerminal; let wsControl; - - const wsUrlPrefix = is_https() ? "wss" : "ws"; - const url = sessionName === "" - ? `${wsUrlPrefix}://${window.location.host}/ws/terminal` - : `${wsUrlPrefix}://${window.location.host}/ws/terminal/${sessionName}`; - + + const wsBaseUrl = getWebSocketBaseUrl(); + const url = + sessionName === "" + ? `${wsBaseUrl}/ws/terminal` + : `${wsBaseUrl}/ws/terminal/${sessionName}`; + const queryString = `?web_client_id=${encodeURIComponent(webClientId)}`; const wsTerminalUrl = `${url}${queryString}`; - + wsTerminal = new WebSocket(wsTerminalUrl); - + wsTerminal.onopen = function () { markConnectionEstablished(); }; - + wsTerminal.onmessage = function (event) { if (ownWebClientId == "") { ownWebClientId = webClientId; - const wsControlUrl = `${wsUrlPrefix}://${window.location.host}/ws/control`; + const wsControlUrl = `${wsBaseUrl}/ws/control`; wsControl = new WebSocket(wsControlUrl); startWsControl(wsControl, term, fitAddon, ownWebClientId); } let data = event.data; - - if (typeof data === 'string') { + + if (typeof data === "string") { // Handle ANSI title change sequences const titleRegex = /\x1b\]0;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g; let match; while ((match = titleRegex.exec(data)) !== null) { document.title = match[1]; } - - if (data.includes('\x1b[0 q')) { + + if (data.includes("\x1b[0 q")) { const shouldBlink = term.options.cursorBlink; const cursorStyle = term.options.cursorStyle; let replacement; switch (cursorStyle) { - case 'block': - replacement = shouldBlink ? '\x1b[1 q' : '\x1b[2 q'; + case "block": + replacement = shouldBlink ? "\x1b[1 q" : "\x1b[2 q"; break; - case 'underline': - replacement = shouldBlink ? '\x1b[3 q' : '\x1b[4 q'; + case "underline": + replacement = shouldBlink ? "\x1b[3 q" : "\x1b[4 q"; break; - case 'bar': - replacement = shouldBlink ? '\x1b[5 q' : '\x1b[6 q'; + case "bar": + replacement = shouldBlink ? "\x1b[5 q" : "\x1b[6 q"; break; default: - replacement = '\x1b[2 q'; + replacement = "\x1b[2 q"; break; } data = data.replace(/\x1b\[0 q/g, replacement); } } - + term.write(data); }; - + wsTerminal.onclose = function () { handleReconnection(); }; - + // Update sendAnsiKey to use the actual WebSocket const originalSendAnsiKey = sendAnsiKey; sendAnsiKey = (ansiKey) => { @@ -87,10 +94,15 @@ export function initWebSockets(webClientId, sessionName, term, fitAddon, sendAns wsTerminal.send(ansiKey); } }; - + // Setup resize handler - setupResizeHandler(term, fitAddon, () => wsControl, () => ownWebClientId); - + setupResizeHandler( + term, + fitAddon, + () => wsControl, + () => ownWebClientId + ); + return { wsTerminal, getWsControl: () => wsControl, @@ -103,7 +115,7 @@ export function initWebSockets(webClientId, sessionName, term, fitAddon, sendAns if (wsControl) { wsControl.close(); } - } + }, }; } @@ -129,7 +141,7 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId) { }) ); }; - + wsControl.onmessage = function (event) { const msg = JSON.parse(event.data); if (msg.type === "SetConfig") { @@ -139,20 +151,20 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId) { cursor_blink, mac_option_is_meta, cursor_style, - cursor_inactive_style + cursor_inactive_style, } = msg; term.options.fontFamily = font; term.options.theme = theme; - if (cursor_blink !== 'undefined') { + if (cursor_blink !== "undefined") { term.options.cursorBlink = cursor_blink; } - if (mac_option_is_meta !== 'undefined') { + if (mac_option_is_meta !== "undefined") { term.options.macOptionIsMeta = mac_option_is_meta; } - if (cursor_style !== 'undefined') { + if (cursor_style !== "undefined") { term.options.cursorStyle = cursor_style; } - if (cursor_inactive_style !== 'undefined') { + if (cursor_inactive_style !== "undefined") { term.options.cursorInactiveStyle = cursor_inactive_style; } const body = document.querySelector("body"); @@ -227,7 +239,12 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId) { * @param {function} getWsControl - Function that returns control WebSocket * @param {function} getOwnWebClientId - Function that returns own web client ID */ -export function setupResizeHandler(term, fitAddon, getWsControl, getOwnWebClientId) { +export function setupResizeHandler( + term, + fitAddon, + getWsControl, + getOwnWebClientId +) { addEventListener("resize", (event) => { const ownWebClientId = getOwnWebClientId(); if (ownWebClientId === "") { diff --git a/zellij-client/src/web_client/http_handlers.rs b/zellij-client/src/web_client/http_handlers.rs index fb5df41d..4040fab7 100644 --- a/zellij-client/src/web_client/http_handlers.rs +++ b/zellij-client/src/web_client/http_handlers.rs @@ -19,11 +19,24 @@ const WEB_CLIENT_PAGE: &str = include_str!(concat!( const ASSETS_DIR: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets"); -pub async fn serve_html(request: Request) -> Html<String> { +pub async fn serve_html(State(state): State<AppState>, request: Request) -> Html<String> { let cookies = parse_cookies(&request); let is_authenticated = cookies.get("session_token").is_some(); let auth_value = if is_authenticated { "true" } else { "false" }; - let html = Html(WEB_CLIENT_PAGE.replace("IS_AUTHENTICATED", &format!("{}", auth_value))); + let base_url = state + .config + .lock() + .unwrap() + .web_client + .base_url + .clone() + .unwrap_or("/".to_string()); + + let html = Html( + WEB_CLIENT_PAGE + .replace("IS_AUTHENTICATED", &format!("{}", auth_value)) + .replace("BASE_URL", &base_url), + ); html } diff --git a/zellij-utils/src/input/web_client.rs b/zellij-utils/src/input/web_client.rs index 1159b4d8..de09efad 100644 --- a/zellij-utils/src/input/web_client.rs +++ b/zellij-utils/src/input/web_client.rs @@ -238,6 +238,7 @@ pub struct WebClientConfig { pub cursor_inactive_style: Option<CursorInactiveStyle>, pub cursor_style: Option<CursorStyle>, pub mac_option_is_meta: bool, + pub base_url: Option<String>, } impl Default for WebClientConfig { @@ -249,6 +250,7 @@ impl Default for WebClientConfig { cursor_inactive_style: None, cursor_style: None, mac_option_is_meta: true, // TODO: yes? no? + base_url: None, } } } @@ -283,6 +285,10 @@ impl WebClientConfig { web_client_config.mac_option_is_meta = mac_option_is_meta; } + if let Some(base_url) = kdl_get_child_entry_string_value!(kdl, "base_url") { + web_client_config.base_url = Some(base_url.to_owned()); + } + Ok(web_client_config) } @@ -326,6 +332,12 @@ impl WebClientConfig { .push(mac_option_is_meta_node); } + if let Some(base_url) = &self.base_url { + let mut base_url_node = KdlNode::new("base_url"); + base_url_node.push(KdlValue::String(base_url.clone())); + web_client_children.nodes_mut().push(base_url_node); + } + web_client_node.set_children(web_client_children); web_client_node } @@ -338,6 +350,7 @@ impl WebClientConfig { 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.base_url = other.base_url; merged } } 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 9635303b..80f17bd6 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 @@ -6192,5 +6192,6 @@ Config { cursor_inactive_style: None, cursor_style: None, mac_option_is_meta: true, + base_url: None, }, } 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 879be0ef..e1e6b5e2 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 @@ -6196,5 +6196,6 @@ Config { cursor_inactive_style: None, cursor_style: None, mac_option_is_meta: true, + base_url: None, }, } 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 1d245a3e..2ff6c915 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 @@ -286,5 +286,6 @@ Config { cursor_inactive_style: None, cursor_style: None, mac_option_is_meta: true, + base_url: 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 bc5119f7..c9d4c32a 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 @@ -7963,5 +7963,6 @@ Config { cursor_inactive_style: None, cursor_style: None, mac_option_is_meta: true, + base_url: None, }, } 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 477e152b..98ebf1bb 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 @@ -6192,5 +6192,6 @@ Config { cursor_inactive_style: None, cursor_style: None, mac_option_is_meta: true, + base_url: None, }, } |
