// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! The page's desktop, when it has one: forge/web publishes `globalThis.forgeWeb`, and the app's surfaces are asked of it by role and drawn into the elements it gives (forge/web/DESIGN.md, section 4). Its events are queued like any other page event. use serde_json::{ json, Value }; use wasm_bindgen::closure::Closure; use wasm_bindgen::{ JsCast, JsValue }; use web_sys::{ HtmlCanvasElement, HtmlElement }; use crate::app::{ App, Layer, ShellMode, ToplevelEvent }; use crate::event_loop::AppData; use super::run::{ push, PageEvent }; use super::{ files, shell }; /// The id the main surface goes by. pub( super ) const MAIN: u32 = 0; pub( crate ) struct Connection { request: js_sys::Function, disconnect: js_sys::Function, _on_event: Closure, } impl Connection { /// Connect as `app_id`; `None` when the page has no desktop. pub( super ) fn open( app_id: &str ) -> Option { let api = js_sys::Reflect::get( &js_sys::global(), &"forgeWeb".into() ).ok().filter( |v| v.is_object() )?; let connect: js_sys::Function = js_sys::Reflect::get( &api, &"connect".into() ).ok()?.dyn_into().ok()?; let on_event = Closure::::new( on_event ); let connection = connect.call2( &api, &JsValue::from_str( app_id ), on_event.as_ref() ).ok()?; let method = |name: &str| js_sys::Reflect::get( &connection, &name.into() ).ok().and_then( |f| f.dyn_into::().ok() ); let this = Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event }; shell::connected( Some( this.request.clone() ) ); files::connected( Some( this.request.clone() ) ); if let Err( e ) = this.request( &json!( { "type": "list_toplevels" } ) ) { web_sys::console::warn_1( &format!( "[ltk] list_toplevels: {e}" ).into() ); } Some( this ) } pub( super ) fn request( &self, message: &Value ) -> Result { self.request.call1( &JsValue::NULL, &JsValue::from_str( &message.to_string() ) ) .map_err( |e| e.as_string().unwrap_or_else( || format!( "{e:?}" ) ) ) } /// A surface in the role `role`: its canvas, and the element above it that takes its input. pub( super ) fn create_surface( &self, surface: u32, role: Value ) -> Result<( HtmlCanvasElement, HtmlElement ), String> { let elements = self.request( &json!( { "type": "create_surface", "surface": surface, "role": role } ) )?; let get = |name: &str| js_sys::Reflect::get( &elements, &name.into() ).map_err( |_| format!( "create_surface: no {name}" ) ); let canvas = get( "canvas" )?.dyn_into().map_err( |_| "create_surface: canvas is not a canvas".to_string() )?; let input = get( "input" )?.dyn_into().map_err( |_| "create_surface: input is not an element".to_string() )?; Ok( ( canvas, input ) ) } /// Ask for `surface`, a window, to fill the desktop or to go back. pub( super ) fn set_fullscreen( &self, surface: u32, on: bool ) { if let Err( e ) = self.request( &json!( { "type": "set_fullscreen", "surface": surface, "on": on } ) ) { web_sys::console::warn_1( &format!( "[ltk] set_fullscreen: {e}" ).into() ); } } /// Make `text` the desktop's clipboard, or with `primary` what the middle button pastes, for the page's other applications. pub( super ) fn set_selection( &self, primary: bool, text: &str ) { let kind = if primary { "set_primary_selection" } else { "set_selection" }; if let Err( e ) = self.request( &json!( { "type": kind, "text": text } ) ) { web_sys::console::warn_1( &format!( "[ltk] {kind}: {e}" ).into() ); } } pub( super ) fn close( &self ) { shell::connected( None ); files::connected( None ); let _ = self.disconnect.call0( &JsValue::NULL ); } } /// One of forge/web's events, with the file contents `data` carries for an `fs_reply`. fn on_event( json: String, data: JsValue ) { let Ok( event ) = serde_json::from_str::( &json ) else { return }; let surface = event[ "surface" ].as_u64().unwrap_or( 0 ) as u32; match event[ "type" ].as_str() { Some( "configure" ) => push( PageEvent::Configure { surface, width: event[ "width" ].as_f64().unwrap_or( 0.0 ), height: event[ "height" ].as_f64().unwrap_or( 0.0 ), scale: event[ "scale" ].as_f64().unwrap_or( 1.0 ), } ), Some( "close" ) => push( PageEvent::Close { surface } ), Some( "shell" ) => shell::deliver( &event[ "message" ] ), Some( "toplevel_opened" ) => push( PageEvent::Toplevel( ToplevelEvent::Opened { id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32, app_id: event[ "app_id" ].as_str().unwrap_or_default().to_string(), identifier: event[ "identifier" ].as_str().unwrap_or_default().to_string(), title: event[ "title" ].as_str().unwrap_or_default().to_string(), } ) ), Some( "toplevel_closed" ) => push( PageEvent::Toplevel( ToplevelEvent::Closed { id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32 } ) ), Some( "fs_reply" ) => files::reply( &event, data ), Some( "fs_progress" ) => files::progress( &event ), Some( "fs_changed" ) => push( PageEvent::FilesChanged( event[ "path" ].as_str().unwrap_or_default().into() ) ), Some( "fs_mounts" ) => push( PageEvent::Mounts( files::mounts( &event ) ) ), Some( "open_files" ) => push( PageEvent::OpenFiles( files::paths( &event ) ) ), Some( "selection" ) => push( PageEvent::Selection { primary: false, text: event[ "text" ].as_str().unwrap_or_default().to_string() } ), Some( "primary_selection" ) => push( PageEvent::Selection { primary: true, text: event[ "text" ].as_str().unwrap_or_default().to_string() } ), _ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ), } } /// Issue the activation tokens the app asked for and hand them to it before it draws, as xdg-activation does on Wayland: forge/web's, or empty without a desktop, so the app still goes ahead. pub( super ) fn issue_activation_tokens( data: &mut AppData ) { let tags = data.app.take_activation_requests(); if tags.is_empty() { return; } for tag in tags { let token = data.platform.desktop.as_ref() .and_then( |d| d.request( &json!( { "type": "activation_token" } ) ).ok() ) .and_then( |token| token.as_string() ) .unwrap_or_default(); if let Some( msg ) = data.app.on_activation_token( tag, token ) { data.pending_msgs.push( msg ); } } data.process_messages(); } pub( super ) fn layer_name( layer: Layer ) -> &'static str { match layer { Layer::Background => "background", Layer::Bottom => "bottom", Layer::Top => "top", Layer::Overlay => "overlay", Layer::Window => "window", } } /// The main surface's role, from the hooks a Wayland compositor would read it from. pub( super ) fn main_role( app: &A ) -> Value { let window = || { let ( width, height ) = app.window_size_hint().unwrap_or( ( 0, 0 ) ); json!( { "kind": "window", "title": app.window_title(), "app_id": app.app_id(), "width": width, "height": height } ) }; match app.shell_mode() { ShellMode::Window | ShellMode::Layer( Layer::Window ) => window(), ShellMode::Layer( layer ) => { let anchor = app.layer_anchor(); let ( width, height ) = app.layer_size(); json!( { "kind": "layer", "layer": layer_name( layer ), "anchor": { "top": anchor.top, "bottom": anchor.bottom, "left": anchor.left, "right": anchor.right }, "width": width, "height": height, "exclusive_zone": app.exclusive_zone(), "keyboard": if app.keyboard_exclusive() { "exclusive" } else { "on_demand" }, } ) } ShellMode::SessionLock => json!( { "kind": "layer", "layer": "overlay", "anchor": { "top": true, "bottom": true, "left": true, "right": true }, "exclusive_zone": -1, "keyboard": "exclusive", } ), } }