feat(servo-shell): per-app WebView lifecycle driven by appd events (servo-embed only)

Task 10 -- App WebView lifecycle.

appd_ws module (servo-embed gated):
  Background thread connects to the appd WebSocket on startup.
  Sends QUERY_RUNNING to receive initial running sessions.
  Translates LAUNCH_ACK -> AppdCmd::Launch and APP_STATE stopped
  -> AppdCmd::Stop, then wakes the winit event loop via the
  shared EventLoopWaker.

embedder changes:
  App struct gains app_rx (mpsc receiver), app_webviews
  (HashMap<session_id, WebView>), active_session, and a stored
  rendering_context used when creating app WebViews later.

  create_app_webview(): resolves weft-app://<app_id>/index.html
  to a file URL, creates a dedicated UserContentManager with the
  weftIpc bridge injected (includes window.weftSessionId), builds
  and registers a new WebView.

  about_to_wait() drains app_rx: creates WebViews for Launch
  commands, removes and clears active_session for Stop commands.

  active_webview() returns the active-session WebView when one
  exists, falling back to the system-ui WebView. Rendering,
  keyboard, and mouse events all route through active_webview().

  Resize propagates to both the system WebView and all app WebViews.

  run() creates the mpsc channel and spawns the appd listener
  before entering the winit event loop.
This commit is contained in:
Marco Allegretti 2026-03-11 17:59:12 +01:00
parent b4824aa8d4
commit ed5a69bb74
5 changed files with 172 additions and 7 deletions

2
Cargo.lock generated
View file

@ -4072,8 +4072,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.11.0", "bitflags 2.11.0",
"serde_json",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tungstenite",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
"wayland-scanner", "wayland-scanner",

View file

@ -22,3 +22,5 @@ wayland-client = "0.31"
wayland-backend = "0.3" wayland-backend = "0.3"
wayland-scanner = "0.31" wayland-scanner = "0.31"
bitflags = "2" bitflags = "2"
serde_json = "1"
tungstenite = "0.24"

View file

@ -0,0 +1,88 @@
#![cfg(feature = "servo-embed")]
use std::sync::mpsc;
pub enum AppdCmd {
Launch { session_id: u64, app_id: String },
Stop { session_id: u64 },
}
pub fn spawn_appd_listener(
ws_port: u16,
tx: mpsc::SyncSender<AppdCmd>,
wake: Box<dyn Fn() + Send>,
) {
std::thread::Builder::new()
.name("appd-ws".into())
.spawn(move || run_listener(ws_port, tx, wake))
.ok();
}
fn run_listener(ws_port: u16, tx: mpsc::SyncSender<AppdCmd>, wake: Box<dyn Fn() + Send>) {
let url = format!("ws://127.0.0.1:{ws_port}");
let (mut ws, _) = match tungstenite::connect(url) {
Ok(p) => p,
Err(e) => {
tracing::warn!("appd WebSocket connect failed: {e}");
return;
}
};
let _ = ws.send(tungstenite::Message::Text(
r#"{"type":"QUERY_RUNNING"}"#.into(),
));
loop {
match ws.read() {
Ok(tungstenite::Message::Text(text)) => {
process_message(&text, &tx, &*wake);
}
Ok(_) => {}
Err(_) => break,
}
}
}
fn process_message(text: &str, tx: &mpsc::SyncSender<AppdCmd>, wake: &dyn Fn()) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
return;
};
match v["type"].as_str() {
Some("LAUNCH_ACK") => {
let Some(session_id) = v["session_id"].as_u64() else {
return;
};
let Some(app_id) = v["app_id"].as_str().map(str::to_string) else {
return;
};
if tx.try_send(AppdCmd::Launch { session_id, app_id }).is_ok() {
wake();
}
}
Some("RUNNING_APPS") => {
let Some(sessions) = v["sessions"].as_array() else {
return;
};
for s in sessions {
let Some(session_id) = s["session_id"].as_u64() else {
continue;
};
let Some(app_id) = s["app_id"].as_str().map(str::to_string) else {
continue;
};
let _ = tx.try_send(AppdCmd::Launch { session_id, app_id });
}
wake();
}
Some("APP_STATE") if v["state"].as_str() == Some("stopped") => {
let Some(session_id) = v["session_id"].as_u64() else {
return;
};
if tx.try_send(AppdCmd::Stop { session_id }).is_ok() {
wake();
}
}
_ => {}
}
}

View file

