diff --git a/Cargo.toml b/Cargo.toml index 6015158..afae137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/weft-app-shell", "crates/weft-appd", "crates/weft-build-meta", "crates/weft-compositor", diff --git a/crates/weft-app-shell/Cargo.toml b/crates/weft-app-shell/Cargo.toml new file mode 100644 index 0000000..d58d50e --- /dev/null +++ b/crates/weft-app-shell/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "weft-app-shell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "weft-app-shell" +path = "src/main.rs" + +[features] +# Enable actual Servo rendering. Requires manually adding the deps listed in +# weft-servo-shell/SERVO_PIN.md to this file before building; they are not +# included here to avoid pulling the Servo monorepo (~1 GB) into every +# `cargo check` cycle. +servo-embed = [] + +[dependencies] +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +wayland-client = "0.31" +wayland-backend = "0.3" +wayland-scanner = "0.31" +bitflags = "2" diff --git a/crates/weft-app-shell/src/embedder.rs b/crates/weft-app-shell/src/embedder.rs new file mode 100644 index 0000000..20909cc --- /dev/null +++ b/crates/weft-app-shell/src/embedder.rs @@ -0,0 +1,380 @@ +#![cfg(feature = "servo-embed")] + +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use servo::{ + EventLoopWaker, InputEvent, MouseButton as ServoMouseButton, MouseButtonAction, + MouseButtonEvent, MouseMoveEvent, ServoBuilder, ServoDelegate, ServoUrl, UserContentManager, + UserScript, WebViewBuilder, WebViewDelegate, +}; +use winit::{ + application::ApplicationHandler, + event::{ElementState, MouseButton, WindowEvent}, + event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}, + keyboard::ModifiersState, + window::{Window, WindowAttributes, WindowId}, +}; + +#[derive(Clone)] +struct WeftEventLoopWaker { + proxy: Arc>>, +} + +#[derive(Debug, Clone)] +struct ServoWake; + +impl EventLoopWaker for WeftEventLoopWaker { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn wake(&self) { + let _ = self + .proxy + .lock() + .unwrap_or_else(|p| p.into_inner()) + .send_event(ServoWake); + } +} + +struct WeftServoDelegate; + +impl ServoDelegate for WeftServoDelegate { + fn notify_error(&self, error: servo::ServoError) { + tracing::error!(?error, "Servo error"); + } +} + +struct WeftWebViewDelegate { + redraw_requested: Arc, +} + +impl WebViewDelegate for WeftWebViewDelegate { + fn notify_new_frame_ready(&self, _webview: servo::WebView) { + self.redraw_requested + .store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +enum RenderingCtx { + Software(Rc), + Egl(Rc), +} + +impl RenderingCtx { + fn as_dyn(&self) -> Rc { + match self { + Self::Software(rc) => Rc::clone(rc) as Rc, + Self::Egl(rc) => Rc::clone(rc) as Rc, + } + } +} + +struct App { + url: ServoUrl, + session_id: u64, + ws_port: u16, + window: Option>, + servo: Option, + webview: Option, + rendering_context: Option, + redraw_requested: Arc, + waker: WeftEventLoopWaker, + shutting_down: bool, + ready_signalled: bool, + modifiers: ModifiersState, + cursor_pos: servo::euclid::default::Point2D, + shell_client: Option, +} + +impl App { + fn new( + url: ServoUrl, + session_id: u64, + ws_port: u16, + waker: WeftEventLoopWaker, + shell_client: Option, + ) -> Self { + Self { + url, + session_id, + ws_port, + window: None, + servo: None, + webview: None, + rendering_context: None, + redraw_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), + waker, + shutting_down: false, + ready_signalled: false, + modifiers: ModifiersState::default(), + cursor_pos: servo::euclid::default::Point2D::zero(), + shell_client, + } + } + + fn render_frame(window: &Arc, ctx: &RenderingCtx) { + match ctx { + RenderingCtx::Software(rc) => Self::blit_software(window, rc), + RenderingCtx::Egl(_) => {} + } + } + + fn blit_software(window: &Arc, rendering_context: &servo::SoftwareRenderingContext) { + let size = window.inner_size(); + let Some(pixels) = rendering_context.read_pixels() else { + return; + }; + let ctx = softbuffer::Context::new(Arc::clone(window)).expect("softbuffer context"); + let mut surface = + softbuffer::Surface::new(&ctx, Arc::clone(window)).expect("softbuffer surface"); + let _ = surface.resize( + std::num::NonZeroU32::new(size.width).unwrap_or(std::num::NonZeroU32::new(1).unwrap()), + std::num::NonZeroU32::new(size.height).unwrap_or(std::num::NonZeroU32::new(1).unwrap()), + ); + let Ok(mut buf) = surface.buffer_mut() else { + return; + }; + for (dst, src) in buf.iter_mut().zip(pixels.chunks(4)) { + *dst = u32::from_be_bytes([0, src[0], src[1], src[2]]); + } + let _ = buf.present(); + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + + let title = format!("WEFT App — {}", self.url.host_str().unwrap_or("app")); + let attrs = WindowAttributes::default().with_title(title); + let window = Arc::new( + event_loop + .create_window(attrs) + .expect("failed to create app window"), + ); + let size = window.inner_size(); + self.window = Some(Arc::clone(&window)); + + let servo = ServoBuilder::default() + .event_loop_waker(Box::new(self.waker.clone())) + .build(); + + servo.set_delegate(Rc::new(WeftServoDelegate)); + + let rendering_context = build_rendering_ctx(event_loop, &window, size); + + 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 = self.session_id, + ); + ucm.add_script(Rc::new(UserScript::new(bridge_js, None))); + + let webview = WebViewBuilder::new(&servo, rendering_context.as_dyn()) + .delegate(Rc::new(WeftWebViewDelegate { + redraw_requested: Arc::clone(&self.redraw_requested), + })) + .user_content_manager(ucm) + .url(self.url.clone()) + .build(); + + self.servo = Some(servo); + self.webview = Some(webview); + self.rendering_context = Some(rendering_context); + + if !self.ready_signalled { + self.ready_signalled = true; + println!("READY"); + use std::io::Write; + let _ = std::io::stdout().flush(); + } + } + + fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ServoWake) { + if let Some(servo) = &self.servo { + servo.spin_event_loop(); + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if self.shutting_down { + event_loop.exit(); + return; + } + if let Some(sc) = &mut self.shell_client { + match sc.dispatch_pending() { + Ok(false) => { + self.shutting_down = true; + if let Some(servo) = &self.servo { + servo.start_shutting_down(); + } + } + Err(e) => tracing::warn!("shell client dispatch error: {e}"), + Ok(true) => {} + } + } + if let Some(servo) = &self.servo { + servo.spin_event_loop(); + } + if self + .redraw_requested + .swap(false, std::sync::atomic::Ordering::Relaxed) + { + if let Some(w) = &self.window { + w.request_redraw(); + } + } + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + match event { + WindowEvent::RedrawRequested => { + if let (Some(window), Some(servo)) = (&self.window, &self.servo) { + if let Some(rc) = &self.rendering_context { + Self::render_frame(window, rc); + } + servo.spin_event_loop(); + } + } + WindowEvent::Resized(new_size) => { + let sz = servo::euclid::Size2D::new(new_size.width, new_size.height); + if let Some(wv) = &self.webview { + wv.resize(sz); + } + } + WindowEvent::ModifiersChanged(mods) => { + self.modifiers = mods.state(); + } + WindowEvent::KeyboardInput { event, .. } => { + if let Some(wv) = &self.webview { + let ev = super::keyutils::keyboard_event_from_winit(&event, self.modifiers); + let _ = wv.notify_input_event(InputEvent::Keyboard(ev)); + } + } + WindowEvent::CursorMoved { position, .. } => { + let pt = servo::euclid::default::Point2D::new(position.x as f32, position.y as f32); + self.cursor_pos = pt; + if let Some(wv) = &self.webview { + let _ = wv.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(pt))); + } + } + WindowEvent::MouseInput { state, button, .. } => { + let btn = match button { + MouseButton::Left => ServoMouseButton::Left, + MouseButton::Right => ServoMouseButton::Right, + MouseButton::Middle => ServoMouseButton::Middle, + _ => return, + }; + let action = match state { + ElementState::Pressed => MouseButtonAction::Click, + ElementState::Released => MouseButtonAction::Up, + }; + if let Some(wv) = &self.webview { + let _ = wv.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( + action, + btn, + self.cursor_pos.cast_unit(), + ))); + } + } + WindowEvent::CloseRequested => { + self.shutting_down = true; + if let Some(servo) = &self.servo { + servo.start_shutting_down(); + } + event_loop.exit(); + } + _ => {} + } + } +} + +fn build_rendering_ctx( + event_loop: &ActiveEventLoop, + window: &Arc, + size: winit::dpi::PhysicalSize, +) -> RenderingCtx { + if std::env::var_os("WEFT_EGL_RENDERING").is_some() { + let display_handle = event_loop.display_handle(); + let window_handle = window.window_handle(); + if let (Ok(dh), Ok(wh)) = (display_handle, window_handle) { + match servo::WindowRenderingContext::new(dh, wh, size) { + Ok(rc) => { + tracing::info!("using EGL rendering context"); + return RenderingCtx::Egl(Rc::new(rc)); + } + Err(e) => { + tracing::warn!("EGL rendering context failed ({e}), falling back to software"); + } + } + } + } + RenderingCtx::Software(Rc::new( + servo::SoftwareRenderingContext::new(servo::euclid::Size2D::new(size.width, size.height)) + .expect("SoftwareRenderingContext"), + )) +} + +fn resolve_weft_app_url(app_id: &str) -> Option { + let url_str = format!("weft-app://{app_id}/index.html"); + let raw = ServoUrl::parse(&url_str).ok()?; + let rel = raw.path().trim_start_matches('/'); + let file_path = app_store_roots() + .into_iter() + .map(|r| r.join(app_id).join("ui").join(rel)) + .find(|p| p.exists())?; + let s = format!("file://{}", file_path.display()); + ServoUrl::parse(&s).ok() +} + +fn app_store_roots() -> Vec { + if let Ok(v) = std::env::var("WEFT_APP_STORE") { + return vec![PathBuf::from(v)]; + } + let mut roots = Vec::new(); + if let Ok(home) = std::env::var("HOME") { + roots.push( + PathBuf::from(home) + .join(".local") + .join("share") + .join("weft") + .join("apps"), + ); + } + roots.push(PathBuf::from("/usr/share/weft/apps")); + roots +} + +pub fn run( + app_id: &str, + session_id: u64, + ws_port: u16, + shell_client: Option, +) -> anyhow::Result<()> { + let url = resolve_weft_app_url(app_id) + .ok_or_else(|| anyhow::anyhow!("no ui/index.html found for app {app_id}"))?; + + let event_loop = EventLoop::::with_user_event() + .build() + .map_err(|e| anyhow::anyhow!("event loop: {e}"))?; + + let waker = WeftEventLoopWaker { + proxy: Arc::new(Mutex::new(event_loop.create_proxy())), + }; + + let mut app = App::new(url, session_id, ws_port, waker, shell_client); + event_loop + .run_app(&mut app) + .map_err(|e| anyhow::anyhow!("event loop run: {e}")) +} diff --git a/crates/weft-app-shell/src/keyutils.rs b/crates/weft-app-shell/src/keyutils.rs new file mode 100644 index 0000000..c4fd87b --- /dev/null +++ b/crates/weft-app-shell/src/keyutils.rs @@ -0,0 +1,204 @@ +#![cfg(feature = "servo-embed")] + +use servo::{Code, Key, KeyState, KeyboardEvent, Location, Modifiers}; +use winit::event::ElementState; +use winit::keyboard::{Key as WinitKey, KeyLocation, ModifiersState, NamedKey, PhysicalKey}; + +pub fn keyboard_event_from_winit( + event: &winit::event::KeyEvent, + modifiers: ModifiersState, +) -> KeyboardEvent { + let state = match event.state { + ElementState::Pressed => KeyState::Down, + ElementState::Released => KeyState::Up, + }; + + let key = match &event.logical_key { + WinitKey::Named(n) => Key::Named(named_key(*n)), + WinitKey::Character(c) => Key::Character(c.to_string().into()), + WinitKey::Unidentified(_) => Key::Unidentified, + WinitKey::Dead(c) => Key::Dead(*c), + }; + + let code = match event.physical_key { + PhysicalKey::Code(c) => winit_code_to_code(c), + PhysicalKey::Unidentified(_) => Code::Unidentified, + }; + + let location = match event.location { + KeyLocation::Standard => Location::Standard, + KeyLocation::Left => Location::Left, + KeyLocation::Right => Location::Right, + KeyLocation::Numpad => Location::Numpad, + }; + + let mut mods = Modifiers::empty(); + if modifiers.shift_key() { + mods |= Modifiers::SHIFT; + } + if modifiers.control_key() { + mods |= Modifiers::CONTROL; + } + if modifiers.alt_key() { + mods |= Modifiers::ALT; + } + if modifiers.super_key() { + mods |= Modifiers::META; + } + + KeyboardEvent { + state, + key, + code, + location, + modifiers: mods, + repeat: event.repeat, + is_composing: false, + } +} + +fn named_key(n: NamedKey) -> servo::NamedKey { + use servo::NamedKey as S; + match n { + NamedKey::Alt => S::Alt, + NamedKey::AltGraph => S::AltGraph, + NamedKey::CapsLock => S::CapsLock, + NamedKey::Control => S::Control, + NamedKey::Fn => S::Fn, + NamedKey::FnLock => S::FnLock, + NamedKey::Meta => S::Meta, + NamedKey::NumLock => S::NumLock, + NamedKey::ScrollLock => S::ScrollLock, + NamedKey::Shift => S::Shift, + NamedKey::Symbol => S::Symbol, + NamedKey::SymbolLock => S::SymbolLock, + NamedKey::Enter => S::Enter, + NamedKey::Tab => S::Tab, + NamedKey::Space => S::Space, + NamedKey::ArrowDown => S::ArrowDown, + NamedKey::ArrowLeft => S::ArrowLeft, + NamedKey::ArrowRight => S::ArrowRight, + NamedKey::ArrowUp => S::ArrowUp, + NamedKey::End => S::End, + NamedKey::Home => S::Home, + NamedKey::PageDown => S::PageDown, + NamedKey::PageUp => S::PageUp, + NamedKey::Backspace => S::Backspace, + NamedKey::Clear => S::Clear, + NamedKey::Copy => S::Copy, + NamedKey::CrSel => S::CrSel, + NamedKey::Cut => S::Cut, + NamedKey::Delete => S::Delete, + NamedKey::EraseEof => S::EraseEof, + NamedKey::ExSel => S::ExSel, + NamedKey::Insert => S::Insert, + NamedKey::Paste => S::Paste, + NamedKey::Redo => S::Redo, + NamedKey::Undo => S::Undo, + NamedKey::Escape => S::Escape, + NamedKey::F1 => S::F1, + NamedKey::F2 => S::F2, + NamedKey::F3 => S::F3, + NamedKey::F4 => S::F4, + NamedKey::F5 => S::F5, + NamedKey::F6 => S::F6, + NamedKey::F7 => S::F7, + NamedKey::F8 => S::F8, + NamedKey::F9 => S::F9, + NamedKey::F10 => S::F10, + NamedKey::F11 => S::F11, + NamedKey::F12 => S::F12, + _ => S::Unidentified, + } +} + +fn winit_code_to_code(c: winit::keyboard::KeyCode) -> Code { + use winit::keyboard::KeyCode as W; + match c { + W::Backquote => Code::Backquote, + W::Backslash => Code::Backslash, + W::BracketLeft => Code::BracketLeft, + W::BracketRight => Code::BracketRight, + W::Comma => Code::Comma, + W::Digit0 => Code::Digit0, + W::Digit1 => Code::Digit1, + W::Digit2 => Code::Digit2, + W::Digit3 => Code::Digit3, + W::Digit4 => Code::Digit4, + W::Digit5 => Code::Digit5, + W::Digit6 => Code::Digit6, + W::Digit7 => Code::Digit7, + W::Digit8 => Code::Digit8, + W::Digit9 => Code::Digit9, + W::Equal => Code::Equal, + W::KeyA => Code::KeyA, + W::KeyB => Code::KeyB, + W::KeyC => Code::KeyC, + W::KeyD => Code::KeyD, + W::KeyE => Code::KeyE, + W::KeyF => Code::KeyF, + W::KeyG => Code::KeyG, + W::KeyH => Code::KeyH, + W::KeyI => Code::KeyI, + W::KeyJ => Code::KeyJ, + W::KeyK => Code::KeyK, + W::KeyL => Code::KeyL, + W::KeyM => Code::KeyM, + W::KeyN => Code::KeyN, + W::KeyO => Code::KeyO, + W::KeyP => Code::KeyP, + W::KeyQ => Code::KeyQ, + W::KeyR => Code::KeyR, + W::KeyS => Code::KeyS, + W::KeyT => Code::KeyT, + W::KeyU => Code::KeyU, + W::KeyV => Code::KeyV, + W::KeyW => Code::KeyW, + W::KeyX => Code::KeyX, + W::KeyY => Code::KeyY, + W::KeyZ => Code::KeyZ, + W::Minus => Code::Minus, + W::Period => Code::Period, + W::Quote => Code::Quote, + W::Semicolon => Code::Semicolon, + W::Slash => Code::Slash, + W::Backspace => Code::Backspace, + W::CapsLock => Code::CapsLock, + W::Enter => Code::Enter, + W::Space => Code::Space, + W::Tab => Code::Tab, + W::Delete => Code::Delete, + W::End => Code::End, + W::Home => Code::Home, + W::Insert => Code::Insert, + W::PageDown => Code::PageDown, + W::PageUp => Code::PageUp, + W::ArrowDown => Code::ArrowDown, + W::ArrowLeft => Code::ArrowLeft, + W::ArrowRight => Code::ArrowRight, + W::ArrowUp => Code::ArrowUp, + W::NumLock => Code::NumLock, + W::Escape => Code::Escape, + W::F1 => Code::F1, + W::F2 => Code::F2, + W::F3 => Code::F3, + W::F4 => Code::F4, + W::F5 => Code::F5, + W::F6 => Code::F6, + W::F7 => Code::F7, + W::F8 => Code::F8, + W::F9 => Code::F9, + W::F10 => Code::F10, + W::F11 => Code::F11, + W::F12 => Code::F12, + W::ShiftLeft => Code::ShiftLeft, + W::ShiftRight => Code::ShiftRight, + W::ControlLeft => Code::ControlLeft, + W::ControlRight => Code::ControlRight, + W::AltLeft => Code::AltLeft, + W::AltRight => Code::AltRight, + W::SuperLeft => Code::MetaLeft, + W::SuperRight => Code::MetaRight, + _ => Code::Unidentified, + } +} diff --git a/crates/weft-app-shell/src/main.rs b/crates/weft-app-shell/src/main.rs new file mode 100644 index 0000000..354c30d --- /dev/null +++ b/crates/weft-app-shell/src/main.rs @@ -0,0 +1,80 @@ +mod protocols; +mod shell_client; + +#[cfg(feature = "servo-embed")] +mod embedder; +#[cfg(feature = "servo-embed")] +mod keyutils; + +use anyhow::Context; + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let mut args = std::env::args().skip(1); + let app_id = args + .next() + .context("usage: weft-app-shell ")?; + let session_id: u64 = args + .next() + .context("usage: weft-app-shell ")? + .parse() + .context("session_id must be a number")?; + + let ws_port = appd_ws_port(); + + let shell = match shell_client::ShellClient::connect_as_app(&app_id, session_id) { + Ok(c) => { + tracing::info!("app shell window registered with compositor"); + Some(c) + } + Err(e) => { + tracing::warn!(error = %e, "shell protocol unavailable; continuing without compositor registration"); + None + } + }; + + embed_app(&app_id, session_id, ws_port, shell) +} + +fn appd_ws_port() -> u16 { + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + let port_file = std::path::PathBuf::from(runtime_dir).join("weft/appd.wsport"); + if let Ok(s) = std::fs::read_to_string(port_file) { + if let Ok(n) = s.trim().parse() { + return n; + } + } + } + if let Ok(s) = std::env::var("WEFT_APPD_WS_PORT") { + if let Ok(n) = s.parse() { + return n; + } + } + 7410 +} + +fn embed_app( + app_id: &str, + session_id: u64, + ws_port: u16, + shell_client: Option, +) -> anyhow::Result<()> { + #[cfg(feature = "servo-embed")] + return embedder::run(app_id, session_id, ws_port, shell_client); + + #[cfg(not(feature = "servo-embed"))] + { + let _ = (app_id, session_id, ws_port, shell_client); + println!("READY"); + use std::io::Write; + let _ = std::io::stdout().flush(); + std::thread::park(); + Ok(()) + } +} diff --git a/crates/weft-app-shell/src/protocols/mod.rs b/crates/weft-app-shell/src/protocols/mod.rs new file mode 100644 index 0000000..31d9d8a --- /dev/null +++ b/crates/weft-app-shell/src/protocols/mod.rs @@ -0,0 +1,20 @@ +#[allow(dead_code, non_camel_case_types, unused_unsafe, unused_variables)] +#[allow(non_upper_case_globals, non_snake_case, unused_imports)] +#[allow(missing_docs, clippy::all)] +pub mod client { + use wayland_client; + use wayland_client::protocol::*; + + pub mod __interfaces { + use wayland_client::protocol::__interfaces::*; + wayland_scanner::generate_interfaces!("../../protocol/weft-shell-unstable-v1.xml"); + } + use self::__interfaces::*; + + wayland_scanner::generate_client_code!("../../protocol/weft-shell-unstable-v1.xml"); +} + +#[allow(unused_imports)] +pub use client::zweft_shell_manager_v1::ZweftShellManagerV1; +#[allow(unused_imports)] +pub use client::zweft_shell_window_v1::ZweftShellWindowV1; diff --git a/crates/weft-app-shell/src/shell_client.rs b/crates/weft-app-shell/src/shell_client.rs new file mode 100644 index 0000000..6114e30 --- /dev/null +++ b/crates/weft-app-shell/src/shell_client.rs @@ -0,0 +1,197 @@ +use anyhow::Context; +use wayland_client::{ + Connection, Dispatch, EventQueue, QueueHandle, + protocol::{wl_registry, wl_surface::WlSurface}, +}; + +use crate::protocols::{ + ZweftShellManagerV1, ZweftShellWindowV1, + client::{zweft_shell_manager_v1, zweft_shell_window_v1}, +}; + +pub struct ShellWindowState { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub state_flags: u32, + pub focused: bool, + pub closed: bool, +} + +struct AppData { + manager: Option, + window: Option, + window_state: ShellWindowState, +} + +impl AppData { + fn new() -> Self { + Self { + manager: None, + window: None, + window_state: ShellWindowState { + x: 0, + y: 0, + width: 0, + height: 0, + state_flags: 0, + focused: false, + closed: false, + }, + } + } +} + +impl Dispatch for AppData { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + && interface == "zweft_shell_manager_v1" + { + let mgr = registry.bind::(name, version.min(1), qh, ()); + state.manager = Some(mgr); + } + } +} + +impl Dispatch for AppData { + fn event( + _: &mut Self, + _: &ZweftShellManagerV1, + _event: zweft_shell_manager_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for AppData { + fn event( + state: &mut Self, + window: &ZweftShellWindowV1, + event: zweft_shell_window_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zweft_shell_window_v1::Event::Configure { + x, + y, + width, + height, + state: flags, + } => { + let ws = &mut state.window_state; + ws.x = x; + ws.y = y; + ws.width = width; + ws.height = height; + ws.state_flags = flags; + tracing::debug!(x, y, width, height, flags, "app shell window configure"); + } + zweft_shell_window_v1::Event::FocusChanged { focused } => { + state.window_state.focused = focused != 0; + tracing::debug!(focused, "app shell window focus changed"); + } + zweft_shell_window_v1::Event::WindowClosed => { + tracing::info!("app shell window closed by compositor"); + state.window_state.closed = true; + window.destroy(); + } + zweft_shell_window_v1::Event::PresentationFeedback { + tv_sec, + tv_nsec, + refresh, + } => { + tracing::trace!(tv_sec, tv_nsec, refresh, "app shell presentation feedback"); + } + } + } +} + +#[allow(dead_code)] +pub struct ShellClient { + event_queue: EventQueue, + data: AppData, +} + +impl ShellClient { + pub fn connect_as_app(app_id: &str, session_id: u64) -> anyhow::Result { + let conn = + Connection::connect_to_env().context("failed to connect to Wayland compositor")?; + + let mut event_queue = conn.new_event_queue::(); + let qh = event_queue.handle(); + + conn.display().get_registry(&qh, ()); + + let mut data = AppData::new(); + event_queue + .roundtrip(&mut data) + .context("Wayland globals roundtrip")?; + + anyhow::ensure!( + data.manager.is_some(), + "zweft_shell_manager_v1 not advertised; WEFT compositor must be running" + ); + + let manager = data.manager.as_ref().unwrap(); + let title = format!("{app_id}/{session_id}"); + let window = manager.create_window( + app_id.to_string(), + title, + "application".to_string(), + None::<&WlSurface>, + 0, + 0, + 0, + 0, + &qh, + (), + ); + data.window = Some(window); + + event_queue + .roundtrip(&mut data) + .context("Wayland create_window roundtrip")?; + + tracing::info!( + app_id, + session_id, + x = data.window_state.x, + y = data.window_state.y, + width = data.window_state.width, + height = data.window_state.height, + "app shell window registered with compositor" + ); + + Ok(Self { event_queue, data }) + } + + #[allow(dead_code)] + pub fn dispatch_pending(&mut self) -> anyhow::Result { + self.event_queue + .dispatch_pending(&mut self.data) + .context("Wayland dispatch")?; + self.event_queue.flush().context("Wayland flush")?; + Ok(!self.data.window_state.closed) + } + + #[allow(dead_code)] + pub fn window_state(&self) -> &ShellWindowState { + &self.data.window_state + } +} diff --git a/crates/weft-appd/src/runtime.rs b/crates/weft-appd/src/runtime.rs index 0f60bd8..2860e02 100644 --- a/crates/weft-appd/src/runtime.rs +++ b/crates/weft-appd/src/runtime.rs @@ -95,6 +95,36 @@ async fn kill_portal(portal: Option<(PathBuf, tokio::process::Child)>) { } } +async fn spawn_app_shell( + session_id: u64, + app_id: &str, +) -> Option { + let bin = std::env::var("WEFT_APP_SHELL_BIN").ok()?; + let mut cmd = tokio::process::Command::new(&bin); + cmd.arg(app_id) + .arg(session_id.to_string()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + match cmd.spawn() { + Ok(child) => { + tracing::info!(session_id, %app_id, bin = %bin, "app shell spawned"); + Some(child) + } + Err(e) => { + tracing::warn!(session_id, %app_id, error = %e, "failed to spawn app shell"); + None + } + } +} + +async fn kill_app_shell(child: Option) { + if let Some(mut c) = child { + let _ = c.kill().await; + let _ = c.wait().await; + } +} + fn portal_socket_path(session_id: u64) -> Option { let runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok()?; let dir = PathBuf::from(runtime_dir).join("weft"); @@ -226,6 +256,7 @@ pub(crate) async fn supervise( _ = &mut abort_rx => None, }; + let mut app_shell: Option = None; match ready_result { Some(Ok(Ok(remaining_stdout))) => { registry @@ -238,6 +269,7 @@ pub(crate) async fn supervise( }); tracing::info!(session_id, %app_id, "app ready"); tokio::spawn(drain_stdout(remaining_stdout, session_id)); + app_shell = spawn_app_shell(session_id, app_id).await; } Some(Ok(Err(e))) => { tracing::warn!(session_id, %app_id, error = %e, "stdout read error before READY"); @@ -281,6 +313,8 @@ pub(crate) async fn supervise( } } + kill_app_shell(app_shell).await; + if let Some(tx) = &compositor_tx { let _ = tx .send(AppdToCompositor::AppSurfaceDestroyed { session_id }) diff --git a/crates/weft-servo-shell/src/appd_ws.rs b/crates/weft-servo-shell/src/appd_ws.rs deleted file mode 100644 index 091b535..0000000 --- a/crates/weft-servo-shell/src/appd_ws.rs +++ /dev/null @@ -1,102 +0,0 @@ -#![cfg(feature = "servo-embed")] - -use std::sync::mpsc; - -pub enum AppdCmd { - Launch { session_id: u64, app_id: String }, - Stop { session_id: u64 }, - SyncSessions { active: Vec<(u64, String)> }, -} - -pub fn spawn_appd_listener( - ws_port: u16, - tx: mpsc::SyncSender, - wake: Box, -) { - 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, wake: Box) { - let url = format!("ws://127.0.0.1:{ws_port}"); - let mut backoff = std::time::Duration::from_millis(500); - const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(16); - - loop { - match tungstenite::connect(&url) { - Err(e) => { - tracing::debug!("appd WebSocket connect failed: {e}; retry in {backoff:?}"); - std::thread::sleep(backoff); - backoff = (backoff * 2).min(MAX_BACKOFF); - continue; - } - Ok((mut ws, _)) => { - backoff = std::time::Duration::from_millis(500); - 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(e) => { - tracing::debug!("appd WebSocket read error: {e}; reconnecting"); - break; - } - } - } - } - } - std::thread::sleep(backoff); - backoff = (backoff * 2).min(MAX_BACKOFF); - } -} - -fn process_message(text: &str, tx: &mpsc::SyncSender, wake: &dyn Fn()) { - let Ok(v) = serde_json::from_str::(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; - }; - let active: Vec<(u64, String)> = sessions - .iter() - .filter_map(|s| { - let sid = s["session_id"].as_u64()?; - let aid = s["app_id"].as_str()?.to_string(); - Some((sid, aid)) - }) - .collect(); - if tx.try_send(AppdCmd::SyncSessions { active }).is_ok() { - 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(); - } - } - _ => {} - } -} diff --git a/crates/weft-servo-shell/src/embedder.rs b/crates/weft-servo-shell/src/embedder.rs index d613094..04db075 100644 --- a/crates/weft-servo-shell/src/embedder.rs +++ b/crates/weft-servo-shell/src/embedder.rs @@ -1,9 +1,7 @@ #![cfg(feature = "servo-embed")] -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::sync::mpsc; use std::sync::{Arc, Mutex}; use servo::{ @@ -91,9 +89,6 @@ struct App { servo: Option, webview: Option, rendering_context: Option, - app_webviews: HashMap, - active_session: Option, - app_rx: mpsc::Receiver, redraw_requested: Arc, waker: WeftEventLoopWaker, shutting_down: bool, @@ -107,7 +102,6 @@ impl App { url: ServoUrl, waker: WeftEventLoopWaker, ws_port: u16, - app_rx: mpsc::Receiver, shell_client: Option, ) -> Self { Self { @@ -117,9 +111,6 @@ impl App { servo: 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)), waker, shutting_down: false, @@ -129,43 +120,6 @@ 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) { - if self.app_webviews.contains_key(&session_id) { - return; - } - 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.as_dyn()) - .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 render_frame(window: &Arc, ctx: &RenderingCtx) { match ctx { RenderingCtx::Software(rc) => Self::blit_software(window, rc), @@ -261,32 +215,6 @@ impl ApplicationHandler for App { Ok(true) => {} } } - 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; - } - } - crate::appd_ws::AppdCmd::SyncSessions { active } => { - let active_ids: std::collections::HashSet = - active.iter().map(|(sid, _)| *sid).collect(); - self.app_webviews.retain(|sid, _| active_ids.contains(sid)); - if let Some(cur) = self.active_session { - if !active_ids.contains(&cur) { - self.active_session = None; - } - } - for (session_id, app_id) in active { - self.create_app_webview(session_id, &app_id); - } - } - } - } if let Some(servo) = &self.servo { servo.spin_event_loop(); } @@ -320,15 +248,12 @@ impl ApplicationHandler for App { if let Some(wv) = &self.webview { wv.resize(sz); } - for wv in self.app_webviews.values() { - wv.resize(sz); - } } WindowEvent::ModifiersChanged(mods) => { self.modifiers = mods.state(); } WindowEvent::KeyboardInput { event, .. } => { - if let Some(wv) = self.active_webview() { + if let Some(wv) = &self.webview { let ev = super::keyutils::keyboard_event_from_winit(&event, self.modifiers); let _ = wv.notify_input_event(InputEvent::Keyboard(ev)); } @@ -336,7 +261,7 @@ impl ApplicationHandler for App { WindowEvent::CursorMoved { position, .. } => { let pt = servo::euclid::default::Point2D::new(position.x as f32, position.y as f32); self.cursor_pos = pt; - if let Some(wv) = self.active_webview() { + if let Some(wv) = &self.webview { let _ = wv.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(pt))); } } @@ -351,7 +276,7 @@ impl ApplicationHandler for App { ElementState::Pressed => MouseButtonAction::Click, ElementState::Released => MouseButtonAction::Up, }; - if let Some(wv) = self.active_webview() { + if let Some(wv) = &self.webview { let _ = wv.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( action, btn, @@ -451,11 +376,7 @@ pub fn run( proxy: Arc::new(Mutex::new(event_loop.create_proxy())), }; - let (app_tx, app_rx) = mpsc::sync_channel::(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, shell_client); + let mut app = App::new(url, waker, ws_port, shell_client); event_loop .run_app(&mut app) .map_err(|e| anyhow::anyhow!("event loop run: {e}")) diff --git a/crates/weft-servo-shell/src/main.rs b/crates/weft-servo-shell/src/main.rs index b04fd74..742f7a4 100644 --- a/crates/weft-servo-shell/src/main.rs +++ b/crates/weft-servo-shell/src/main.rs @@ -2,8 +2,6 @@ use std::path::PathBuf; use anyhow::Context; -#[cfg(feature = "servo-embed")] -mod appd_ws; #[cfg(feature = "servo-embed")] mod embedder; #[cfg(feature = "servo-embed")]