feat: appd IPC relay, WIT interfaces, UI kit, gesture routing, and CI hardening

- weft-appd: per-session IPC socket paths; bidirectional Wasm-HTML JSON relay
  via spawn_ipc_relay; SO_PEERCRED UID check on Unix socket connections;
  PanelGesture request and NavigationGesture broadcast for compositor gestures
- weft-runtime: weft:app/ipc, weft:app/fetch, weft:app/notifications WIT
  interfaces; IpcState non-blocking Unix socket host functions; ureq-backed
  net:fetch host function (net-fetch feature); notify-send notifications host
- weft-file-portal: spawn a thread per accepted connection for concurrent access
- weft-app-shell: weft-system:// URL translation; WEFT UI Kit UserScript
  injection; resolve_weft_system_url helper
- weft-servo-shell: forward compositor navigation gestures to weft-appd
  WebSocket as PanelGesture; WEFT UI Kit UserScript injection
- infra/shell: weft-ui-kit.js with 11 custom elements (weft-button, weft-card,
  weft-dialog, weft-icon, weft-list, weft-list-item, weft-menu, weft-menu-item,
  weft-progress, weft-input, weft-label); system-ui.html handles
  NAVIGATION_GESTURE messages and dispatches weft:navigation-gesture CustomEvent
- infra/systemd: add missing env vars to weft-appd.service; correct
  servo-shell.service binary path and system-ui.html argument
- .github/workflows/ci.yml: exclude weft-servo-shell and weft-app-shell from
  cross-platform job; add them to linux-only job with libsystemd-dev dependency
This commit is contained in:
Marco Allegretti 2026-03-12 12:49:45 +01:00
parent a401510b88
commit 4d0089a107
27 changed files with 1127 additions and 73 deletions

View file

@ -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

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": "docu_dev/old",
"envManager": "ms-python.python:venv",
"packageManager": "ms-python.python:pip"
}
]
}

15
Cargo.lock generated
View file

@ -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"

View file

@ -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 }

View file

@ -169,6 +169,9 @@ impl ApplicationHandler<ServoWake> 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<ServoUrl> {
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<String> {
std::fs::read_to_string(ui_kit_path()).ok()
}
fn resolve_weft_system_url(url: &ServoUrl) -> Option<ServoUrl> {
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<String> {
#[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()
.map(|r| r.join(app_id).join("ui").join(rel))
.find(|p| p.exists())?;
.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<std::path::PathBuf> {
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(rel))
.find(|p| p.exists())
}
fn resolve_weft_app_url(app_id: &str) -> Option<ServoUrl> {
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()
}

View file

@ -59,7 +59,7 @@ impl Dispatch<wl_registry::WlRegistry, ()> for AppData {
} = event
&& interface == "zweft_shell_manager_v1"
{
let mgr = registry.bind::<ZweftShellManagerV1, _, _>(name, version.min(1), qh, ());
let mgr = registry.bind::<ZweftShellManagerV1, _, _>(name, version.min(2), qh, ());
state.manager = Some(mgr);
}
}
@ -118,11 +118,11 @@ impl Dispatch<ZweftShellWindowV1, ()> 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<AppData>,
data: AppData,
@ -181,7 +181,6 @@ impl ShellClient {
Ok(Self { event_queue, data })
}
#[allow(dead_code)]
pub fn dispatch_pending(&mut self) -> anyhow::Result<bool> {
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
}

View file

@ -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"

View file

@ -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<AppInfo>,
},
IpcMessage {
session_id: u64,
payload: String,
},
NavigationGesture {
gesture_type: u32,
fingers: u32,
dx: f64,
dy: f64,
},
Error {
code: u32,
message: String,

View file

@ -26,7 +26,7 @@ struct SessionRegistry {
broadcast: tokio::sync::broadcast::Sender<Response>,
abort_senders: std::collections::HashMap<u64, tokio::sync::oneshot::Sender<()>>,
compositor_tx: Option<compositor_client::CompositorSender>,
ipc_socket: Option<PathBuf>,
ipc_senders: std::collections::HashMap<u64, tokio::sync::mpsc::Sender<String>>,
}
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<String>,
) {
self.ipc_senders.insert(session_id, tx);
}
pub(crate) fn ipc_sender_for(
&self,
session_id: u64,
) -> Option<tokio::sync::mpsc::Sender<String>> {
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<Response> {
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<PathBuf> {
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
}
}
}

View file