@ -1,7 +1,9 @@
#![cfg(feature = "servo-embed")] #![cfg(feature = "servo-embed")]
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::sync::mpsc;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use servo::{ use servo::{
@ -72,6 +74,10 @@ struct App {
window: Option<Arc<Window>>, window: Option<Arc<Window>>,
servo: Option<servo::Servo>, servo: Option<servo::Servo>,
webview: Option<servo::WebView>, webview: Option<servo::WebView>,
rendering_context: Option<Rc<servo::SoftwareRenderingContext>>,
app_webviews: HashMap<u64, servo::WebView>,
active_session: Option<u64>,
app_rx: mpsc::Receiver<crate::appd_ws::AppdCmd>,
redraw_requested: Arc<std::sync::atomic::AtomicBool>, redraw_requested: Arc<std::sync::atomic::AtomicBool>,
waker: WeftEventLoopWaker, waker: WeftEventLoopWaker,
shutting_down: bool, shutting_down: bool,
@ -80,13 +86,22 @@ struct App {
} }
impl App { impl App {
fn new(url: ServoUrl, waker: WeftEventLoopWaker, ws_port: u16) -> Self { fn new(
url: ServoUrl,
waker: WeftEventLoopWaker,
ws_port: u16,
app_rx: mpsc::Receiver<crate::appd_ws::AppdCmd>,
) -> Self {
Self { Self {
url, url,
ws_port, ws_port,
window: None, window: None,
servo: None, servo: None,
webview: None, webview: None,
rendering_context: None,
app_webviews: HashMap::new(),
active_session: None,
app_rx,
redraw_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), redraw_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
waker, waker,
shutting_down: false, shutting_down: false,
@ -95,6 +110,40 @@ impl App {
} }
} }
fn active_webview(&self) -> Option<&servo::WebView> {
self.active_session
.and_then(|sid| self.app_webviews.get(&sid))
.or(self.webview.as_ref())
}
fn create_app_webview(&mut self, session_id: u64, app_id: &str) {
let (Some(servo), Some(rc)) = (&self.servo, &self.rendering_context) else {
return;
};
let url_str = format!("weft-app://{app_id}/index.html");
let raw = match ServoUrl::parse(&url_str) {
Ok(u) => u,
Err(_) => return,
};
let url = resolve_weft_app_url(&raw).unwrap_or(raw);
let ucm = Rc::new(UserContentManager::new(servo));
let bridge_js = format!(
r#"(function(){{var ws=new WebSocket('ws://127.0.0.1:{p}');var sid={sid};var q=[];var r=false;ws.onopen=function(){{r=true;q.forEach(function(m){{ws.send(JSON.stringify(m))}});q.length=0}};window.weftSessionId=sid;window.weftIpc={{send:function(m){{if(r)ws.send(JSON.stringify(m));else q.push(m)}},onmessage:null}};ws.onmessage=function(e){{if(window.weftIpc.onmessage)window.weftIpc.onmessage(JSON.parse(e.data))}}}})()"#,
p = self.ws_port,
sid = session_id,
);
ucm.add_script(Rc::new(UserScript::new(bridge_js, None)));
let wv = WebViewBuilder::new(servo, Rc::clone(rc))
.delegate(Rc::new(WeftWebViewDelegate {
redraw_requested: Arc::clone(&self.redraw_requested),
}))
.user_content_manager(ucm)
.url(url)
.build();
self.app_webviews.insert(session_id, wv);
self.active_session = Some(session_id);
}
fn blit_to_window(window: &Arc<Window>, rendering_context: &servo::SoftwareRenderingContext) { fn blit_to_window(window: &Arc<Window>, rendering_context: &servo::SoftwareRenderingContext) {
let size = window.inner_size(); let size = window.inner_size();
let Some(pixels) = rendering_context.read_pixels() else { let Some(pixels) = rendering_context.read_pixels() else {
@ -163,6 +212,7 @@ impl ApplicationHandler<ServoWake> for App {
self.servo = Some(servo); self.servo = Some(servo);
self.webview = Some(webview); self.webview = Some(webview);
self.rendering_context = Some(rendering_context);
} }
fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ServoWake) { fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ServoWake) {
@ -176,6 +226,19 @@ impl ApplicationHandler<ServoWake> for App {
event_loop.exit(); event_loop.exit();
return; return;
} }
while let Ok(cmd) = self.app_rx.try_recv() {
match cmd {
crate::appd_ws::AppdCmd::Launch { session_id, app_id } => {
self.create_app_webview(session_id, &app_id);
}
crate::appd_ws::AppdCmd::Stop { session_id } => {
self.app_webviews.remove(&session_id);
if self.active_session == Some(session_id) {
self.active_session = None;
}
}
}
}
if let Some(servo) = &self.servo { if let Some(servo) = &self.servo {
servo.spin_event_loop(); servo.spin_event_loop();
} }
@ -198,7 +261,7 @@ impl ApplicationHandler<ServoWake> for App {
match event { match event {
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => {
if let (Some(window), Some(servo)) = (&self.window, &self.servo) { if let (Some(window), Some(servo)) = (&self.window, &self.servo) {
if let Some(wv) = &self.webview { if let Some(wv) = self.active_webview() {
let rc = wv.rendering_context(); let rc = wv.rendering_context();
Self::blit_to_window(window, rc); Self::blit_to_window(window, rc);
} }
@ -206,15 +269,19 @@ impl ApplicationHandler<ServoWake> for App {
} }
} }
WindowEvent::Resized(new_size) => { WindowEvent::Resized(new_size) => {
let sz = servo::euclid::Size2D::new(new_size.width, new_size.height);
if let Some(wv) = &self.webview { if let Some(wv) = &self.webview {
wv.resize(servo::euclid::Size2D::new(new_size.width, new_size.height)); wv.resize(sz);
}
for wv in self.app_webviews.values() {
wv.resize(sz);
} }
} }
WindowEvent::ModifiersChanged(mods) => { WindowEvent::ModifiersChanged(mods) => {
self.modifiers = mods.state(); self.modifiers = mods.state();
} }
WindowEvent::KeyboardInput { event, .. } => { WindowEvent::KeyboardInput { event, .. } => {
if let Some(wv) = &self.webview { if let Some(wv) = self.active_webview() {
let ev = super::keyutils::keyboard_event_from_winit(&event, self.modifiers); let ev = super::keyutils::keyboard_event_from_winit(&event, self.modifiers);
let _ = wv.notify_input_event(InputEvent::Keyboard(ev)); let _ = wv.notify_input_event(InputEvent::Keyboard(ev));
} }
@ -222,7 +289,7 @@ impl ApplicationHandler<ServoWake> for App {
WindowEvent::CursorMoved { position, .. } => { WindowEvent::CursorMoved { position, .. } => {
let pt = servo::euclid::default::Point2D::new(position.x as f32, position.y as f32); let pt = servo::euclid::default::Point2D::new(position.x as f32, position.y as f32);
self.cursor_pos = pt; self.cursor_pos = pt;
if let Some(wv) = &self.webview { if let Some(wv) = self.active_webview() {
let _ = wv.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(pt))); let _ = wv.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(pt)));
} }
} }
@ -237,7 +304,7 @@ impl ApplicationHandler<ServoWake> for App {
ElementState::Pressed => MouseButtonAction::Click, ElementState::Pressed => MouseButtonAction::Click,
ElementState::Released => MouseButtonAction::Up, ElementState::Released => MouseButtonAction::Up,
}; };
if let Some(wv) = &self.webview { if let Some(wv) = self.active_webview() {
let _ = wv.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( let _ = wv.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new(
action, action,
btn, btn,
@ -305,7 +372,11 @@ pub fn run(html_path: &Path, ws_port: u16) -> anyhow::Result<()> {
proxy: Arc::new(Mutex::new(event_loop.create_proxy())), proxy: Arc::new(Mutex::new(event_loop.create_proxy())),
}; };
let mut app = App::new(url, waker, ws_port); let (app_tx, app_rx) = mpsc::sync_channel::<crate::appd_ws::AppdCmd>(64);
let waker_for_thread = waker.clone();
crate::appd_ws::spawn_appd_listener(ws_port, app_tx, Box::new(move || waker_for_thread.wake()));
let mut app = App::new(url, waker, ws_port, app_rx);
event_loop event_loop
.run_app(&mut app) .run_app(&mut app)
.map_err(|e| anyhow::anyhow!("event loop run: {e}")) .map_err(|e| anyhow::anyhow!("event loop run: {e}"))

View file

@ -2,6 +2,8 @@ use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
#[cfg(feature = "servo-embed")]
mod appd_ws;
#[cfg(feature = "servo-embed")] #[cfg(feature = "servo-embed")]
mod embedder; mod embedder;
#[cfg(feature = "servo-embed")] #[cfg(feature = "servo-embed")]