zellij/zellij-client/assets/websockets.js
Aram Drevekenin c5ac796880
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>
2025-06-23 19:19:37 +02:00

253 lines
8.3 KiB
JavaScript

/**
* WebSocket management for terminal and control connections
*/
import { is_https } from './utils.js';
import { handleReconnection, markConnectionEstablished } from './connection.js';
/**
* Initialize both terminal and control WebSocket connections
* @param {string} webClientId - Client ID from authentication
* @param {string} sessionName - Session name from URL
* @param {Terminal} term - Terminal instance
* @param {FitAddon} fitAddon - Terminal fit addon
* @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) {
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 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`;
wsControl = new WebSocket(wsControlUrl);
startWsControl(wsControl, term, fitAddon, ownWebClientId);
}
let data = event.data;
if (typeof data === 'string' && 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';
break;
case 'underline':
replacement = shouldBlink ? '\x1b[3 q' : '\x1b[4 q';
break;
case 'bar':
replacement = shouldBlink ? '\x1b[5 q' : '\x1b[6 q';
break;
default:
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) => {
if (ownWebClientId !== "") {
wsTerminal.send(ansiKey);
}
};
// Setup resize handler
setupResizeHandler(term, fitAddon, () => wsControl, () => ownWebClientId);
return {
wsTerminal,
getWsControl: () => wsControl,
getOwnWebClientId: () => ownWebClientId,
sendAnsiKey,
cleanup: () => {
if (wsTerminal) {
wsTerminal.close();
}
if (wsControl) {
wsControl.close();
}
}
};
}
/**
* Start the control WebSocket and set up its handlers
* @param {WebSocket} wsControl - Control WebSocket instance
* @param {Terminal} term - Terminal instance
* @param {FitAddon} fitAddon - Terminal fit addon
* @param {string} ownWebClientId - Own web client ID
*/
function startWsControl(wsControl, term, fitAddon, ownWebClientId) {
wsControl.onopen = function (event) {
const fitDimensions = fitAddon.proposeDimensions();
const { rows, cols } = fitDimensions;
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
type: "TerminalResize",
rows,
cols,
},
})
);
};
wsControl.onmessage = function (event) {
const msg = JSON.parse(event.data);
if (msg.type === "SetConfig") {
const {
font,
theme,
cursor_blink,
mac_option_is_meta,
cursor_style,
cursor_inactive_style
} = msg;
term.options.fontFamily = font;
term.options.theme = theme;
if (cursor_blink !== 'undefined') {
term.options.cursorBlink = cursor_blink;
}
if (mac_option_is_meta !== 'undefined') {
term.options.macOptionIsMeta = mac_option_is_meta;
}
if (cursor_style !== 'undefined') {
term.options.cursorStyle = cursor_style;
}
if (cursor_inactive_style !== 'undefined') {
term.options.cursorInactiveStyle = cursor_inactive_style;
}
const body = document.querySelector("body");
body.style.background = theme.background;
const terminal = document.getElementById("terminal");
terminal.style.background = theme.background;
const fitDimensions = fitAddon.proposeDimensions();
if (fitDimensions === undefined) {
console.warn("failed to get new fit dimensions");
return;
}
const { rows, cols } = fitDimensions;
if (rows === term.rows && cols === term.cols) {
return;
}
term.resize(cols, rows);
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
type: "TerminalResize",
rows,
cols,
},
})
);
} else if (msg.type === "QueryTerminalSize") {
const fitDimensions = fitAddon.proposeDimensions();
const { rows, cols } = fitDimensions;
if (rows !== term.rows || cols !== term.cols) {
term.resize(cols, rows);
}
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
type: "TerminalResize",
rows,
cols,
},
})
);
} else if (msg.type === "Log") {
const { lines } = msg;
for (const line in lines) {
console.log(line);
}
} else if (msg.type === "LogError") {
const { lines } = msg;
for (const line in lines) {
console.error(line);
}
} else if (msg.type === "SwitchedSession") {
const { new_session_name } = msg;
window.location.pathname = `/${new_session_name}`;
}
};
wsControl.onclose = function () {
handleReconnection();
};
}
/**
* Set up window resize event handler
* @param {Terminal} term - Terminal instance
* @param {FitAddon} fitAddon - Terminal fit addon
* @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) {
addEventListener("resize", (event) => {
const ownWebClientId = getOwnWebClientId();
if (ownWebClientId === "") {
return;
}
const fitDimensions = fitAddon.proposeDimensions();
if (fitDimensions === undefined) {
console.warn("failed to get new fit dimensions");
return;
}
const { rows, cols } = fitDimensions;
if (rows === term.rows && cols === term.cols) {
return;
}
term.resize(cols, rows);
const wsControl = getWsControl();
if (wsControl) {
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
type: "TerminalResize",
rows,
cols,
},
})
);
}
});
}