From 7cebac4188e4763d75c0e536f73c3ec9d4b94168 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 11 Mar 2026 09:01:54 +0100 Subject: [PATCH] feat(appd): add WebSocket UI endpoint for Servo shell integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the weft-appd WebSocket server that allows the system-ui.html page running inside Servo to send requests and receive push notifications without requiring custom SpiderMonkey bindings. ws.rs — WebSocket connection handler: - Accepts a tokio TcpStream, performs WebSocket handshake via tokio-tungstenite accept_async. - Reads JSON Text frames, deserializes as Request (serde_json), calls dispatch(), sends Response as JSON Text. - Subscribes to a broadcast::Receiver for server-push notifications (APP_READY, etc.); forwards to client via select!. - Handles close frames, partial errors, and lagged broadcast gracefully. main.rs — server changes: - broadcast::channel(16) created at startup; WebSocket handlers subscribe for push delivery. - TcpListener bound on 127.0.0.1:7410 (default) or WEFT_APPD_WS_PORT. - ws_port() / write_ws_port(): port written to XDG_RUNTIME_DIR/weft/appd.wsport for runtime discovery. - WS accept branch added to the main select! loop alongside Unix socket. ipc.rs — Response and AppStateKind now derive Clone (required by broadcast::Sender). system-ui.html — appd WebSocket client: - appdConnect(): opens ws://127.0.0.1:/appd with exponential backoff reconnect (1s → 16s max). - On open: sends QUERY_RUNNING to populate taskbar with live sessions. - handleAppdMessage(): maps LAUNCH_ACK and RUNNING_APPS to taskbar entries; APP_READY shows a timed notification; APP_STATE::stopped removes the taskbar entry. - WEFT_APPD_WS_PORT window global overrides the default port. New deps: tokio-tungstenite 0.24, futures-util 0.3 (sink+std), serde_json 1. --- Cargo.lock | 134 +++++++++++++++++++++++++++++++++-- crates/weft-appd/Cargo.toml | 3 + crates/weft-appd/src/ipc.rs | 2 +- crates/weft-appd/src/main.rs | 47 ++++++++++-- crates/weft-appd/src/ws.rs | 54 ++++++++++++++ infra/shell/system-ui.html | 93 ++++++++++++++++++++++++ 6 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 crates/weft-appd/src/ws.rs diff --git a/Cargo.lock b/Cargo.lock index b3d6e70..0c88b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,6 +158,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -343,6 +349,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "digest" version = "0.10.7" @@ -488,6 +500,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -501,6 +519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -550,6 +569,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -619,6 +649,22 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "id-arena" version = "2.3.0" @@ -1325,14 +1371,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1342,7 +1409,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1528,6 +1604,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1604,7 +1691,7 @@ dependencies = [ "libseat", "pkg-config", "profiling", - "rand", + "rand 0.9.2", "rustix 1.1.4", "sha2", "smallvec", @@ -1798,6 +1885,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -1920,6 +2019,24 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1956,6 +2073,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "valuable" version = "0.1.1" @@ -2275,10 +2398,13 @@ name = "weft-appd" version = "0.1.0" dependencies = [ "anyhow", + "futures-util", "rmp-serde", "sd-notify", "serde", + "serde_json", "tokio", + "tokio-tungstenite", "tracing", "tracing-subscriber", ] diff --git a/crates/weft-appd/Cargo.toml b/crates/weft-appd/Cargo.toml index ee6783e..51c17df 100644 --- a/crates/weft-appd/Cargo.toml +++ b/crates/weft-appd/Cargo.toml @@ -14,5 +14,8 @@ sd-notify = "0.4" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] } serde = { version = "1", features = ["derive"] } rmp-serde = "1" +serde_json = "1" +tokio-tungstenite = "0.24" +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/weft-appd/src/ipc.rs b/crates/weft-appd/src/ipc.rs index 3566a07..215a7dd 100644 --- a/crates/weft-appd/src/ipc.rs +++ b/crates/weft-appd/src/ipc.rs @@ -10,7 +10,7 @@ pub enum Request { QueryAppState { session_id: u64 }, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] pub enum Response { LaunchAck { diff --git a/crates/weft-appd/src/main.rs b/crates/weft-appd/src/main.rs index 19bf54f..eedd70d 100644 --- a/crates/weft-appd/src/main.rs +++ b/crates/weft-appd/src/main.rs @@ -6,10 +6,11 @@ use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; mod ipc; +mod ws; use ipc::{AppStateKind, Request, Response}; -type Registry = Arc>; +pub(crate) type Registry = Arc>; #[derive(Default)] struct SessionRegistry { @@ -65,9 +66,18 @@ async fn run() -> anyhow::Result<()> { .with_context(|| format!("bind {}", socket_path.display()))?; tracing::info!(path = %socket_path.display(), "IPC socket listening"); - let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]); - let registry: Registry = Arc::new(Mutex::new(SessionRegistry::default())); + let (broadcast_tx, _) = tokio::sync::broadcast::channel::(16); + + let ws_port = ws_port(); + let ws_addr: std::net::SocketAddr = format!("127.0.0.1:{ws_port}").parse()?; + let ws_listener = tokio::net::TcpListener::bind(ws_addr) + .await + .with_context(|| format!("bind WebSocket {ws_addr}"))?; + tracing::info!(addr = %ws_addr, "WebSocket listener ready"); + write_ws_port(ws_port)?; + + let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]); let mut shutdown = std::pin::pin!(tokio::signal::ctrl_c()); @@ -78,7 +88,17 @@ async fn run() -> anyhow::Result<()> { let reg = Arc::clone(®istry); tokio::spawn(async move { if let Err(e) = handle_connection(stream, reg).await { - tracing::warn!(error = %e, "connection error"); + tracing::warn!(error = %e, "unix connection error"); + } + }); + } + result = ws_listener.accept() => { + let (stream, _) = result.context("ws accept")?; + let reg = Arc::clone(®istry); + let rx = broadcast_tx.subscribe(); + tokio::spawn(async move { + if let Err(e) = ws::handle_ws_connection(stream, reg, rx).await { + tracing::warn!(error = %e, "ws connection error"); } }); } @@ -93,6 +113,23 @@ async fn run() -> anyhow::Result<()> { Ok(()) } +fn ws_port() -> u16 { + std::env::var("WEFT_APPD_WS_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(7410) +} + +fn write_ws_port(port: u16) -> anyhow::Result<()> { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR").context("XDG_RUNTIME_DIR not set")?; + let path = PathBuf::from(runtime_dir).join("weft/appd.wsport"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, port.to_string())?; + Ok(()) +} + async fn handle_connection( stream: tokio::net::UnixStream, registry: Registry, @@ -110,7 +147,7 @@ async fn handle_connection( Ok(()) } -async fn dispatch(req: Request, registry: &Registry) -> Response { +pub(crate) async fn dispatch(req: Request, registry: &Registry) -> Response { match req { Request::LaunchApp { app_id, diff --git a/crates/weft-appd/src/ws.rs b/crates/weft-appd/src/ws.rs new file mode 100644 index 0000000..964e6a6 --- /dev/null +++ b/crates/weft-appd/src/ws.rs @@ -0,0 +1,54 @@ +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::broadcast; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +use crate::{Registry, dispatch, ipc::Request, ipc::Response}; + +pub async fn handle_ws_connection( + stream: tokio::net::TcpStream, + registry: Registry, + broadcast_rx: broadcast::Receiver, +) -> anyhow::Result<()> { + let ws_stream = accept_async(stream).await?; + let (mut ws_write, mut ws_read) = ws_stream.split(); + let mut broadcast_rx = broadcast_rx; + + loop { + tokio::select! { + msg = ws_read.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + let req: Request = match serde_json::from_str(&text) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "invalid WS request"); + continue; + } + }; + tracing::debug!(?req, "ws request"); + let resp = dispatch(req, ®istry).await; + let json = serde_json::to_string(&resp)?; + ws_write.send(Message::Text(json)).await?; + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(e)) => { + tracing::warn!(error = %e, "ws error"); + break; + } + } + } + notification = broadcast_rx.recv() => { + match notification { + Ok(resp) => { + let json = serde_json::to_string(&resp)?; + ws_write.send(Message::Text(json)).await?; + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + } + Ok(()) +} diff --git a/infra/shell/system-ui.html b/infra/shell/system-ui.html index 2040d80..322bc49 100644 --- a/infra/shell/system-ui.html +++ b/infra/shell/system-ui.html @@ -161,6 +161,99 @@ launcher.setAttribute('hidden', ''); } }); + + var APPD_WS_PORT = window.WEFT_APPD_WS_PORT || 7410; + var ws = null; + var wsReconnectDelay = 1000; + + function appdConnect() { + try { + ws = new WebSocket('ws://127.0.0.1:' + APPD_WS_PORT + '/appd'); + } catch (e) { + scheduleReconnect(); + return; + } + + ws.addEventListener('open', function () { + wsReconnectDelay = 1000; + ws.send(JSON.stringify({ type: 'QUERY_RUNNING' })); + }); + + ws.addEventListener('message', function (ev) { + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + handleAppdMessage(msg); + }); + + ws.addEventListener('close', function () { + ws = null; + scheduleReconnect(); + }); + + ws.addEventListener('error', function () { + ws = null; + }); + } + + function scheduleReconnect() { + setTimeout(appdConnect, wsReconnectDelay); + wsReconnectDelay = Math.min(wsReconnectDelay * 2, 16000); + } + + function handleAppdMessage(msg) { + if (msg.type === 'APP_READY') { + showNotification('App ready (session ' + msg.session_id + ')'); + } else if (msg.type === 'APP_STATE') { + if (msg.state === 'stopped') { + removeTaskbarEntry(msg.session_id); + } + } else if (msg.type === 'RUNNING_APPS') { + msg.session_ids.forEach(function (id) { + ensureTaskbarEntry(id); + }); + } else if (msg.type === 'LAUNCH_ACK') { + ensureTaskbarEntry(msg.session_id); + } + } + + function ensureTaskbarEntry(sessionId) { + var id = 'task-' + sessionId; + if (document.getElementById(id)) { return; } + var el = document.createElement('weft-taskbar-app'); + el.id = id; + el.dataset.sessionId = sessionId; + el.textContent = '● ' + sessionId; + el.style.cssText = 'font-size:12px;color:var(--text-secondary);padding:0 6px;cursor:pointer;'; + el.title = 'Session ' + sessionId; + var taskbar = document.querySelector('weft-taskbar'); + var clock = document.getElementById('clock'); + taskbar.insertBefore(el, clock); + } + + function removeTaskbarEntry(sessionId) { + var el = document.getElementById('task-' + sessionId); + if (el) { el.remove(); } + } + + function showNotification(text) { + var center = document.getElementById('notifications'); + var note = document.createElement('div'); + note.textContent = text; + note.style.cssText = [ + 'background:var(--surface-bg)', + 'border:1px solid var(--surface-border)', + 'border-radius:8px', + 'padding:10px 14px', + 'font-size:13px', + 'color:var(--text-primary)', + 'pointer-events:auto', + 'backdrop-filter:blur(20px)', + ].join(';'); + center.appendChild(note); + setTimeout(function () { note.remove(); }, 4000); + } + + appdConnect(); }());