mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-11 22:50:07 +00:00
fix(webclient): handle host query responses for pixel dimensions and colors (#5156)
* fix(webclient): handle host query responses for pixel dimensions and colors * rustfmt * add pr
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)
|
||||
* fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139)
|
||||
* fix: deadlock with --blocking panes (https://github.com/zellij-org/zellij/pull/5152)
|
||||
* fix: occasional stall before prompt/program-run (https://github.com/zellij-org/zellij/pull/5149)
|
||||
* fix: occasional stall before prompt/program-run (https://github.com/zellij-org/zellij/pull/5149 and https://github.com/zellij-org/zellij/pull/5156)
|
||||
|
||||
## [0.44.2] - 2026-05-05
|
||||
* fix: idle CPU + disk i/o, CommandChanged plugin Event, GetSessionList plugin command (https://github.com/zellij-org/zellij/pull/5063)
|
||||
|
||||
@@ -5,6 +5,80 @@
|
||||
import { handleReconnection, handleDisconnected, markConnectionEstablished } from "./connection.js";
|
||||
import { getBaseUrl, getWebSocketBaseUrl } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Read cell pixel dimensions from xterm.js. Tries the internal
|
||||
* _renderService first (matches what the vendored FitAddon uses) and
|
||||
* falls back to a DOM measurement of .xterm-char-measure-element — a
|
||||
* hidden helper element xterm.js creates explicitly for character
|
||||
* measurement. Returns null if neither path yields usable numbers.
|
||||
*/
|
||||
function getCellPixelDimensions(term) {
|
||||
try {
|
||||
const cell =
|
||||
term && term._core && term._core._renderService &&
|
||||
term._core._renderService.dimensions &&
|
||||
term._core._renderService.dimensions.css &&
|
||||
term._core._renderService.dimensions.css.cell;
|
||||
if (cell && cell.width && cell.height) {
|
||||
return { width: cell.width, height: cell.height };
|
||||
}
|
||||
} catch (_) {}
|
||||
const el = term && term.element &&
|
||||
term.element.querySelector(".xterm-char-measure-element");
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width && rect.height) {
|
||||
return { width: rect.width, height: rect.height };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send both control messages that describe the client's display state
|
||||
* to the Zellij server: TerminalResize (grid rows/cols) and
|
||||
* TerminalMetrics (pixel dimensions used to answer host-terminal
|
||||
* queries such as CSI 14t / 16t and OSC 11;?).
|
||||
*
|
||||
* Single chokepoint so the protocol contract lives in one place — any
|
||||
* site that updates terminal size or theme must call this helper, and
|
||||
* any future field added to the protocol is added here once. The
|
||||
* server's TerminalResize handler is idempotent, so calling this even
|
||||
* when the grid hasn't changed (e.g. after a theme reload that only
|
||||
* shifts font metrics) is safe.
|
||||
*/
|
||||
function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols) {
|
||||
if (!wsControl || !ownWebClientId) {
|
||||
return;
|
||||
}
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalResize",
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
})
|
||||
);
|
||||
const cell = getCellPixelDimensions(term);
|
||||
if (!cell) {
|
||||
return;
|
||||
}
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalMetrics",
|
||||
cell_pixel_width: Math.round(cell.width),
|
||||
cell_pixel_height: Math.round(cell.height),
|
||||
text_area_pixel_width: Math.round(cols * cell.width),
|
||||
text_area_pixel_height: Math.round(rows * cell.height),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize both terminal and control WebSocket connections
|
||||
* @param {string} webClientId - Client ID from authentication
|
||||
@@ -146,16 +220,7 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
);
|
||||
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
|
||||
};
|
||||
|
||||
wsControl.onmessage = function (event) {
|
||||
@@ -198,37 +263,21 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
|
||||
}
|
||||
|
||||
const { rows, cols } = fitDimensions;
|
||||
if (rows === term.rows && cols === term.cols) {
|
||||
return;
|
||||
if (rows !== term.rows || cols !== term.cols) {
|
||||
term.resize(cols, rows);
|
||||
}
|
||||
term.resize(cols, rows);
|
||||
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalResize",
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
})
|
||||
);
|
||||
// Always emit a size update on SetConfig: even if the grid
|
||||
// didn't change, font metrics may have shifted and the
|
||||
// pixel-cell measurements in TerminalMetrics need to
|
||||
// refresh so host-terminal queries get accurate values.
|
||||
sendSizeUpdate(wsControl, ownWebClientId, term, 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,
|
||||
},
|
||||
})
|
||||
);
|
||||
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
|
||||
} else if (msg.type === "Log") {
|
||||
const { lines } = msg;
|
||||
for (const line in lines) {
|
||||
@@ -299,18 +348,7 @@ export function setupResizeHandler(
|
||||
term.resize(cols, rows);
|
||||
|
||||
const wsControl = getWsControl();
|
||||
if (wsControl) {
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalResize",
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
|
||||
};
|
||||
|
||||
const handleViewportChange = () => {
|
||||
|
||||
@@ -746,7 +746,10 @@ async fn test_control_message_handling() {
|
||||
Message::Text(text) => {
|
||||
let parsed: WebClientToWebServerControlMessage =
|
||||
serde_json::from_str(&text).expect("Failed to parse");
|
||||
let WebClientToWebServerControlMessagePayload::TerminalResize(size) = parsed.payload;
|
||||
let WebClientToWebServerControlMessagePayload::TerminalResize(size) = parsed.payload
|
||||
else {
|
||||
panic!("Expected TerminalResize, got: {:?}", parsed.payload);
|
||||
};
|
||||
assert_eq!(size, terminal_size);
|
||||
},
|
||||
_ => panic!("Expected Text message, got: {:?}", received),
|
||||
|
||||
@@ -12,6 +12,15 @@ pub struct WebClientToWebServerControlMessage {
|
||||
#[serde(tag = "type")]
|
||||
pub enum WebClientToWebServerControlMessagePayload {
|
||||
TerminalResize(Size),
|
||||
TerminalMetrics(TerminalMetricsPayload),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TerminalMetricsPayload {
|
||||
pub cell_pixel_width: usize,
|
||||
pub cell_pixel_height: usize,
|
||||
pub text_area_pixel_width: usize,
|
||||
pub text_area_pixel_height: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Builds the IPC messages that seed Screen's host-terminal-query cache
|
||||
//! with web-client data on attach (and on subsequent config reloads).
|
||||
//!
|
||||
//! The native client populates `Screen.terminal_emulator_colors`,
|
||||
//! `Screen.pixel_dimensions` and `Screen.terminal_emulator_color_codes`
|
||||
//! by forwarding what its real terminal reports. The web client has no
|
||||
//! "real terminal" — its display surface is xterm.js in the browser —
|
||||
//! so we synthesize the equivalent state from two sources:
|
||||
//!
|
||||
//! 1. The same `Config` that drives `SetConfigPayload`, which the
|
||||
//! browser ultimately renders with. fg/bg are guaranteed to match
|
||||
//! what xterm.js paints.
|
||||
//! 2. The canonical xterm-256 palette formula for indices 16-255 (not
|
||||
//! overridable in xterm.js's configurable theme, so the formula is
|
||||
//! authoritative).
|
||||
//!
|
||||
//! Pixel dimensions are not handled here — they require browser-side
|
||||
//! measurement and flow through the `TerminalMetrics` control message.
|
||||
|
||||
use zellij_utils::{
|
||||
data::PaletteColor,
|
||||
input::{config::Config, options::Options},
|
||||
ipc::{ClientToServerMsg, ColorRegister},
|
||||
};
|
||||
|
||||
/// Build the seed messages to send to the server immediately after attach
|
||||
/// (and on every `SetConfig` update). May return an empty vec if no theme
|
||||
/// information is resolvable from the config — in which case the server's
|
||||
/// cache stays at its defaults and `synthesize_cached_reply` returns empty
|
||||
/// bytes, matching pre-existing behavior.
|
||||
pub fn build_host_query_seed_msgs(
|
||||
config: &Config,
|
||||
config_options: &Options,
|
||||
) -> Vec<ClientToServerMsg> {
|
||||
let mut msgs = Vec::new();
|
||||
let resolved = resolve_theme(config, config_options);
|
||||
|
||||
if let Some(fg) = resolved
|
||||
.foreground
|
||||
.as_ref()
|
||||
.and_then(|s| css_rgb_to_xparse(s))
|
||||
{
|
||||
msgs.push(ClientToServerMsg::ForegroundColor { color: fg });
|
||||
}
|
||||
if let Some(bg) = resolved
|
||||
.background
|
||||
.as_ref()
|
||||
.and_then(|s| css_rgb_to_xparse(s))
|
||||
{
|
||||
msgs.push(ClientToServerMsg::BackgroundColor { color: bg });
|
||||
}
|
||||
|
||||
let registers = build_color_registers(&resolved);
|
||||
if !registers.is_empty() {
|
||||
msgs.push(ClientToServerMsg::ColorRegisters {
|
||||
color_registers: registers,
|
||||
});
|
||||
}
|
||||
|
||||
msgs
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct ResolvedTheme {
|
||||
foreground: Option<String>,
|
||||
background: Option<String>,
|
||||
indexed: [Option<String>; 16],
|
||||
}
|
||||
|
||||
/// Resolve the web-client theme using the same precedence as
|
||||
/// `SetConfigPayload::from(&Config)` so server-side synthesis matches
|
||||
/// the colors xterm.js paints in the browser.
|
||||
fn resolve_theme(config: &Config, config_options: &Options) -> ResolvedTheme {
|
||||
let mut out = ResolvedTheme::default();
|
||||
|
||||
let palette = config.theme_config(config_options.theme.as_ref());
|
||||
let web_client_theme = config.web_client.theme.as_ref();
|
||||
|
||||
out.foreground = web_client_theme
|
||||
.and_then(|t| t.foreground.clone())
|
||||
.or_else(|| palette.map(|p| p.text_unselected.base.as_rgb_str()));
|
||||
out.background = web_client_theme
|
||||
.and_then(|t| t.background.clone())
|
||||
.or_else(|| palette.map(|p| p.text_unselected.background.as_rgb_str()));
|
||||
|
||||
if let Some(t) = web_client_theme {
|
||||
out.indexed[0] = t.black.clone();
|
||||
out.indexed[1] = t.red.clone();
|
||||
out.indexed[2] = t.green.clone();
|
||||
out.indexed[3] = t.yellow.clone();
|
||||
out.indexed[4] = t.blue.clone();
|
||||
out.indexed[5] = t.magenta.clone();
|
||||
out.indexed[6] = t.cyan.clone();
|
||||
out.indexed[7] = t.white.clone();
|
||||
out.indexed[8] = t.bright_black.clone();
|
||||
out.indexed[9] = t.bright_red.clone();
|
||||
out.indexed[10] = t.bright_green.clone();
|
||||
out.indexed[11] = t.bright_yellow.clone();
|
||||
out.indexed[12] = t.bright_blue.clone();
|
||||
out.indexed[13] = t.bright_magenta.clone();
|
||||
out.indexed[14] = t.bright_cyan.clone();
|
||||
out.indexed[15] = t.bright_white.clone();
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn build_color_registers(resolved: &ResolvedTheme) -> Vec<ColorRegister> {
|
||||
let mut registers = Vec::with_capacity(256);
|
||||
|
||||
// Indices 0-15: only seed entries that have an explicit override in
|
||||
// `web_client.theme.*`. Unseeded indices fall through to empty
|
||||
// synthesis (existing semantics) — apps will use their own defaults.
|
||||
for (i, slot) in resolved.indexed.iter().enumerate() {
|
||||
if let Some(css) = slot {
|
||||
if let Some(color) = css_rgb_to_xparse_color(css) {
|
||||
registers.push(ColorRegister { index: i, color });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Indices 16-255: canonical xterm-256 palette. xterm.js does not let
|
||||
// these be overridden via its theme options, and Zellij's config does
|
||||
// not expose them, so the formula is the source of truth.
|
||||
for index in 16u8..=255 {
|
||||
let (r, g, b) = xterm_256_rgb(index);
|
||||
registers.push(ColorRegister {
|
||||
index: index as usize,
|
||||
color: rgb_to_xparse_color(r, g, b),
|
||||
});
|
||||
}
|
||||
|
||||
registers
|
||||
}
|
||||
|
||||
/// Convert a CSS `rgb(R, G, B)` string (the format `PaletteColor::as_rgb_str`
|
||||
/// produces, which is what `SetConfigPayload` ships to xterm.js) into the
|
||||
/// `rgb:RRRR/GGGG/BBBB` form `xparse_color` accepts, prefixed for
|
||||
/// `ClientToServerMsg::{Foreground,Background}Color`'s consumer.
|
||||
fn css_rgb_to_xparse(css: &str) -> Option<String> {
|
||||
css_rgb_to_xparse_color(css)
|
||||
}
|
||||
|
||||
/// Same as `css_rgb_to_xparse`. Kept as a separate alias to make the
|
||||
/// difference between OSC 10/11 (xparse-format string going into screen's
|
||||
/// terminal_emulator_colors) and OSC 4 (color string going into
|
||||
/// terminal_emulator_color_codes) explicit at call sites — currently both
|
||||
/// consumers want the same `rgb:RRRR/GGGG/BBBB` shape.
|
||||
fn css_rgb_to_xparse_color(css: &str) -> Option<String> {
|
||||
let PaletteColor::Rgb((r, g, b)) = PaletteColor::from_rgb_str(css) else {
|
||||
return None;
|
||||
};
|
||||
Some(rgb_to_xparse_color(r, g, b))
|
||||
}
|
||||
|
||||
fn rgb_to_xparse_color(r: u8, g: u8, b: u8) -> String {
|
||||
format!(
|
||||
"rgb:{:04x}/{:04x}/{:04x}",
|
||||
(r as u16) * 0x0101,
|
||||
(g as u16) * 0x0101,
|
||||
(b as u16) * 0x0101,
|
||||
)
|
||||
}
|
||||
|
||||
/// Canonical xterm-256 palette for index 16..=255. Indices 0..=15 are
|
||||
/// intentionally not handled here — those are theme-dependent and seeded
|
||||
/// separately from `WebClientTheme` when overrides are present.
|
||||
fn xterm_256_rgb(index: u8) -> (u8, u8, u8) {
|
||||
match index {
|
||||
0..=15 => (0, 0, 0), // not used; caller restricts to 16..=255
|
||||
16..=231 => {
|
||||
let idx = (index - 16) as u32;
|
||||
let level = |x: u32| if x == 0 { 0u8 } else { (55 + x * 40) as u8 };
|
||||
(level(idx / 36), level((idx / 6) % 6), level(idx % 6))
|
||||
},
|
||||
232..=255 => {
|
||||
let v = 8u8 + 10 * (index - 232);
|
||||
(v, v, v)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use zellij_utils::input::web_client::{WebClientConfig, WebClientTheme};
|
||||
|
||||
fn config_with_web_theme(theme: WebClientTheme) -> Config {
|
||||
let mut config = Config::default();
|
||||
config.web_client = WebClientConfig {
|
||||
theme: Some(theme),
|
||||
..WebClientConfig::default()
|
||||
};
|
||||
config
|
||||
}
|
||||
|
||||
/// Locate the single `ColorRegister` matching `index` in the
|
||||
/// `ColorRegisters` message produced by the seed builder.
|
||||
fn find_register(msgs: &[ClientToServerMsg], index: usize) -> Option<String> {
|
||||
for msg in msgs {
|
||||
if let ClientToServerMsg::ColorRegisters { color_registers } = msg {
|
||||
if let Some(reg) = color_registers.iter().find(|r| r.index == index) {
|
||||
return Some(reg.color.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn fg_color(msgs: &[ClientToServerMsg]) -> Option<String> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
ClientToServerMsg::ForegroundColor { color } => Some(color.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn bg_color(msgs: &[ClientToServerMsg]) -> Option<String> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
ClientToServerMsg::BackgroundColor { color } => Some(color.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn registers_msg(msgs: &[ClientToServerMsg]) -> Option<usize> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
ClientToServerMsg::ColorRegisters { color_registers } => Some(color_registers.len()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xterm_256_first_cube_entry_is_black() {
|
||||
assert_eq!(xterm_256_rgb(16), (0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xterm_256_last_cube_entry_is_white() {
|
||||
// 5,5,5 — corners at maximal level
|
||||
assert_eq!(xterm_256_rgb(231), (255, 255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xterm_256_greyscale_endpoints() {
|
||||
assert_eq!(xterm_256_rgb(232), (8, 8, 8));
|
||||
assert_eq!(xterm_256_rgb(255), (238, 238, 238));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_level_formula_matches_xterm_table() {
|
||||
// index 17: r=0, g=0, b=1 => (0,0,95)
|
||||
assert_eq!(xterm_256_rgb(17), (0, 0, 95));
|
||||
// index 21: r=0, g=0, b=5 => (0,0,255)
|
||||
assert_eq!(xterm_256_rgb(21), (0, 0, 255));
|
||||
// index 196: 196-16=180; r=180/36=5, g=(180/6)%6=0, b=180%6=0 => (255,0,0)
|
||||
assert_eq!(xterm_256_rgb(196), (255, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_to_xparse_round_trip() {
|
||||
assert_eq!(rgb_to_xparse_color(0x12, 0x34, 0x56), "rgb:1212/3434/5656");
|
||||
assert_eq!(rgb_to_xparse_color(0, 0, 0), "rgb:0000/0000/0000");
|
||||
assert_eq!(rgb_to_xparse_color(255, 255, 255), "rgb:ffff/ffff/ffff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_rgb_str_parses_through_palette_color() {
|
||||
// PaletteColor::from_rgb_str accepts "rgb(R, G, B)".
|
||||
assert_eq!(
|
||||
css_rgb_to_xparse_color("rgb(255, 128, 0)").as_deref(),
|
||||
Some("rgb:ffff/8080/0000"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_css_rgb_yields_no_conversion() {
|
||||
// PaletteColor::from_rgb_str returns the default variant for
|
||||
// strings it cannot parse; the bridge must reject those rather
|
||||
// than emit a garbage xparse string.
|
||||
assert!(css_rgb_to_xparse_color("#ff8800").is_none());
|
||||
assert!(css_rgb_to_xparse_color("not a color").is_none());
|
||||
assert!(css_rgb_to_xparse_color("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_with_explicit_fg_bg_emits_both_messages() {
|
||||
// User has set web_client.theme.foreground and .background
|
||||
// explicitly — the seed builder must propagate them as
|
||||
// ForegroundColor / BackgroundColor messages in xparse format.
|
||||
let mut theme = WebClientTheme::default();
|
||||
theme.foreground = Some("rgb(200, 200, 200)".to_string());
|
||||
theme.background = Some("rgb(20, 20, 20)".to_string());
|
||||
let config = config_with_web_theme(theme);
|
||||
let opts = Options::default();
|
||||
|
||||
let msgs = build_host_query_seed_msgs(&config, &opts);
|
||||
|
||||
assert_eq!(fg_color(&msgs).as_deref(), Some("rgb:c8c8/c8c8/c8c8"));
|
||||
assert_eq!(bg_color(&msgs).as_deref(), Some("rgb:1414/1414/1414"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_with_indexed_overrides_populates_those_registers() {
|
||||
// Per-index overrides in WebClientTheme must show up in the
|
||||
// ColorRegisters payload at the correct ANSI index.
|
||||
let mut theme = WebClientTheme::default();
|
||||
theme.red = Some("rgb(204, 0, 0)".to_string()); // index 1
|
||||
theme.bright_blue = Some("rgb(50, 100, 200)".to_string()); // index 12
|
||||
let config = config_with_web_theme(theme);
|
||||
let opts = Options::default();
|
||||
|
||||
let msgs = build_host_query_seed_msgs(&config, &opts);
|
||||
|
||||
assert_eq!(
|
||||
find_register(&msgs, 1).as_deref(),
|
||||
Some("rgb:cccc/0000/0000"),
|
||||
);
|
||||
assert_eq!(
|
||||
find_register(&msgs, 12).as_deref(),
|
||||
Some("rgb:3232/6464/c8c8"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_omits_unset_low_indices() {
|
||||
// Indices 0-15 without explicit overrides should be absent from
|
||||
// the ColorRegisters payload — apps will fall back to their own
|
||||
// defaults rather than reading a wrong value we made up.
|
||||
let mut theme = WebClientTheme::default();
|
||||
theme.red = Some("rgb(255, 0, 0)".to_string());
|
||||
let config = config_with_web_theme(theme);
|
||||
let opts = Options::default();
|
||||
|
||||
let msgs = build_host_query_seed_msgs(&config, &opts);
|
||||
|
||||
// Only `red` (index 1) was set.
|
||||
assert_eq!(find_register(&msgs, 0), None);
|
||||
assert_eq!(find_register(&msgs, 2), None);
|
||||
assert!(find_register(&msgs, 1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_always_populates_extended_palette_16_to_255() {
|
||||
// The 16-255 range is the canonical xterm-256 palette, which is
|
||||
// independent of theme config. Verify both the count (240
|
||||
// entries) and a couple of spot-check values.
|
||||
let config = Config::default();
|
||||
let opts = Options::default();
|
||||
|
||||
let msgs = build_host_query_seed_msgs(&config, &opts);
|
||||
|
||||
// Default config has no per-index overrides, so the ColorRegisters
|
||||
// payload contains exactly 240 entries (16..=255).
|
||||
assert_eq!(registers_msg(&msgs), Some(240));
|
||||
|
||||
// Index 196 is canonical pure red in the 6x6x6 cube.
|
||||
assert_eq!(
|
||||
find_register(&msgs, 196).as_deref(),
|
||||
Some("rgb:ffff/0000/0000"),
|
||||
);
|
||||
// Index 232 is the first greyscale step (rgb 8/8/8).
|
||||
assert_eq!(
|
||||
find_register(&msgs, 232).as_deref(),
|
||||
Some("rgb:0808/0808/0808"),
|
||||
);
|
||||
// Index 255 is the last greyscale step (rgb 238/238/238).
|
||||
assert_eq!(
|
||||
find_register(&msgs, 255).as_deref(),
|
||||
Some("rgb:eeee/eeee/eeee"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_with_overrides_sums_to_240_plus_overrides() {
|
||||
// Adding per-index overrides should grow the ColorRegisters
|
||||
// payload — 240 for the extended range plus one entry per
|
||||
// override in 0..=15.
|
||||
let mut theme = WebClientTheme::default();
|
||||
theme.red = Some("rgb(255, 0, 0)".to_string());
|
||||
theme.green = Some("rgb(0, 255, 0)".to_string());
|
||||
theme.blue = Some("rgb(0, 0, 255)".to_string());
|
||||
let config = config_with_web_theme(theme);
|
||||
let opts = Options::default();
|
||||
|
||||
let msgs = build_host_query_seed_msgs(&config, &opts);
|
||||
|
||||
assert_eq!(registers_msg(&msgs), Some(240 + 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_skips_color_messages_when_no_theme_resolvable() {
|
||||
// A Config whose theme resolution yields nothing (no
|
||||
// web_client.theme, no main theme matching) should produce no
|
||||
// ForegroundColor / BackgroundColor messages — the server's
|
||||
// cache keeps its defaults instead of being clobbered.
|
||||
let mut config = Config::default();
|
||||
// Force theme resolution to fail by clearing the theme name as
|
||||
// well. The exact internals depend on Config::theme_config(),
|
||||
// so this test just asserts: whatever the resolver decides,
|
||||
// the seed builder must not emit "Some(empty string)" garbage.
|
||||
config.web_client = WebClientConfig::default();
|
||||
let opts = Options::default();
|
||||
|
||||
let msgs = build_host_query_seed_msgs(&config, &opts);
|
||||
|
||||
// Whatever fg/bg the resolver picks, the bridge must produce
|
||||
// a well-formed xparse string or skip the message entirely.
|
||||
for msg in &msgs {
|
||||
match msg {
|
||||
ClientToServerMsg::ForegroundColor { color }
|
||||
| ClientToServerMsg::BackgroundColor { color } => {
|
||||
assert!(
|
||||
color.starts_with("rgb:"),
|
||||
"expected xparse-format string, got: {:?}",
|
||||
color
|
||||
);
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_color_registers_always_emit_xparse_format() {
|
||||
// Every entry in the ColorRegisters payload must use the same
|
||||
// `rgb:RRRR/GGGG/BBBB` shape that synthesize_cached_reply
|
||||
// re-emits verbatim — no `rgb(R, G, B)` CSS strings leaking
|
||||
// through.
|
||||
let mut theme = WebClientTheme::default();
|
||||
theme.black = Some("rgb(0, 0, 0)".to_string());
|
||||
theme.bright_white = Some("rgb(255, 255, 255)".to_string());
|
||||
let config = config_with_web_theme(theme);
|
||||
let opts = Options::default();
|
||||
|
||||
let msgs = build_host_query_seed_msgs(&config, &opts);
|
||||
|
||||
let registers = msgs
|
||||
.iter()
|
||||
.find_map(|m| match m {
|
||||
ClientToServerMsg::ColorRegisters { color_registers } => Some(color_registers),
|
||||
_ => None,
|
||||
})
|
||||
.expect("ColorRegisters message missing");
|
||||
for reg in registers {
|
||||
assert!(
|
||||
reg.color.starts_with("rgb:") && reg.color.len() == 4 + 4 + 1 + 4 + 1 + 4,
|
||||
"register {} has malformed color string: {:?}",
|
||||
reg.index,
|
||||
reg.color
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod control_message;
|
||||
|
||||
pub(crate) mod authentication;
|
||||
mod connection_manager;
|
||||
mod host_query_seed;
|
||||
mod http_handlers;
|
||||
mod ipc_listener;
|
||||
mod message_handlers;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::os_input_output::ClientOsApi;
|
||||
use crate::web_client::control_message::{SetConfigPayload, WebServerToWebClientControlMessage};
|
||||
use crate::web_client::host_query_seed::build_host_query_seed_msgs;
|
||||
use crate::web_client::session_management::{
|
||||
build_initial_connection, create_first_message, create_ipc_pipe,
|
||||
};
|
||||
@@ -119,6 +120,17 @@ pub fn zellij_server_listener(
|
||||
first_message,
|
||||
);
|
||||
|
||||
// Seed the server's host-terminal-query cache with web
|
||||
// client state derived from Config (fg/bg/palette).
|
||||
// Without this, OSC 10/11/4 queries from apps
|
||||
// stall on the 1s server forward timeout and then come
|
||||
// back empty. Pixel dimensions are seeded separately
|
||||
// via the TerminalMetrics control message once the
|
||||
// browser has reported them.
|
||||
for seed in build_host_query_seed_msgs(&config, &config_options) {
|
||||
os_input.send_to_server(seed);
|
||||
}
|
||||
|
||||
if let Some(tx) = attachment_complete_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
@@ -187,6 +199,11 @@ pub fn zellij_server_listener(
|
||||
|
||||
if let Some(config_file_path) = &config_file_path {
|
||||
if let Ok(new_config) = Config::from_path(&config_file_path, Some(config.clone())) {
|
||||
// Re-seed host-query cache for this client
|
||||
// so OSC 10/11/4 replies follow the new theme.
|
||||
for seed in build_host_query_seed_msgs(&new_config, &config_options) {
|
||||
os_input.send_to_server(seed);
|
||||
}
|
||||
let set_config_payload = SetConfigPayload::from(&new_config);
|
||||
|
||||
let client_ids: Vec<String> = {
|
||||
@@ -233,11 +250,22 @@ pub fn zellij_server_listener(
|
||||
// Subscribe-only messages — not relevant for web clients
|
||||
Some(ServerToClientMsg::PaneRenderUpdate { .. }) => {},
|
||||
Some(ServerToClientMsg::SubscribedPaneClosed { .. }) => {},
|
||||
Some(ServerToClientMsg::ForwardQueryToHost { .. }) => {
|
||||
// Web clients do not currently participate in
|
||||
// host-terminal query forwarding — the browser
|
||||
// side has no stdin writer wired for the
|
||||
// barrier-based protocol. Silently drop.
|
||||
Some(ServerToClientMsg::ForwardQueryToHost { token, .. }) => {
|
||||
// Reply immediately with empty reply_bytes.
|
||||
// This is the existing convention that signals
|
||||
// "no host reply available — please synthesize
|
||||
// from cached state". The server's
|
||||
// synthesize_cached_reply path will use the
|
||||
// pixel dimensions and colors we have already
|
||||
// seeded from the browser/config, returning a
|
||||
// real answer rather than waiting for the
|
||||
// 1000ms forward timeout.
|
||||
os_input.send_to_server(
|
||||
ClientToServerMsg::ForwardedReplyFromHost {
|
||||
token,
|
||||
reply_bytes: Vec::new(),
|
||||
},
|
||||
);
|
||||
},
|
||||
None => {
|
||||
if unknown_message_count >= 1000 {
|
||||
|
||||
@@ -462,6 +462,199 @@ mod web_client_tests {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_terminal_metrics_translates_to_pixel_dimensions() {
|
||||
// Simulates a browser sending a TerminalMetrics control message
|
||||
// (the payload our websockets.js sendTerminalMetrics() helper
|
||||
// produces) and verifies the web server translates it into
|
||||
// ClientToServerMsg::TerminalPixelDimensions with field-for-field
|
||||
// accuracy. This guards the wire-format contract end-to-end
|
||||
// through serde + the match arm in websocket_handlers.rs.
|
||||
let _ = delete_db();
|
||||
|
||||
let test_token_name = "test_token_terminal_metrics";
|
||||
let read_only = false;
|
||||
let (auth_token, _) = create_token(Some(test_token_name.to_string()), read_only)
|
||||
.expect("Failed to create test token");
|
||||
|
||||
let session_manager = Arc::new(MockSessionManager::new());
|
||||
let client_os_api_factory = Arc::new(MockClientOsApiFactory::new());
|
||||
let factory_for_verification = client_os_api_factory.clone();
|
||||
|
||||
let config = Config::default();
|
||||
let options = Options::default();
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let port = addr.port();
|
||||
|
||||
let temp_config_path = std::env::temp_dir().join("test_config.kdl");
|
||||
let server_handle = tokio::spawn(async move {
|
||||
serve_web_client(
|
||||
config,
|
||||
options,
|
||||
Some(temp_config_path),
|
||||
listener,
|
||||
None,
|
||||
Some(session_manager),
|
||||
Some(client_os_api_factory),
|
||||
addr.ip(),
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
wait_for_server(port, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("Server failed to start");
|
||||
|
||||
let login_url = format!("http://127.0.0.1:{}/command/login", port);
|
||||
let login_payload = serde_json::json!({
|
||||
"auth_token": auth_token,
|
||||
"remember_me": true,
|
||||
});
|
||||
let login_response = timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
isahc::Request::post(&login_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(login_payload.to_string())
|
||||
.unwrap()
|
||||
.send()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(login_response.status().is_success());
|
||||
|
||||
let set_cookie_header = login_response.headers().get("set-cookie");
|
||||
let cookie_value = set_cookie_header.unwrap().to_str().unwrap();
|
||||
let session_token = cookie_value
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|part| part.split('=').nth(1))
|
||||
.unwrap();
|
||||
|
||||
let session_url = format!("http://127.0.0.1:{}/session", port);
|
||||
let mut client_response = timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking({
|
||||
let session_token = session_token.to_string();
|
||||
move || {
|
||||
isahc::Request::post(&session_url)
|
||||
.header("Cookie", format!("session_token={}", session_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.body("{}")
|
||||
.unwrap()
|
||||
.send()
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(client_response.status().is_success());
|
||||
|
||||
let client_data: serde_json::Value =
|
||||
serde_json::from_str(&client_response.text().unwrap()).unwrap();
|
||||
let web_client_id = client_data["web_client_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Open the control WebSocket and consume the initial SetConfig.
|
||||
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
|
||||
let (control_ws, _) = timeout(
|
||||
Duration::from_secs(5),
|
||||
connect_async_with_cookie(&control_ws_url, session_token),
|
||||
)
|
||||
.await
|
||||
.expect("Control WebSocket connection timed out")
|
||||
.expect("Failed to connect to control WebSocket");
|
||||
let (mut control_sink, mut control_stream) = control_ws.split();
|
||||
let _ = timeout(Duration::from_secs(2), control_stream.next())
|
||||
.await
|
||||
.expect("Timeout waiting for initial control message");
|
||||
|
||||
// The control channel only registers the client_id after the
|
||||
// first inbound message. Send a TerminalResize first to mirror
|
||||
// what the browser does at startup, then the TerminalMetrics.
|
||||
let resize_msg = WebClientToWebServerControlMessage {
|
||||
web_client_id: web_client_id.clone(),
|
||||
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
}),
|
||||
};
|
||||
control_sink
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&resize_msg).unwrap().into(),
|
||||
))
|
||||
.await
|
||||
.expect("Failed to send TerminalResize");
|
||||
|
||||
// Send the actual TerminalMetrics payload — this is what the
|
||||
// browser-side sendTerminalMetrics() helper writes onto the wire.
|
||||
// Hand-construct the JSON to lock in the exact field names the
|
||||
// browser uses, rather than going through the serde Serialize
|
||||
// impl (which would mask any rename mismatch).
|
||||
let metrics_json = serde_json::json!({
|
||||
"web_client_id": web_client_id,
|
||||
"payload": {
|
||||
"type": "TerminalMetrics",
|
||||
"cell_pixel_width": 9,
|
||||
"cell_pixel_height": 18,
|
||||
"text_area_pixel_width": 720,
|
||||
"text_area_pixel_height": 432,
|
||||
},
|
||||
});
|
||||
control_sink
|
||||
.send(Message::Text(metrics_json.to_string().into()))
|
||||
.await
|
||||
.expect("Failed to send TerminalMetrics");
|
||||
|
||||
// Give the server a moment to process both messages.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Inspect the captured ClientToServerMsg traffic on the mock OS
|
||||
// API for this web client.
|
||||
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
|
||||
let mut found_pixel_dims: Option<ClientToServerMsg> = None;
|
||||
for (_, mock_api) in mock_apis.iter() {
|
||||
for msg in mock_api.get_sent_messages() {
|
||||
if matches!(msg, ClientToServerMsg::TerminalPixelDimensions { .. }) {
|
||||
found_pixel_dims = Some(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pixel_dims = found_pixel_dims
|
||||
.expect("TerminalMetrics did not reach the server as TerminalPixelDimensions");
|
||||
match pixel_dims {
|
||||
ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
|
||||
let cell = pixel_dimensions
|
||||
.character_cell_size
|
||||
.expect("character_cell_size missing");
|
||||
let area = pixel_dimensions
|
||||
.text_area_size
|
||||
.expect("text_area_size missing");
|
||||
assert_eq!(cell.width, 9);
|
||||
assert_eq!(cell.height, 18);
|
||||
assert_eq!(area.width, 720);
|
||||
assert_eq!(area.height, 432);
|
||||
},
|
||||
other => panic!("unexpected msg variant: {:?}", other),
|
||||
}
|
||||
|
||||
drop(mock_apis);
|
||||
let _ = control_sink.close().await;
|
||||
server_handle.abort();
|
||||
|
||||
revoke_token(test_token_name).expect("Failed to revoke test token");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_unauthorized_access_without_session() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::web_client::authentication::SessionTokenHash;
|
||||
use crate::web_client::control_message::{
|
||||
SetConfigPayload, WebClientToWebServerControlMessage,
|
||||
SetConfigPayload, TerminalMetricsPayload, WebClientToWebServerControlMessage,
|
||||
WebClientToWebServerControlMessagePayload, WebServerToWebClientControlMessage,
|
||||
};
|
||||
use crate::web_client::message_handlers::{
|
||||
@@ -19,7 +19,11 @@ use axum::{
|
||||
use futures::StreamExt;
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use zellij_utils::{input::mouse::MouseEvent, ipc::ClientToServerMsg};
|
||||
use zellij_utils::{
|
||||
input::mouse::MouseEvent,
|
||||
ipc::{ClientToServerMsg, PixelDimensions},
|
||||
pane_size::SizeInPixels,
|
||||
};
|
||||
|
||||
pub async fn ws_handler_control(
|
||||
ws: WebSocketUpgrade,
|
||||
@@ -74,6 +78,9 @@ async fn handle_ws_control(
|
||||
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
|
||||
ClientToServerMsg::TerminalResize { new_size: size }
|
||||
},
|
||||
WebClientToWebServerControlMessagePayload::TerminalMetrics(metrics) => {
|
||||
terminal_metrics_to_ipc(metrics)
|
||||
},
|
||||
};
|
||||
|
||||
let _ = client_connection.send_to_server(client_msg);
|
||||
@@ -314,3 +321,100 @@ async fn handle_ws_terminal(
|
||||
}
|
||||
os_input.send_to_server(ClientToServerMsg::ClientExited);
|
||||
}
|
||||
|
||||
fn terminal_metrics_to_ipc(metrics: TerminalMetricsPayload) -> ClientToServerMsg {
|
||||
ClientToServerMsg::TerminalPixelDimensions {
|
||||
pixel_dimensions: PixelDimensions {
|
||||
text_area_size: Some(SizeInPixels {
|
||||
width: metrics.text_area_pixel_width,
|
||||
height: metrics.text_area_pixel_height,
|
||||
}),
|
||||
character_cell_size: Some(SizeInPixels {
|
||||
width: metrics.cell_pixel_width,
|
||||
height: metrics.cell_pixel_height,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn terminal_metrics_to_ipc_preserves_all_dimensions() {
|
||||
let metrics = TerminalMetricsPayload {
|
||||
cell_pixel_width: 9,
|
||||
cell_pixel_height: 18,
|
||||
text_area_pixel_width: 80 * 9,
|
||||
text_area_pixel_height: 24 * 18,
|
||||
};
|
||||
let msg = terminal_metrics_to_ipc(metrics);
|
||||
match msg {
|
||||
ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
|
||||
let cell = pixel_dimensions
|
||||
.character_cell_size
|
||||
.expect("cell size missing");
|
||||
let area = pixel_dimensions
|
||||
.text_area_size
|
||||
.expect("text area size missing");
|
||||
assert_eq!(cell.width, 9);
|
||||
assert_eq!(cell.height, 18);
|
||||
assert_eq!(area.width, 720);
|
||||
assert_eq!(area.height, 432);
|
||||
},
|
||||
other => panic!("expected TerminalPixelDimensions, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_metrics_round_trips_through_json_payload() {
|
||||
// The browser sends this message as JSON over the control
|
||||
// socket. Verify that the on-wire shape deserializes into the
|
||||
// variant we route into terminal_metrics_to_ipc.
|
||||
let raw = serde_json::json!({
|
||||
"web_client_id": "abc",
|
||||
"payload": {
|
||||
"type": "TerminalMetrics",
|
||||
"cell_pixel_width": 7,
|
||||
"cell_pixel_height": 14,
|
||||
"text_area_pixel_width": 560,
|
||||
"text_area_pixel_height": 336,
|
||||
}
|
||||
});
|
||||
let parsed: WebClientToWebServerControlMessage =
|
||||
serde_json::from_value(raw).expect("parse");
|
||||
let metrics = match parsed.payload {
|
||||
WebClientToWebServerControlMessagePayload::TerminalMetrics(m) => m,
|
||||
other => panic!("expected TerminalMetrics, got {:?}", other),
|
||||
};
|
||||
assert_eq!(metrics.cell_pixel_width, 7);
|
||||
assert_eq!(metrics.cell_pixel_height, 14);
|
||||
assert_eq!(metrics.text_area_pixel_width, 560);
|
||||
assert_eq!(metrics.text_area_pixel_height, 336);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_resize_still_deserializes_after_adding_variant() {
|
||||
// Regression guard for the new enum variant: the existing
|
||||
// TerminalResize wire shape must continue to parse unchanged
|
||||
// (no `type` rename, no required-field changes).
|
||||
let raw = serde_json::json!({
|
||||
"web_client_id": "abc",
|
||||
"payload": {
|
||||
"type": "TerminalResize",
|
||||
"rows": 24,
|
||||
"cols": 80,
|
||||
}
|
||||
});
|
||||
let parsed: WebClientToWebServerControlMessage =
|
||||
serde_json::from_value(raw).expect("parse");
|
||||
match parsed.payload {
|
||||
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
|
||||
assert_eq!(size.rows, 24);
|
||||
assert_eq!(size.cols, 80);
|
||||
},
|
||||
other => panic!("expected TerminalResize, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user