feat(appd): add WebSocket UI endpoint for Servo shell integration

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<Response> 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<Response>).

system-ui.html — appd WebSocket client:
- appdConnect(): opens ws://127.0.0.1:<port>/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.
This commit is contained in:
Marco Allegretti 2026-03-11 09:01:54 +01:00
parent ad40271d69
commit 7cebac4188
6 changed files with 323 additions and 10 deletions

134
Cargo.lock generated
View file

@ -158,6 +158,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@ -343,6 +349,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -488,6 +500,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.32"
@ -501,6 +519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
"futures-task", "futures-task",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
@ -550,6 +569,17 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@ -619,6 +649,22 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 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]] [[package]]
name = "id-arena" name = "id-arena"
version = "2.3.0" version = "2.3.0"
@ -1325,14 +1371,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core", "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]] [[package]]
@ -1342,7 +1409,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@ -1528,6 +1604,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@ -1604,7 +1691,7 @@ dependencies = [
"libseat", "libseat",
"pkg-config", "pkg-config",
"profiling", "profiling",
"rand", "rand 0.9.2",
"rustix 1.1.4", "rustix 1.1.4",
"sha2", "sha2",
"smallvec", "smallvec",
@ -1798,6 +1885,18 @@ dependencies = [
"syn", "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]] [[package]]
name = "toml" name = "toml"
version = "0.9.12+spec-1.1.0" version = "0.9.12+spec-1.1.0"
@ -1920,6 +2019,24 @@ dependencies = [
"tracing-log", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@ -1956,6 +2073,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@ -2275,10 +2398,13 @@ name = "weft-appd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"futures-util",
"rmp-serde", "rmp-serde",
"sd-notify", "sd-notify",
"serde", "serde",
"serde_json",
"tokio", "tokio",
"tokio-tungstenite",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]

View file

@ -14,5 +14,8 @@ sd-notify = "0.4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
rmp-serde = "1" 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 = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -10,7 +10,7 @@ pub enum Request {
QueryAppState { session_id: u64 }, QueryAppState { session_id: u64 },
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] #[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Response { pub enum Response {
LaunchAck { LaunchAck {

View file

@ -6,10 +6,11 @@ use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
mod ipc; mod ipc;
mod ws;
use ipc::{AppStateKind, Request, Response}; use ipc::{AppStateKind, Request, Response};
type Registry = Arc<Mutex<SessionRegistry>>; pub(crate) type Registry = Arc<Mutex<SessionRegistry>>;
#[derive(Default)] #[derive(Default)]
struct SessionRegistry { struct SessionRegistry {
@ -65,9 +66,18 @@ async fn run() -> anyhow::Result<()> {
.with_context(|| format!("bind {}", socket_path.display()))?; .with_context(|| format!("bind {}", socket_path.display()))?;
tracing::info!(path = %socket_path.display(), "IPC socket listening"); 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 registry: Registry = Arc::new(Mutex::new(SessionRegistry::default()));
let (broadcast_tx, _) = tokio::sync::broadcast::channel::<Response>(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()); let mut shutdown = std::pin::pin!(tokio::signal::ctrl_c());
@ -78,7 +88,17 @@ async fn run() -> anyhow::Result<()> {
let reg = Arc::clone(&registry); let reg = Arc::clone(&registry);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_connection(stream, reg).await { 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(&registry);
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(()) 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( async fn handle_connection(
stream: tokio::net::UnixStream, stream: tokio::net::UnixStream,
registry: Registry, registry: Registry,
@ -110,7 +147,7 @@ async fn handle_connection(
Ok(()) Ok(())
} }
async fn dispatch(req: Request, registry: &Registry) -> Response { pub(crate) async fn dispatch(req: Request, registry: &Registry) -> Response {
match req { match req {
Request::LaunchApp { Request::LaunchApp {
app_id, app_id,

View file

@ -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<Response>,
) -> 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, &registry).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(())
}

View file

@ -161,6 +161,99 @@
launcher.setAttribute('hidden', ''); 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();
}()); }());
</script> </script>
</body> </body>