@ -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<Response>,
) -> Option<tokio::sync::mpsc::Sender<String>> {
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::<String>(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 {

View file

@ -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")]

View file

@ -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);

View file

@ -273,36 +273,49 @@ fn handle_touch_cancel<B: InputBackend>(
}
}
const NAVIGATION_SWIPE_FINGERS: u32 = 3;
const NAVIGATION_SWIPE_THRESHOLD: f64 = 100.0;
fn handle_gesture_swipe_begin<B: InputBackend>(
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<B: InputBackend>(
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<B: InputBackend>(
@ -310,16 +323,28 @@ fn handle_gesture_swipe_end<B: InputBackend>(
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<B: InputBackend>(

View file

@ -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<ZweftShellWindowV1>,
}
#[allow(dead_code)]
pub struct WeftShellWindowData {
pub app_id: String,
pub title: String,
@ -38,15 +38,51 @@ impl WeftShellState {
D: GlobalDispatch<ZweftShellManagerV1, ()>,
D: 'static,
{
let global = display.create_global::<D, ZweftShellManagerV1, ()>(1, ());
Self { _global: global }
let global = display.create_global::<D, ZweftShellManagerV1, ()>(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<Item = &ZweftShellWindowV1> {
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]

View file

@ -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<Window>,
pub popups: PopupManager,
// Seat and input state
pub seat_state: SeatState<Self>,
pub seat: Seat<Self>,
pub pointer_location: Point<f64, Logical>,
pub cursor_image_status: CursorImageStatus,
// Set by the backend after renderer initialisation when DMA-BUF is supported.
#[allow(dead_code)]
pub dmabuf_global: Option<DmabufGlobal>,
// Set to false when the compositor should exit the event loop.
pub running: bool,
// WEFT compositorshell 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<WeftAppdIpc>,
@ -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<Self>, _focused: Option<&WlSurface>) {}
fn focus_changed(&mut self, _seat: &Seat<Self>, 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::<WeftShellWindowData>();
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<Self>, image: CursorImageStatus) {
self.cursor_image_status = image;
@ -439,7 +455,7 @@ impl GlobalDispatch<ZweftShellManagerV1, ()> for WeftCompositorState {
impl Dispatch<ZweftShellManagerV1, ()> 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<ZweftShellManagerV1, ()> for WeftCompositorState {
width,
height,
} => {
let is_panel = role == "panel";
let window = data_init.init(
id,
WeftShellWindowData {
@ -470,10 +487,28 @@ impl Dispatch<ZweftShellManagerV1, ()> for WeftCompositorState {
closed: std::sync::atomic::AtomicBool::new(false),
},
);
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);
}
}
}
}
}
impl Dispatch<ZweftShellWindowV1, WeftShellWindowData> for WeftCompositorState {

View file

@ -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}"),
}
}

View file

@ -189,14 +189,8 @@ fn check_package(dir: &Path) -> anyhow::Result<String> {
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 {

View file

@ -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 }

View file

@ -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<u8>,
}
impl IpcState {
fn connect(path: &str) -> Option<Self> {
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<String> {
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<u8> = 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<Mutex<Option<IpcState>>>,
}
impl WasiView for State {
@ -130,6 +186,11 @@ fn run_module(
let mut linker: Linker<State> = Linker::new(&engine);
add_to_linker_sync(&mut linker).context("add WASI to linker")?;
let ipc_state: Arc<Mutex<Option<IpcState>>> = 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")?
@ -139,11 +200,83 @@ fn run_module(
})
.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<String>,)> {
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/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<Vec<u8>>,
)|
-> wasmtime::Result<(
Result<(u16, String, Vec<u8>), 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<String>)|
-> 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<u8>), 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<u8>), 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};

View file

@ -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<string>;
}
/// 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<u8>,
}
/// 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<tuple<string, string>>,
body: option<list<u8>>,
) -> result<response, string>;
}
/// 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<string>,
) -> result<_, string>;
}

View file

@ -173,6 +173,9 @@ impl ApplicationHandler<ServoWake> 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<ServoWake> 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> {
ServoUrl::parse(&s).ok()
}
fn load_ui_kit_script() -> Option<String> {
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<PathBuf> {
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);
}

View file

@ -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<ZweftShellManagerV1>,
window: Option<ZweftShellWindowV1>,
window_state: ShellWindowState,
pending_gestures: Vec<PendingGesture>,
}
impl AppData {
@ -43,6 +51,7 @@ impl AppData {
focused: false,
closed: false,
},
pending_gestures: Vec::new(),
}
}
}
@ -63,7 +72,7 @@ impl Dispatch<wl_registry::WlRegistry, ()> for AppData {
} = event
&& interface == "zweft_shell_manager_v1"
{
let mgr = registry.bind::<ZweftShellManagerV1, _, _>(name, version.min(1), qh, ());
let mgr = registry.bind::<ZweftShellManagerV1, _, _>(name, version.min(2), qh, ());
state.manager = Some(mgr);
}
}
@ -122,13 +131,26 @@ impl Dispatch<ZweftShellWindowV1, ()> 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<AppData>,
data: AppData,
@ -185,7 +207,6 @@ impl ShellClient {
Ok(Self { event_queue, data })
}
#[allow(dead_code)]
pub fn dispatch_pending(&mut self) -> anyhow::Result<bool> {
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<PendingGesture> {
std::mem::take(&mut self.data.pending_gestures)
}
}

View file

@ -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);
}

332
infra/shell/weft-ui-kit.js Normal file
View file

@ -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]);
}
});
}());

View file

@ -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

View file

@ -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

View file

@ -43,7 +43,7 @@
<!-- ───────────────────────────── Manager ───────────────────────────── -->
<interface name="zweft_shell_manager_v1" version="1">
<interface name="zweft_shell_manager_v1" version="2">
<description summary="shell session manager">
Bound once by weft-servo-shell. The manager owns the shell session.
@ -90,7 +90,7 @@
<!-- ───────────────────────────── Window ────────────────────────────── -->
<interface name="zweft_shell_window_v1" version="1">
<interface name="zweft_shell_window_v1" version="2">
<description summary="a compositor-managed window slot">
Represents one visual window. The compositor controls the effective
@ -193,6 +193,24 @@
<arg name="refresh" type="uint" summary="output refresh interval in nanoseconds"/>
</event>
<event name="navigation_gesture" since="2">
<description summary="compositor-recognized navigation gesture">
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.
</description>
<arg name="type" type="uint" summary="gesture type: 0=swipe 1=pinch 2=hold"/>
<arg name="fingers" type="uint" summary="number of fingers"/>
<arg name="dx" type="fixed" summary="horizontal displacement in logical pixels"/>
<arg name="dy" type="fixed" summary="vertical displacement in logical pixels"/>
</event>
</interface>
</protocol>