diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b60dd..2da47ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,18 @@ jobs: - name: cargo fmt run: cargo fmt --all --check - name: cargo clippy (cross-platform crates) - run: cargo clippy --workspace --exclude weft-compositor --all-targets -- -D warnings + run: > + cargo clippy --workspace + --exclude weft-compositor + --exclude weft-servo-shell + --exclude weft-app-shell + --all-targets -- -D warnings - name: cargo test (cross-platform crates) - run: cargo test --workspace --exclude weft-compositor + run: > + cargo test --workspace + --exclude weft-compositor + --exclude weft-servo-shell + --exclude weft-app-shell # Wayland compositor and other Linux-only system crates. # These require libwayland-server and other Linux kernel interfaces. @@ -52,8 +61,18 @@ jobs: libinput-dev \ libseat-dev \ libudev-dev \ + libsystemd-dev \ pkg-config - - name: cargo clippy (weft-compositor) - run: cargo clippy -p weft-compositor --all-targets -- -D warnings - - name: cargo test (weft-compositor) - run: cargo test -p weft-compositor + - name: cargo clippy (linux-only crates) + run: > + cargo clippy + -p weft-compositor + -p weft-servo-shell + -p weft-app-shell + --all-targets -- -D warnings + - name: cargo test (linux-only crates) + run: > + cargo test + -p weft-compositor + -p weft-servo-shell + -p weft-app-shell diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..19834a0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python-envs.pythonProjects": [ + { + "path": "docu_dev/old", + "envManager": "ms-python.python:venv", + "packageManager": "ms-python.python:pip" + } + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index dfee955..0bdfb76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3973,6 +3973,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weft-app-shell" +version = "0.1.0" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "serde", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + [[package]] name = "weft-appd" version = "0.1.0" diff --git a/crates/weft-app-shell/Cargo.toml b/crates/weft-app-shell/Cargo.toml index d58d50e..8f59b7a 100644 --- a/crates/weft-app-shell/Cargo.toml +++ b/crates/weft-app-shell/Cargo.toml @@ -13,7 +13,7 @@ path = "src/main.rs" # 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 = [] +servo-embed = ["dep:serde", "dep:toml"] [dependencies] anyhow = "1.0" @@ -23,3 +23,5 @@ wayland-client = "0.31" wayland-backend = "0.3" wayland-scanner = "0.31" bitflags = "2" +serde = { version = "1", features = ["derive"], optional = true } +toml = { version = "0.8", optional = true } diff --git a/crates/weft-app-shell/src/embedder.rs b/crates/weft-app-shell/src/embedder.rs index 20909cc..519c98b 100644 --- a/crates/weft-app-shell/src/embedder.rs +++ b/crates/weft-app-shell/src/embedder.rs @@ -169,6 +169,9 @@ impl ApplicationHandler for App { let rendering_context = build_rendering_ctx(event_loop, &window, size); let ucm = Rc::new(UserContentManager::new(&servo)); + if let Some(kit_js) = load_ui_kit_script() { + ucm.add_script(Rc::new(UserScript::new(kit_js, None))); + } 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, @@ -326,14 +329,71 @@ fn build_rendering_ctx( )) } -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() +fn ui_kit_path() -> std::path::PathBuf { + if let Ok(p) = std::env::var("WEFT_UI_KIT_JS") { + return std::path::PathBuf::from(p); + } + std::path::PathBuf::from("/usr/share/weft/system/weft-ui-kit.js") +} + +fn load_ui_kit_script() -> Option { + std::fs::read_to_string(ui_kit_path()).ok() +} + +fn resolve_weft_system_url(url: &ServoUrl) -> Option { + if url.scheme() != "weft-system" { return None; } + let host = url.host_str().unwrap_or(""); + let path = url.path().trim_start_matches('/'); + let system_root = std::env::var("WEFT_SYSTEM_RESOURCES") + .unwrap_or_else(|_| "/usr/share/weft/system".to_owned()); + let file = std::path::Path::new(&system_root).join(host).join(path); + ServoUrl::parse(&format!("file://{}", file.display())).ok() +} + +fn read_ui_entry(app_id: &str) -> Option { + #[derive(serde::Deserialize)] + struct Ui { + entry: String, + } + #[derive(serde::Deserialize)] + struct Manifest { + ui: Ui, + } + + let erofs_manifest = std::path::Path::new("/run/weft/apps") + .join(app_id) + .join("merged") + .join("wapp.toml"); + let toml_text = std::fs::read_to_string(&erofs_manifest) + .ok() + .or_else(|| { + app_store_roots() + .into_iter() + .find_map(|r| std::fs::read_to_string(r.join(app_id).join("wapp.toml")).ok()) + })?; + let m: Manifest = toml::from_str(&toml_text).ok()?; + Some(m.ui.entry) +} + +fn resolve_app_file_path(app_id: &str, rel: &str) -> Option { + let erofs_root = std::path::Path::new("/run/weft/apps") + .join(app_id) + .join("merged"); + if erofs_root.exists() { + let p = erofs_root.join(rel); + if p.exists() { + return Some(p); + } + } + app_store_roots() .into_iter() - .map(|r| r.join(app_id).join("ui").join(rel)) - .find(|p| p.exists())?; + .map(|r| r.join(app_id).join(rel)) + .find(|p| p.exists()) +} + +fn resolve_weft_app_url(app_id: &str) -> Option { + let entry = read_ui_entry(app_id).unwrap_or_else(|| "ui/index.html".to_owned()); + let file_path = resolve_app_file_path(app_id, &entry)?; let s = format!("file://{}", file_path.display()); ServoUrl::parse(&s).ok() } diff --git a/crates/weft-app-shell/src/shell_client.rs b/crates/weft-app-shell/src/shell_client.rs index 6114e30..1a1b239 100644 --- a/crates/weft-app-shell/src/shell_client.rs +++ b/crates/weft-app-shell/src/shell_client.rs @@ -59,7 +59,7 @@ impl Dispatch for AppData { } = event && interface == "zweft_shell_manager_v1" { - let mgr = registry.bind::(name, version.min(1), qh, ()); + let mgr = registry.bind::(name, version.min(2), qh, ()); state.manager = Some(mgr); } } @@ -118,11 +118,11 @@ impl Dispatch for AppData { } => { tracing::trace!(tv_sec, tv_nsec, refresh, "app shell presentation feedback"); } + zweft_shell_window_v1::Event::NavigationGesture { .. } => {} } } } -#[allow(dead_code)] pub struct ShellClient { event_queue: EventQueue, data: AppData, @@ -181,7 +181,6 @@ impl ShellClient { Ok(Self { event_queue, data }) } - #[allow(dead_code)] pub fn dispatch_pending(&mut self) -> anyhow::Result { self.event_queue .dispatch_pending(&mut self.data) @@ -190,7 +189,6 @@ impl ShellClient { 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/Cargo.toml b/crates/weft-appd/Cargo.toml index eb784b6..fe6e021 100644 --- a/crates/weft-appd/Cargo.toml +++ b/crates/weft-appd/Cargo.toml @@ -21,3 +21,4 @@ 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"] } +libc = "0.2" diff --git a/crates/weft-appd/src/ipc.rs b/crates/weft-appd/src/ipc.rs index 50f8852..ad03580 100644 --- a/crates/weft-appd/src/ipc.rs +++ b/crates/weft-appd/src/ipc.rs @@ -9,6 +9,13 @@ pub enum Request { QueryRunning, QueryAppState { session_id: u64 }, QueryInstalledApps, + IpcForward { session_id: u64, payload: String }, + PanelGesture { + gesture_type: u32, + fingers: u32, + dx: f64, + dy: f64, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -45,6 +52,16 @@ pub enum Response { InstalledApps { apps: Vec, }, + IpcMessage { + session_id: u64, + payload: String, + }, + NavigationGesture { + gesture_type: u32, + fingers: u32, + dx: f64, + dy: f64, + }, Error { code: u32, message: String, diff --git a/crates/weft-appd/src/main.rs b/crates/weft-appd/src/main.rs index 5309b1e..03e23ab 100644 --- a/crates/weft-appd/src/main.rs +++ b/crates/weft-appd/src/main.rs @@ -26,7 +26,7 @@ struct SessionRegistry { broadcast: tokio::sync::broadcast::Sender, abort_senders: std::collections::HashMap>, compositor_tx: Option, - ipc_socket: Option, + ipc_senders: std::collections::HashMap>, } impl Default for SessionRegistry { @@ -38,7 +38,7 @@ impl Default for SessionRegistry { broadcast, abort_senders: std::collections::HashMap::new(), compositor_tx: None, - ipc_socket: None, + ipc_senders: std::collections::HashMap::new(), } } } @@ -105,6 +105,25 @@ impl SessionRegistry { self.abort_senders.remove(&session_id); } + pub(crate) fn register_ipc_sender( + &mut self, + session_id: u64, + tx: tokio::sync::mpsc::Sender, + ) { + self.ipc_senders.insert(session_id, tx); + } + + pub(crate) fn ipc_sender_for( + &self, + session_id: u64, + ) -> Option> { + self.ipc_senders.get(&session_id).cloned() + } + + pub(crate) fn remove_ipc_sender(&mut self, session_id: u64) { + self.ipc_senders.remove(&session_id); + } + pub(crate) fn subscribe(&self) -> tokio::sync::broadcast::Receiver { self.broadcast.subscribe() } @@ -143,8 +162,6 @@ async fn run() -> anyhow::Result<()> { tracing::info!(path = %socket_path.display(), "IPC socket listening"); let registry: Registry = Arc::new(Mutex::new(SessionRegistry::default())); - registry.lock().await.ipc_socket = Some(socket_path.clone()); - if let Some(path) = compositor_client::socket_path() { let tx = compositor_client::spawn(path); registry.lock().await.compositor_tx = Some(tx); @@ -272,6 +289,17 @@ async fn handle_connection( stream: tokio::net::UnixStream, registry: Registry, ) -> anyhow::Result<()> { + #[cfg(unix)] + { + let cred = stream + .peer_cred() + .context("SO_PEERCRED")?; + let our_uid = unsafe { libc::getuid() }; + if cred.uid() != our_uid { + anyhow::bail!("peer UID {} != process UID {}; connection rejected", cred.uid(), our_uid); + } + } + let (reader, writer) = tokio::io::split(stream); let mut reader = tokio::io::BufReader::new(reader); let mut writer = tokio::io::BufWriter::new(writer); @@ -285,6 +313,13 @@ async fn handle_connection( Ok(()) } +pub(crate) fn session_ipc_socket_path(session_id: u64) -> Option { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok()?; + let dir = PathBuf::from(runtime_dir).join("weft"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir.join(format!("ipc-{session_id}.sock"))) +} + pub(crate) async fn dispatch(req: Request, registry: &Registry) -> Response { match req { Request::LaunchApp { @@ -295,7 +330,15 @@ pub(crate) async fn dispatch(req: Request, registry: &Registry) -> Response { tracing::info!(session_id, %app_id, "launched"); let abort_rx = registry.lock().await.register_abort(session_id); let compositor_tx = registry.lock().await.compositor_tx.clone(); - let ipc_socket = registry.lock().await.ipc_socket.clone(); + let ipc_socket = session_ipc_socket_path(session_id); + let broadcast = registry.lock().await.broadcast().clone(); + if let Some(ref sock_path) = ipc_socket { + if let Some(tx) = + runtime::spawn_ipc_relay(session_id, sock_path.clone(), broadcast).await + { + registry.lock().await.register_ipc_sender(session_id, tx); + } + } let reg = Arc::clone(registry); let aid = app_id.clone(); tokio::spawn(async move { @@ -339,6 +382,36 @@ pub(crate) async fn dispatch(req: Request, registry: &Registry) -> Response { let apps = scan_installed_apps(); Response::InstalledApps { apps } } + Request::IpcForward { + session_id, + payload, + } => { + if let Some(tx) = registry.lock().await.ipc_sender_for(session_id) { + if tx.send(payload).await.is_err() { + tracing::warn!(session_id, "IPC relay sender closed"); + registry.lock().await.remove_ipc_sender(session_id); + } + } + Response::AppState { + session_id, + state: ipc::AppStateKind::Running, + } + } + Request::PanelGesture { + gesture_type, + fingers, + dx, + dy, + } => { + let msg = Response::NavigationGesture { + gesture_type, + fingers, + dx, + dy, + }; + let _ = registry.lock().await.broadcast().send(msg.clone()); + msg + } } } diff --git a/crates/weft-appd/src/runtime.rs b/crates/weft-appd/src/runtime.rs index 2860e02..0433fef 100644 --- a/crates/weft-appd/src/runtime.rs +++ b/crates/weft-appd/src/runtime.rs @@ -1,13 +1,63 @@ use std::path::PathBuf; use std::time::Duration; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use weft_ipc_types::AppdToCompositor; use crate::Registry; use crate::compositor_client::CompositorSender; use crate::ipc::{AppStateKind, Response}; +pub(crate) async fn spawn_ipc_relay( + session_id: u64, + socket_path: PathBuf, + broadcast: tokio::sync::broadcast::Sender, +) -> Option> { + let _ = std::fs::remove_file(&socket_path); + let listener = tokio::net::UnixListener::bind(&socket_path).ok()?; + let (html_to_wasm_tx, mut html_to_wasm_rx) = tokio::sync::mpsc::channel::(64); + tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + tracing::warn!(session_id, "IPC relay: failed to accept connection"); + let _ = std::fs::remove_file(&socket_path); + return; + }; + let (reader, writer) = tokio::io::split(stream); + let mut reader = BufReader::new(reader); + let mut writer = BufWriter::new(writer); + loop { + let mut line = String::new(); + tokio::select! { + n = reader.read_line(&mut line) => { + match n { + Ok(0) | Err(_) => break, + Ok(_) => { + let payload = line.trim_end().to_owned(); + let _ = broadcast.send(Response::IpcMessage { session_id, payload }); + } + } + } + msg = html_to_wasm_rx.recv() => { + match msg { + Some(payload) => { + let mut data = payload; + data.push('\n'); + if writer.write_all(data.as_bytes()).await.is_err() + || writer.flush().await.is_err() + { + break; + } + } + None => break, + } + } + } + } + let _ = std::fs::remove_file(&socket_path); + }); + Some(html_to_wasm_tx) +} + const READY_TIMEOUT: Duration = Duration::from_secs(30); fn systemd_cgroup_available() -> bool { diff --git a/crates/weft-compositor/src/backend/drm.rs b/crates/weft-compositor/src/backend/drm.rs index 567c125..25c772e 100644 --- a/crates/weft-compositor/src/backend/drm.rs +++ b/crates/weft-compositor/src/backend/drm.rs @@ -539,6 +539,9 @@ fn connector_connected( state.space.map_output(&output, (0, 0)); tracing::info!(?name, "output connected"); + let (pw, ph) = (wl_mode.size.w, wl_mode.size.h); + state.weft_shell_state.reconfigure_panels(0, 0, pw, ph); + state.weft_shell_state.retain_alive_panels(); render_output(state, node, crtc); } @@ -558,6 +561,15 @@ fn connector_disconnected( { state.space.unmap_output(&surface.output); } + let (pw, ph) = state + .space + .outputs() + .next() + .and_then(|o| state.space.output_geometry(o)) + .map(|g| (g.size.w, g.size.h)) + .unwrap_or((0, 0)); + state.weft_shell_state.reconfigure_panels(0, 0, pw, ph); + state.weft_shell_state.retain_alive_panels(); } #[cfg(target_os = "linux")] diff --git a/crates/weft-compositor/src/backend/winit.rs b/crates/weft-compositor/src/backend/winit.rs index c515c18..ca26693 100644 --- a/crates/weft-compositor/src/backend/winit.rs +++ b/crates/weft-compositor/src/backend/winit.rs @@ -117,6 +117,13 @@ pub fn run() -> anyhow::Result<()> { output.set_preferred(new_mode); state.space.map_output(&output, (0, 0)); damage_tracker = OutputDamageTracker::from_output(&output); + state.weft_shell_state.reconfigure_panels( + 0, + 0, + size.w, + size.h, + ); + state.weft_shell_state.retain_alive_panels(); } WinitEvent::Input(input_event) => { input::process_input_event(state, input_event); diff --git a/crates/weft-compositor/src/input.rs b/crates/weft-compositor/src/input.rs index c884a09..1939cf8 100644 --- a/crates/weft-compositor/src/input.rs +++ b/crates/weft-compositor/src/input.rs @@ -273,36 +273,49 @@ fn handle_touch_cancel( } } +const NAVIGATION_SWIPE_FINGERS: u32 = 3; +const NAVIGATION_SWIPE_THRESHOLD: f64 = 100.0; + fn handle_gesture_swipe_begin( state: &mut WeftCompositorState, event: B::GestureSwipeBeginEvent, ) { let serial = SERIAL_COUNTER.next_serial(); + let fingers = event.fingers(); if let Some(pointer) = state.seat.get_pointer() { pointer.gesture_swipe_begin( state, &smithay::input::pointer::GestureSwipeBeginEvent { serial, time: event.time_msec(), - fingers: event.fingers(), + fingers, }, ); } + state.gesture_state.in_progress = true; + state.gesture_state.fingers = fingers; + state.gesture_state.dx = 0.0; + state.gesture_state.dy = 0.0; } fn handle_gesture_swipe_update( state: &mut WeftCompositorState, event: B::GestureSwipeUpdateEvent, ) { + let delta = event.delta(); if let Some(pointer) = state.seat.get_pointer() { pointer.gesture_swipe_update( state, &smithay::input::pointer::GestureSwipeUpdateEvent { time: event.time_msec(), - delta: event.delta(), + delta, }, ); } + if state.gesture_state.in_progress { + state.gesture_state.dx += delta.x; + state.gesture_state.dy += delta.y; + } } fn handle_gesture_swipe_end( @@ -310,16 +323,28 @@ fn handle_gesture_swipe_end( event: B::GestureSwipeEndEvent, ) { let serial = SERIAL_COUNTER.next_serial(); + let cancelled = event.cancelled(); if let Some(pointer) = state.seat.get_pointer() { pointer.gesture_swipe_end( state, &smithay::input::pointer::GestureSwipeEndEvent { serial, time: event.time_msec(), - cancelled: event.cancelled(), + cancelled, }, ); } + let gs = std::mem::take(&mut state.gesture_state); + if !cancelled + && gs.in_progress + && gs.fingers >= NAVIGATION_SWIPE_FINGERS + && (gs.dx.abs() >= NAVIGATION_SWIPE_THRESHOLD + || gs.dy.abs() >= NAVIGATION_SWIPE_THRESHOLD) + { + state + .weft_shell_state + .send_navigation_gesture_to_panels(0, gs.fingers, gs.dx, gs.dy); + } } fn handle_gesture_pinch_begin( diff --git a/crates/weft-compositor/src/protocols/mod.rs b/crates/weft-compositor/src/protocols/mod.rs index a5d9ea1..b1a3bad 100644 --- a/crates/weft-compositor/src/protocols/mod.rs +++ b/crates/weft-compositor/src/protocols/mod.rs @@ -17,13 +17,13 @@ pub mod server { pub use server::zweft_shell_manager_v1::ZweftShellManagerV1; pub use server::zweft_shell_window_v1::ZweftShellWindowV1; -use wayland_server::{DisplayHandle, GlobalDispatch, backend::GlobalId}; +use wayland_server::{DisplayHandle, GlobalDispatch, Resource, backend::GlobalId}; pub struct WeftShellState { _global: GlobalId, + panels: Vec, } -#[allow(dead_code)] pub struct WeftShellWindowData { pub app_id: String, pub title: String, @@ -38,15 +38,51 @@ impl WeftShellState { D: GlobalDispatch, D: 'static, { - let global = display.create_global::(1, ()); - Self { _global: global } + let global = display.create_global::(2, ()); + Self { + _global: global, + panels: Vec::new(), + } + } + + pub fn add_panel(&mut self, window: ZweftShellWindowV1) { + self.panels.push(window); + } + + pub fn reconfigure_panels(&self, x: i32, y: i32, width: i32, height: i32) { + for panel in &self.panels { + if panel.is_alive() { + panel.configure(x, y, width, height, 0); + } + } + } + + pub fn send_navigation_gesture_to_panels( + &self, + gesture_type: u32, + fingers: u32, + dx: f64, + dy: f64, + ) { + for panel in &self.panels { + if panel.is_alive() && panel.version() >= 2 { + panel.navigation_gesture(gesture_type, fingers, dx, dy); + } + } + } + + pub fn retain_alive_panels(&mut self) { + self.panels.retain(|p| p.is_alive()); + } + + pub fn panels(&self) -> impl Iterator { + self.panels.iter() } } #[cfg(test)] mod tests { use std::sync::atomic::Ordering; - use wayland_server::Resource; use super::*; @@ -83,14 +119,14 @@ mod tests { fn manager_interface_name_and_version() { let iface = ZweftShellManagerV1::interface(); assert_eq!(iface.name, "zweft_shell_manager_v1"); - assert_eq!(iface.version, 1); + assert_eq!(iface.version, 2); } #[test] fn window_interface_name_and_version() { let iface = ZweftShellWindowV1::interface(); assert_eq!(iface.name, "zweft_shell_window_v1"); - assert_eq!(iface.version, 1); + assert_eq!(iface.version, 2); } #[test] diff --git a/crates/weft-compositor/src/state.rs b/crates/weft-compositor/src/state.rs index 33ab910..34927f6 100644 --- a/crates/weft-compositor/src/state.rs +++ b/crates/weft-compositor/src/state.rs @@ -60,13 +60,21 @@ impl ClientData for WeftClientState { fn disconnected(&self, _client_id: ClientId, _reason: DisconnectReason) {} } -#[allow(dead_code)] +/// Accumulated state for a multi-touch swipe gesture in progress. +#[derive(Default)] +pub struct GestureState { + pub in_progress: bool, + pub fingers: u32, + pub dx: f64, + pub dy: f64, +} + pub struct WeftCompositorState { pub display_handle: DisplayHandle, pub loop_signal: LoopSignal, pub loop_handle: LoopHandle<'static, WeftCompositorState>, + pub gesture_state: GestureState, - // Wayland protocol globals pub compositor_state: CompositorState, pub xdg_shell_state: XdgShellState, pub layer_shell_state: WlrLayerShellState, @@ -79,26 +87,21 @@ pub struct WeftCompositorState { pub pointer_constraints_state: PointerConstraintsState, pub cursor_shape_state: CursorShapeManagerState, - // Desktop abstraction layer pub space: Space, pub popups: PopupManager, - // Seat and input state pub seat_state: SeatState, pub seat: Seat, pub pointer_location: Point, pub cursor_image_status: CursorImageStatus, - // Set by the backend after renderer initialisation when DMA-BUF is supported. + #[allow(dead_code)] pub dmabuf_global: Option, - // Set to false when the compositor should exit the event loop. pub running: bool, - // WEFT compositor–shell protocol global. pub weft_shell_state: WeftShellState, - // IPC channel with weft-appd (compositor is the server, appd is the client). #[cfg(unix)] pub appd_ipc: Option, @@ -159,6 +162,7 @@ impl WeftCompositorState { cursor_image_status: CursorImageStatus::Hidden, dmabuf_global: None, running: true, + gesture_state: GestureState::default(), #[cfg(unix)] appd_ipc: None, #[cfg(target_os = "linux")] @@ -230,10 +234,8 @@ impl XdgShellHandler for WeftCompositorState { } fn new_toplevel(&mut self, surface: ToplevelSurface) { - // Send initial configure before wrapping — the toplevel needs a configure to map. surface.send_configure(); let window = Window::new_wayland_window(surface); - // Map at origin; proper placement policy comes with the shell protocol wave. self.space.map_element(window, (0, 0), false); } @@ -307,7 +309,21 @@ impl SeatHandler for WeftCompositorState { &mut self.seat_state } - fn focus_changed(&mut self, _seat: &Seat, _focused: Option<&WlSurface>) {} + fn focus_changed(&mut self, _seat: &Seat, focused: Option<&WlSurface>) { + let focused_id = focused.map(|s| s.id()); + for panel in self.weft_shell_state.panels() { + if !panel.is_alive() { + continue; + } + let data = panel.data::(); + let is_focused = data + .and_then(|d| d.surface.as_ref()) + .map(|s| Some(s.id()) == focused_id) + .unwrap_or(false); + panel.focus_changed(if is_focused { 1 } else { 0 }); + } + self.weft_shell_state.retain_alive_panels(); + } fn cursor_image(&mut self, _seat: &Seat, image: CursorImageStatus) { self.cursor_image_status = image; @@ -439,7 +455,7 @@ impl GlobalDispatch for WeftCompositorState { impl Dispatch for WeftCompositorState { fn request( - _state: &mut Self, + state: &mut Self, _client: &Client, _resource: &ZweftShellManagerV1, request: zweft_shell_manager_v1::Request, @@ -460,6 +476,7 @@ impl Dispatch for WeftCompositorState { width, height, } => { + let is_panel = role == "panel"; let window = data_init.init( id, WeftShellWindowData { @@ -470,7 +487,25 @@ impl Dispatch for WeftCompositorState { closed: std::sync::atomic::AtomicBool::new(false), }, ); - window.configure(x, y, width, height, 0); + if is_panel { + let (ox, oy, ow, oh) = state + .space + .outputs() + .next() + .and_then(|o| state.space.output_geometry(o)) + .map(|g| (g.loc.x, g.loc.y, g.size.w, g.size.h)) + .unwrap_or((x, y, width, height)); + window.configure( + ox, + oy, + ow, + oh, + crate::protocols::server::zweft_shell_window_v1::State::Maximized as u32, + ); + state.weft_shell_state.add_panel(window); + } else { + window.configure(x, y, width, height, 0); + } } } } diff --git a/crates/weft-file-portal/src/main.rs b/crates/weft-file-portal/src/main.rs index 3475464..e346511 100644 --- a/crates/weft-file-portal/src/main.rs +++ b/crates/weft-file-portal/src/main.rs @@ -1,6 +1,7 @@ use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use anyhow::Context; use serde::{Deserialize, Serialize}; @@ -38,7 +39,7 @@ fn main() -> anyhow::Result<()> { } let socket_path = &args[1]; - let allowed = parse_allowed(&args[2..]); + let allowed = Arc::new(parse_allowed(&args[2..])); if Path::new(socket_path).exists() { std::fs::remove_file(socket_path) @@ -50,7 +51,10 @@ fn main() -> anyhow::Result<()> { for stream in listener.incoming() { match stream { - Ok(s) => handle_connection(s, &allowed), + Ok(s) => { + let allowed = Arc::clone(&allowed); + std::thread::spawn(move || handle_connection(s, &allowed)); + } Err(e) => eprintln!("accept error: {e}"), } } diff --git a/crates/weft-pack/src/main.rs b/crates/weft-pack/src/main.rs index 9e55169..73434b3 100644 --- a/crates/weft-pack/src/main.rs +++ b/crates/weft-pack/src/main.rs @@ -189,14 +189,8 @@ fn check_package(dir: &Path) -> anyhow::Result { errors.push(format!("ui.entry '{}' not found", ui_path.display())); } - const KNOWN_CAPS: &[&str] = &[ - "fs:rw:app-data", - "fs:read:app-data", - "fs:rw:xdg-documents", - "fs:read:xdg-documents", - ]; for cap in m.package.capabilities.iter().flatten() { - if !KNOWN_CAPS.contains(&cap.as_str()) { + if !is_known_capability(cap) { errors.push(format!("unknown capability '{cap}'")); } } @@ -235,6 +229,28 @@ fn print_info(m: &Manifest) { } } +fn is_known_capability(cap: &str) -> bool { + const EXACT: &[&str] = &[ + "fs:rw:app-data", + "fs:read:app-data", + "fs:rw:xdg-documents", + "fs:read:xdg-documents", + "net:fetch:*", + "hw:gpu:compute", + "hw:gpu:render", + "sys:notifications", + "sys:clipboard:read", + "sys:clipboard:write", + ]; + if EXACT.contains(&cap) { + return true; + } + if let Some(domain) = cap.strip_prefix("net:fetch:") { + return !domain.is_empty() && domain != "*"; + } + false +} + fn is_valid_app_id(id: &str) -> bool { let parts: Vec<&str> = id.split('.').collect(); if parts.len() < 3 { diff --git a/crates/weft-runtime/Cargo.toml b/crates/weft-runtime/Cargo.toml index c5bd8b1..532a7e0 100644 --- a/crates/weft-runtime/Cargo.toml +++ b/crates/weft-runtime/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" default = [] wasmtime-runtime = ["dep:wasmtime", "dep:wasmtime-wasi", "dep:cap-std"] seccomp = ["dep:seccompiler", "dep:libc"] +net-fetch = ["dep:ureq"] [dependencies] anyhow = "1.0" @@ -22,3 +23,4 @@ wasmtime-wasi = { version = "30", optional = true } cap-std = { version = "3", optional = true } seccompiler = { version = "0.4", optional = true } libc = { version = "0.2", optional = true } +ureq = { version = "2", optional = true } diff --git a/crates/weft-runtime/src/main.rs b/crates/weft-runtime/src/main.rs index 0e5b92c..201f8c3 100644 --- a/crates/weft-runtime/src/main.rs +++ b/crates/weft-runtime/src/main.rs @@ -98,6 +98,7 @@ fn run_module( ipc_socket: Option<&str>, ) -> anyhow::Result<()> { use cap_std::{ambient_authority, fs::Dir}; + use std::sync::{Arc, Mutex}; use wasmtime::{ Config, Engine, Store, component::{Component, Linker}, @@ -107,9 +108,64 @@ fn run_module( bindings::sync::Command, }; + struct IpcState { + socket: std::os::unix::net::UnixStream, + recv_buf: Vec, + } + + impl IpcState { + fn connect(path: &str) -> Option { + let socket = std::os::unix::net::UnixStream::connect(path).ok()?; + socket.set_nonblocking(true).ok()?; + Some(Self { + socket, + recv_buf: Vec::new(), + }) + } + + fn send(&mut self, payload: &str) -> Result<(), String> { + use std::io::Write; + let _ = self.socket.set_nonblocking(false); + let mut line = payload.to_owned(); + line.push('\n'); + let result = self + .socket + .write_all(line.as_bytes()) + .map_err(|e| e.to_string()); + let _ = self.socket.set_nonblocking(true); + result + } + + fn recv(&mut self) -> Option { + use std::io::Read; + let mut chunk = [0u8; 4096]; + loop { + match self.socket.read(&mut chunk) { + Ok(0) => break, + Ok(n) => self.recv_buf.extend_from_slice(&chunk[..n]), + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(_) => break, + } + } + if let Some(pos) = self.recv_buf.iter().position(|&b| b == b'\n') { + let raw: Vec = self.recv_buf.drain(..=pos).collect(); + return String::from_utf8(raw) + .ok() + .map(|s| s.trim_end_matches('\n').trim_end_matches('\r').to_owned()); + } + None + } + } + struct State { ctx: WasiCtx, table: ResourceTable, + ipc: Arc>>, } impl WasiView for State { @@ -130,20 +186,97 @@ fn run_module( let mut linker: Linker = Linker::new(&engine); add_to_linker_sync(&mut linker).context("add WASI to linker")?; + let ipc_state: Arc>> = Arc::new(Mutex::new(None)); + + { + let ipc_send = Arc::clone(&ipc_state); + let ipc_recv = Arc::clone(&ipc_state); + linker + .instance("weft:app/notify@0.1.0") + .context("define weft:app/notify instance")? + .func_wrap("ready", |_: wasmtime::StoreContextMut<'_, State>, ()| { + println!("READY"); + Ok::<(), wasmtime::Error>(()) + }) + .context("define weft:app/notify#ready")?; + + let mut ipc_instance = linker + .instance("weft:app/ipc@0.1.0") + .context("define weft:app/ipc instance")?; + + ipc_instance + .func_wrap( + "send", + move |_: wasmtime::StoreContextMut<'_, State>, + (payload,): (String,)| + -> wasmtime::Result<(Result<(), String>,)> { + let mut guard = ipc_send.lock().unwrap(); + match guard.as_mut() { + Some(ipc) => Ok((ipc.send(&payload),)), + None => Ok((Err("IPC not connected".to_owned()),)), + } + }, + ) + .context("define weft:app/ipc#send")?; + + ipc_instance + .func_wrap( + "recv", + move |_: wasmtime::StoreContextMut<'_, State>, + ()| + -> wasmtime::Result<(Option,)> { + let mut guard = ipc_recv.lock().unwrap(); + Ok((guard.as_mut().and_then(|ipc| ipc.recv()),)) + }, + ) + .context("define weft:app/ipc#recv")?; + } + linker - .instance("weft:app/notify@0.1.0") - .context("define weft:app/notify instance")? - .func_wrap("ready", |_: wasmtime::StoreContextMut<'_, State>, ()| { - println!("READY"); - Ok::<(), wasmtime::Error>(()) - }) - .context("define weft:app/notify#ready")?; + .instance("weft:app/fetch@0.1.0") + .context("define weft:app/fetch instance")? + .func_wrap( + "fetch", + |_: wasmtime::StoreContextMut<'_, State>, + (url, method, headers, body): ( + String, + String, + Vec<(String, String)>, + Option>, + )| + -> wasmtime::Result<( + Result<(u16, String, Vec), String>, + )> { + let result = host_fetch(&url, &method, &headers, body.as_deref()); + Ok((result,)) + }, + ) + .context("define weft:app/fetch#fetch")?; + + linker + .instance("weft:app/notifications@0.1.0") + .context("define weft:app/notifications instance")? + .func_wrap( + "notify", + |_: wasmtime::StoreContextMut<'_, State>, + (title, body, icon): (String, String, Option)| + -> wasmtime::Result<(Result<(), String>,)> { + let result = host_notify(&title, &body, icon.as_deref()); + Ok((result,)) + }, + ) + .context("define weft:app/notifications#notify")?; let mut ctx_builder = WasiCtxBuilder::new(); ctx_builder.inherit_stdout().inherit_stderr(); if let Some(socket_path) = ipc_socket { ctx_builder.env("WEFT_IPC_SOCKET", socket_path); + if let Some(ipc) = IpcState::connect(socket_path) { + *ipc_state.lock().unwrap() = Some(ipc); + } else { + tracing::warn!("weft:app/ipc: could not connect to IPC socket {socket_path}"); + } } if let Ok(portal_socket) = std::env::var("WEFT_FILE_PORTAL_SOCKET") { @@ -162,6 +295,7 @@ fn run_module( State { ctx, table: ResourceTable::new(), + ipc: ipc_state, }, ); @@ -175,6 +309,60 @@ fn run_module( .map_err(|()| anyhow::anyhow!("wasm component run exited with error")) } +#[cfg(feature = "net-fetch")] +fn host_fetch( + url: &str, + method: &str, + headers: &[(String, String)], + body: Option<&[u8]>, +) -> Result<(u16, String, Vec), String> { + use std::io::Read; + let mut req = ureq::request(method, url); + for (name, value) in headers { + req = req.set(name, value); + } + let response = match body { + Some(b) => req.send_bytes(b), + None => req.call(), + } + .map_err(|e| e.to_string())?; + let status = response.status(); + let content_type = response.content_type().to_owned(); + let mut body_bytes = Vec::new(); + response + .into_reader() + .read_to_end(&mut body_bytes) + .map_err(|e| e.to_string())?; + Ok((status, content_type, body_bytes)) +} + +#[cfg(not(feature = "net-fetch"))] +fn host_fetch( + _url: &str, + _method: &str, + _headers: &[(String, String)], + _body: Option<&[u8]>, +) -> Result<(u16, String, Vec), String> { + Err("net-fetch capability not compiled in".to_owned()) +} + +fn host_notify(title: &str, body: &str, icon: Option<&str>) -> Result<(), String> { + let mut cmd = std::process::Command::new("notify-send"); + if let Some(i) = icon { + cmd.arg("--icon").arg(i); + } + cmd.arg("--").arg(title).arg(body); + cmd.status() + .map_err(|e| e.to_string()) + .and_then(|s| { + if s.success() { + Ok(()) + } else { + Err(format!("notify-send exited with {s}")) + } + }) +} + #[cfg(feature = "seccomp")] fn apply_seccomp_filter() -> anyhow::Result<()> { use seccompiler::{BpfProgram, SeccompAction, SeccompFilter, SeccompRule}; diff --git a/crates/weft-runtime/wit/weft-app.wit b/crates/weft-runtime/wit/weft-app.wit index 6b7c526..f861b70 100644 --- a/crates/weft-runtime/wit/weft-app.wit +++ b/crates/weft-runtime/wit/weft-app.wit @@ -8,3 +8,45 @@ interface notify { /// Running state. ready: func(); } + +/// Host interface for bidirectional IPC between the Wasm component and the +/// HTML front-end served by weft-app-shell. Messages are JSON strings. +interface ipc { + /// Send a payload to the HTML front-end. Returns an error string if the + /// channel is not connected or the write fails. + send: func(payload: string) -> result<_, string>; + + /// Return the next pending payload from the HTML front-end, or none if + /// no message is currently available. Non-blocking. + recv: func() -> option; +} + +/// Host interface for outbound HTTP requests. Requires the net:fetch +/// capability to be declared in wapp.toml. +interface fetch { + record response { + status: u16, + content-type: string, + body: list, + } + + /// Perform a synchronous HTTP request. method is GET, POST, etc. + /// headers is a list of (name, value) pairs. body is the request body. + fetch: func( + url: string, + method: string, + headers: list>, + body: option>, + ) -> result; +} + +/// Host interface for sending desktop notifications. Requires the +/// sys:notifications capability to be declared in wapp.toml. +interface notifications { + /// Send a desktop notification. icon is an optional XDG icon name. + notify: func( + title: string, + body: string, + icon: option, + ) -> result<_, string>; +} diff --git a/crates/weft-servo-shell/src/embedder.rs b/crates/weft-servo-shell/src/embedder.rs index 04db075..76fc718 100644 --- a/crates/weft-servo-shell/src/embedder.rs +++ b/crates/weft-servo-shell/src/embedder.rs @@ -173,6 +173,9 @@ impl ApplicationHandler for App { let rendering_context = build_rendering_ctx(event_loop, &window, size); let user_content_manager = Rc::new(UserContentManager::new(&servo)); + if let Some(kit_js) = load_ui_kit_script() { + user_content_manager.add_script(Rc::new(UserScript::new(kit_js, None))); + } let bridge_js = format!( r#"(function(){{var ws=new WebSocket('ws://127.0.0.1:{p}');var q=[];var r=false;ws.onopen=function(){{r=true;q.forEach(function(m){{ws.send(JSON.stringify(m))}});q.length=0}};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 @@ -214,6 +217,13 @@ impl ApplicationHandler for App { Err(e) => tracing::warn!("shell client dispatch error: {e}"), Ok(true) => {} } + let gestures = sc.take_pending_gestures(); + if !gestures.is_empty() { + let ws_port = self.ws_port; + std::thread::spawn(move || { + forward_gestures_to_appd(ws_port, &gestures); + }); + } } if let Some(servo) = &self.servo { servo.spin_event_loop(); @@ -340,6 +350,15 @@ fn resolve_weft_app_url(url: &ServoUrl) -> Option { ServoUrl::parse(&s).ok() } +fn load_ui_kit_script() -> Option { + let path = std::env::var("WEFT_UI_KIT_JS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + std::path::PathBuf::from("/usr/share/weft/system/weft-ui-kit.js") + }); + std::fs::read_to_string(path).ok() +} + fn app_store_roots() -> Vec { if let Ok(v) = std::env::var("WEFT_APP_STORE") { return vec![PathBuf::from(v)]; @@ -381,3 +400,37 @@ pub fn run( .run_app(&mut app) .map_err(|e| anyhow::anyhow!("event loop run: {e}")) } + +fn forward_gestures_to_appd( + ws_port: u16, + gestures: &[crate::shell_client::PendingGesture], +) { + use std::net::TcpStream; + let addr = format!("127.0.0.1:{ws_port}"); + let stream = match TcpStream::connect(&addr) { + Ok(s) => s, + Err(e) => { + tracing::warn!("gesture forward: connect to {addr} failed: {e}"); + return; + } + }; + let url = format!("ws://{addr}/"); + let (mut ws, _) = match tungstenite::client::client(url, stream) { + Ok(pair) => pair, + Err(e) => { + tracing::warn!("gesture forward: WebSocket handshake failed: {e}"); + return; + } + }; + for g in gestures { + let json = format!( + r#"{{"type":"PANEL_GESTURE","gesture_type":{},"fingers":{},"dx":{},"dy":{}}}"#, + g.gesture_type, g.fingers, g.dx, g.dy + ); + if let Err(e) = ws.send(tungstenite::Message::Text(json)) { + tracing::warn!("gesture forward: send failed: {e}"); + break; + } + } + let _ = ws.close(None); +} diff --git a/crates/weft-servo-shell/src/shell_client.rs b/crates/weft-servo-shell/src/shell_client.rs index cd0ea2e..a92443c 100644 --- a/crates/weft-servo-shell/src/shell_client.rs +++ b/crates/weft-servo-shell/src/shell_client.rs @@ -23,10 +23,18 @@ pub struct ShellWindowState { // ── Internal Wayland dispatch state ────────────────────────────────────────── +pub struct PendingGesture { + pub gesture_type: u32, + pub fingers: u32, + pub dx: f64, + pub dy: f64, +} + struct AppData { manager: Option, window: Option, window_state: ShellWindowState, + pending_gestures: Vec, } impl AppData { @@ -43,6 +51,7 @@ impl AppData { focused: false, closed: false, }, + pending_gestures: Vec::new(), } } } @@ -63,7 +72,7 @@ impl Dispatch for AppData { } = event && interface == "zweft_shell_manager_v1" { - let mgr = registry.bind::(name, version.min(1), qh, ()); + let mgr = registry.bind::(name, version.min(2), qh, ()); state.manager = Some(mgr); } } @@ -122,13 +131,26 @@ impl Dispatch for AppData { } => { tracing::trace!(tv_sec, tv_nsec, refresh, "shell presentation feedback"); } + zweft_shell_window_v1::Event::NavigationGesture { + r#type, + fingers, + dx, + dy, + } => { + tracing::debug!(r#type, fingers, dx, dy, "navigation gesture from compositor"); + state.pending_gestures.push(PendingGesture { + gesture_type: r#type, + fingers, + dx, + dy, + }); + } } } } // ── Public client ───────────────────────────────────────────────────────────── -#[allow(dead_code)] pub struct ShellClient { event_queue: EventQueue, data: AppData, @@ -185,7 +207,6 @@ impl ShellClient { Ok(Self { event_queue, data }) } - #[allow(dead_code)] pub fn dispatch_pending(&mut self) -> anyhow::Result { self.event_queue .dispatch_pending(&mut self.data) @@ -194,8 +215,11 @@ impl ShellClient { Ok(!self.data.window_state.closed) } - #[allow(dead_code)] pub fn window_state(&self) -> &ShellWindowState { &self.data.window_state } + + pub fn take_pending_gestures(&mut self) -> Vec { + std::mem::take(&mut self.data.pending_gestures) + } } diff --git a/infra/shell/system-ui.html b/infra/shell/system-ui.html index 8bb65fb..1241163 100644 --- a/infra/shell/system-ui.html +++ b/infra/shell/system-ui.html @@ -301,6 +301,15 @@ }); } else if (msg.type === 'LAUNCH_ACK') { ensureTaskbarEntry(msg.session_id, msg.app_id || null); + } else if (msg.type === 'NAVIGATION_GESTURE') { + document.dispatchEvent(new CustomEvent('weft:navigation-gesture', { + detail: { + gesture_type: msg.gesture_type, + fingers: msg.fingers, + dx: msg.dx, + dy: msg.dy, + }, + })); } else if (msg.type === 'ERROR') { console.warn('appd error', msg.code, msg.message); } diff --git a/infra/shell/weft-ui-kit.js b/infra/shell/weft-ui-kit.js new file mode 100644 index 0000000..5d87202 --- /dev/null +++ b/infra/shell/weft-ui-kit.js @@ -0,0 +1,332 @@ +(function () { + 'use strict'; + + var CSS = ` + :host { + box-sizing: border-box; + font-family: system-ui, -apple-system, sans-serif; + } + :host([hidden]) { display: none !important; } + `; + + function sheet(extra) { + var s = new CSSStyleSheet(); + s.replaceSync(CSS + (extra || '')); + return s; + } + + /* ── weft-button ─────────────────────────────────────────────── */ + class WeftButton extends HTMLElement { + static observedAttributes = ['variant', 'disabled']; + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: inline-block; } + button { + display: inline-flex; align-items: center; justify-content: center; + gap: 6px; padding: 8px 16px; border: none; border-radius: 8px; + font-size: 14px; font-weight: 500; cursor: pointer; + background: rgba(91,138,245,0.9); color: #fff; + transition: opacity 0.15s, background 0.15s; + width: 100%; + } + button:hover { background: rgba(91,138,245,1); } + button:active { opacity: 0.8; } + button:disabled { opacity: 0.4; cursor: not-allowed; } + :host([variant=secondary]) button { + background: rgba(255,255,255,0.1); + color: rgba(255,255,255,0.9); + } + :host([variant=destructive]) button { + background: rgba(220,50,50,0.85); + } + `)]; + this._btn = document.createElement('button'); + this._btn.appendChild(document.createElement('slot')); + root.appendChild(this._btn); + } + attributeChangedCallback(name, _old, val) { + if (name === 'disabled') this._btn.disabled = val !== null; + } + connectedCallback() { + this._btn.disabled = this.hasAttribute('disabled'); + } + } + + /* ── weft-card ───────────────────────────────────────────────── */ + class WeftCard extends HTMLElement { + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: block; } + .card { + background: rgba(255,255,255,0.07); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; padding: 16px; + } + `)]; + var d = document.createElement('div'); + d.className = 'card'; + d.appendChild(document.createElement('slot')); + root.appendChild(d); + } + } + + /* ── weft-dialog ─────────────────────────────────────────────── */ + class WeftDialog extends HTMLElement { + static observedAttributes = ['open']; + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: none; } + :host([open]) { display: flex; align-items: center; justify-content: center; + position: fixed; inset: 0; z-index: 9000; + background: rgba(0,0,0,0.55); } + .dialog { + background: #1a1d28; border: 1px solid rgba(255,255,255,0.15); + border-radius: 16px; padding: 24px; min-width: 320px; + max-width: 90vw; max-height: 80vh; overflow-y: auto; + box-shadow: 0 24px 64px rgba(0,0,0,0.6); + } + `)]; + var d = document.createElement('div'); + d.className = 'dialog'; + d.appendChild(document.createElement('slot')); + root.appendChild(d); + root.addEventListener('click', function (e) { + if (e.target === root.host) root.host.removeAttribute('open'); + }); + } + } + + /* ── weft-icon ───────────────────────────────────────────────── */ + class WeftIcon extends HTMLElement { + static observedAttributes = ['name', 'size']; + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: inline-flex; align-items: center; justify-content: center; } + svg { width: var(--icon-size, 20px); height: var(--icon-size, 20px); + fill: currentColor; } + `)]; + this._root = root; + this._render(); + } + attributeChangedCallback() { this._render(); } + _render() { + var size = this.getAttribute('size') || '20'; + this._root.host.style.setProperty('--icon-size', size + 'px'); + } + } + + /* ── weft-list / weft-list-item ─────────────────────────────── */ + class WeftList extends HTMLElement { + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: block; } + ul { list-style: none; margin: 0; padding: 0; } + `)]; + var ul = document.createElement('ul'); + ul.appendChild(document.createElement('slot')); + root.appendChild(ul); + } + } + + class WeftListItem extends HTMLElement { + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: block; } + li { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; border-radius: 8px; cursor: pointer; + color: rgba(255,255,255,0.88); + transition: background 0.12s; + } + li:hover { background: rgba(255,255,255,0.08); } + `)]; + var li = document.createElement('li'); + li.appendChild(document.createElement('slot')); + root.appendChild(li); + } + } + + /* ── weft-menu / weft-menu-item ─────────────────────────────── */ + class WeftMenu extends HTMLElement { + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: block; } + .menu { + background: rgba(20,22,32,0.95); backdrop-filter: blur(20px); + border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; + padding: 4px; min-width: 160px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + } + `)]; + var d = document.createElement('div'); + d.className = 'menu'; + d.appendChild(document.createElement('slot')); + root.appendChild(d); + } + } + + class WeftMenuItem extends HTMLElement { + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: block; } + .item { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border-radius: 6px; cursor: pointer; + font-size: 13px; color: rgba(255,255,255,0.88); + transition: background 0.1s; + } + .item:hover { background: rgba(91,138,245,0.25); } + :host([destructive]) .item { color: #f87171; } + `)]; + var d = document.createElement('div'); + d.className = 'item'; + d.appendChild(document.createElement('slot')); + root.appendChild(d); + } + } + + /* ── weft-progress ───────────────────────────────────────────── */ + class WeftProgress extends HTMLElement { + static observedAttributes = ['value', 'max', 'indeterminate']; + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: block; } + .track { + height: 6px; background: rgba(255,255,255,0.12); + border-radius: 3px; overflow: hidden; + } + .fill { + height: 100%; background: #5b8af5; border-radius: 3px; + transition: width 0.2s; + } + @keyframes indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } + } + :host([indeterminate]) .fill { + width: 25%; animation: indeterminate 1.4s linear infinite; + } + `)]; + this._track = document.createElement('div'); + this._track.className = 'track'; + this._fill = document.createElement('div'); + this._fill.className = 'fill'; + this._track.appendChild(this._fill); + root.appendChild(this._track); + this._update(); + } + attributeChangedCallback() { this._update(); } + _update() { + if (this.hasAttribute('indeterminate')) { + this._fill.style.width = '25%'; + } else { + var val = parseFloat(this.getAttribute('value') || '0'); + var max = parseFloat(this.getAttribute('max') || '100'); + this._fill.style.width = (Math.min(100, (val / max) * 100)) + '%'; + } + } + } + + /* ── weft-input ──────────────────────────────────────────────── */ + class WeftInput extends HTMLElement { + static observedAttributes = ['placeholder', 'type', 'value', 'disabled']; + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: block; } + input { + width: 100%; padding: 9px 12px; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.07); + color: rgba(255,255,255,0.92); font-size: 14px; + outline: none; transition: border-color 0.15s; + } + input::placeholder { color: rgba(255,255,255,0.35); } + input:focus { border-color: rgba(91,138,245,0.7); } + input:disabled { opacity: 0.45; cursor: not-allowed; } + `)]; + this._input = document.createElement('input'); + root.appendChild(this._input); + this._input.addEventListener('input', function (e) { + this.dispatchEvent(new CustomEvent('weft:input', { detail: e.target.value, bubbles: true })); + }.bind(this)); + this._sync(); + } + attributeChangedCallback() { this._sync(); } + _sync() { + var i = this._input; + if (!i) return; + i.placeholder = this.getAttribute('placeholder') || ''; + i.type = this.getAttribute('type') || 'text'; + if (this.hasAttribute('value')) i.value = this.getAttribute('value'); + i.disabled = this.hasAttribute('disabled'); + } + get value() { return this._input ? this._input.value : ''; } + set value(v) { if (this._input) this._input.value = v; } + } + + /* ── weft-label ──────────────────────────────────────────────── */ + class WeftLabel extends HTMLElement { + constructor() { + super(); + var root = this.attachShadow({ mode: 'open' }); + root.adoptedStyleSheets = [sheet(` + :host { display: inline-block; } + .label { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 100px; font-size: 11px; + font-weight: 600; letter-spacing: 0.02em; + background: rgba(91,138,245,0.2); color: #93b4ff; + } + :host([variant=success]) .label { background: rgba(52,199,89,0.2); color: #6ee09c; } + :host([variant=warning]) .label { background: rgba(255,159,10,0.2); color: #ffd060; } + :host([variant=error]) .label { background: rgba(255,69,58,0.2); color: #ff8a80; } + :host([variant=neutral]) .label { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); } + `)]; + var d = document.createElement('div'); + d.className = 'label'; + d.appendChild(document.createElement('slot')); + root.appendChild(d); + } + } + + /* ── registration ────────────────────────────────────────────── */ + var defs = { + 'weft-button': WeftButton, + 'weft-card': WeftCard, + 'weft-dialog': WeftDialog, + 'weft-icon': WeftIcon, + 'weft-list': WeftList, + 'weft-list-item': WeftListItem, + 'weft-menu': WeftMenu, + 'weft-menu-item': WeftMenuItem, + 'weft-progress': WeftProgress, + 'weft-input': WeftInput, + 'weft-label': WeftLabel, + }; + + Object.keys(defs).forEach(function (name) { + if (!customElements.get(name)) { + customElements.define(name, defs[name]); + } + }); +}()); diff --git a/infra/systemd/servo-shell.service b/infra/systemd/servo-shell.service index 63c748f..e44cf9d 100644 --- a/infra/systemd/servo-shell.service +++ b/infra/systemd/servo-shell.service @@ -6,12 +6,12 @@ After=weft-compositor.service [Service] Type=simple -ExecStart=/packages/system/servo-shell/active/bin/weft-servo-shell +PassEnvironment=WAYLAND_DISPLAY XDG_RUNTIME_DIR DISPLAY +Environment=WEFT_APPD_WS_PORT=7410 +ExecStart=/packages/system/weft-servo-shell/active/bin/weft-servo-shell \ + /packages/system/weft-servo-shell/active/share/weft/shell/system-ui.html Restart=on-failure RestartSec=2 -# WAYLAND_DISPLAY is exported by weft-compositor after sd_notify(READY=1). -# Downstream services that need the shell ready must declare -# After=servo-shell.service and a suitable readiness mechanism. [Install] WantedBy=graphical.target diff --git a/infra/systemd/weft-appd.service b/infra/systemd/weft-appd.service index bae0ee9..268af47 100644 --- a/infra/systemd/weft-appd.service +++ b/infra/systemd/weft-appd.service @@ -7,9 +7,16 @@ After=weft-compositor.service servo-shell.service [Service] Type=notify Environment=WEFT_RUNTIME_BIN=/packages/system/weft-runtime/active/bin/weft-runtime +Environment=WEFT_APP_SHELL_BIN=/packages/system/weft-app-shell/active/bin/weft-app-shell +Environment=WEFT_FILE_PORTAL_BIN=/packages/system/weft-file-portal/active/bin/weft-file-portal +Environment=WEFT_MOUNT_HELPER=/packages/system/weft-mount-helper/active/bin/weft-mount-helper +PassEnvironment=WAYLAND_DISPLAY XDG_RUNTIME_DIR DISPLAY ExecStart=/packages/system/weft-appd/active/bin/weft-appd Restart=on-failure RestartSec=1s +ProtectSystem=strict +PrivateTmp=true +ReadWritePaths=%t [Install] WantedBy=graphical.target diff --git a/protocol/weft-shell-unstable-v1.xml b/protocol/weft-shell-unstable-v1.xml index 6281ac4..339eb11 100644 --- a/protocol/weft-shell-unstable-v1.xml +++ b/protocol/weft-shell-unstable-v1.xml @@ -43,7 +43,7 @@ - + Bound once by weft-servo-shell. The manager owns the shell session. @@ -90,7 +90,7 @@ - + Represents one visual window. The compositor controls the effective @@ -193,6 +193,24 @@ + + + Sent to panel windows only when the compositor recognizes a + multi-touch gesture as a navigation intent rather than a standard + pointer gesture. The shell uses this to present the launcher, + switch applications, or perform other system-level transitions. + + type values: 0 = swipe, 1 = pinch, 2 = hold. + dx and dy are total displacement in logical pixels at gesture end. + The compositor only emits this after the gesture has ended and + met the recognition threshold. + + + + + + +