From c1fd4faede69bb9be359b92d1ab91126b4f6bf10 Mon Sep 17 00:00:00 2001 From: Divy Srivastava Date: Tue, 10 Mar 2026 11:15:56 +0530 Subject: [PATCH 001/137] deno compile --desktop --- Cargo.lock | 36 ++- Cargo.toml | 3 +- cli/args/flags.rs | 27 ++ cli/rt/Cargo.toml | 2 + cli/rt/binary.rs | 12 +- cli/rt/desktop.rs | 92 +++++++ cli/rt/file_system.rs | 17 +- cli/rt/hmr.rs | 505 ++++++++++++++++++++++++++++++++++++++ cli/rt/lib.rs | 14 +- cli/rt/run.rs | 244 ++++++++++++++++-- cli/rt_desktop/Cargo.toml | 35 +++ cli/rt_desktop/build.rs | 50 ++++ cli/rt_desktop/lib.rs | 435 ++++++++++++++++++++++++++++++++ cli/standalone/binary.rs | 96 ++++++++ cli/tools/compile.rs | 228 ++++++++++++++++- cli/tools/framework.rs | 428 ++++++++++++++++++++++++++++++++ cli/tools/mod.rs | 1 + runtime/js/99_main.js | 7 + 18 files changed, 2189 insertions(+), 43 deletions(-) create mode 100644 cli/rt/desktop.rs create mode 100644 cli/rt/hmr.rs create mode 100644 cli/rt_desktop/Cargo.toml create mode 100644 cli/rt_desktop/build.rs create mode 100644 cli/rt_desktop/lib.rs create mode 100644 cli/tools/framework.rs diff --git a/Cargo.lock b/Cargo.lock index f7045580ccea57..579288823d00d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3483,6 +3483,7 @@ version = "2.7.2" dependencies = [ "async-trait", "bincode", + "deno_ast", "deno_cache_dir", "deno_config", "deno_core", @@ -3505,6 +3506,7 @@ dependencies = [ "log", "memmap2", "node_resolver", + "notify", "rustls", "serde", "serde_json", @@ -3515,6 +3517,24 @@ dependencies = [ "url", ] +[[package]] +name = "denort_desktop" +version = "2.7.4" +dependencies = [ + "deno_core", + "deno_error", + "deno_lib", + "deno_runtime", + "deno_snapshots", + "deno_terminal", + "denort", + "libsui", + "log", + "rustls", + "tokio", + "wef", +] + [[package]] name = "denort_helper" version = "0.33.0" @@ -6327,13 +6347,12 @@ dependencies = [ [[package]] name = "libsui" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b6d6bbf43ba95540d1681826c8d7acb9744708398463ccbcd3c3a5d04c2fdc" +version = "0.13.0" dependencies = [ "editpe", "image", "libc", + "object", "sha2", "windows-sys 0.48.0", "zerocopy 0.7.32", @@ -7029,6 +7048,9 @@ version = "0.36.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" dependencies = [ + "crc32fast", + "hashbrown 0.14.5", + "indexmap 2.9.0", "memchr", ] @@ -11250,6 +11272,14 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +[[package]] +name = "wef" +version = "0.1.0" +dependencies = [ + "bindgen 0.71.1", + "tokio", +] + [[package]] name = "wgpu-core" version = "28.0.0" diff --git a/Cargo.toml b/Cargo.toml index 1046db6034acb7..c139b2809acecc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "cli", "cli/lib", "cli/rt", + "cli/rt_desktop", "cli/snapshot", "ext/bundle", "ext/cache", @@ -360,7 +361,7 @@ dprint-plugin-markdown = "=0.20.0" dprint-plugin-typescript = "=0.95.15" env_logger = "=0.11.6" fancy-regex = "=0.14.0" -libsui = "0.12.6" +libsui = { path = "../sui" } malva = "=0.12.1" markup_fmt = "=0.22.0" open = "5.0.1" diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 205b1843c997bd..1cd53871f2d539 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -175,6 +175,8 @@ pub struct CompileFlags { pub exclude: Vec, pub eszip: bool, pub self_extracting: bool, + pub desktop: bool, + pub hmr: bool, } impl CompileFlags { @@ -2815,6 +2817,21 @@ On the first invocation of `deno compile`, Deno will download the relevant binar .action(ArgAction::SetTrue) .help_heading(COMPILE_HEADING), ) + .arg( + Arg::new("desktop") + .long("desktop") + .help("Compile as a desktop application using a WEF backend for the UI layer") + .action(ArgAction::SetTrue) + .help_heading(COMPILE_HEADING), + ) + .arg( + Arg::new("hmr") + .long("hmr") + .help("Compile and run the desktop app with Hot Module Replacement enabled") + .requires("desktop") + .action(ArgAction::SetTrue) + .help_heading(COMPILE_HEADING), + ) .arg(executable_ext_arg()) .arg(env_file_arg()) .arg( @@ -6135,6 +6152,8 @@ fn compile_parse( let no_terminal = matches.get_flag("no-terminal"); let eszip = matches.get_flag("eszip-internal-do-not-use"); let self_extracting = matches.get_flag("self-extracting"); + let desktop = matches.get_flag("desktop"); + let hmr = matches.get_flag("hmr"); let include = matches .remove_many::("include") .map(|f| f.collect::>()) @@ -6158,6 +6177,8 @@ fn compile_parse( exclude, eszip, self_extracting, + desktop, + hmr, }); Ok(()) @@ -12495,6 +12516,8 @@ mod tests { exclude: Default::default(), eszip: false, self_extracting: false, + desktop: false, + hmr: false, }), type_check_mode: TypeCheckMode::Local, code_cache_enabled: true, @@ -12522,6 +12545,8 @@ mod tests { exclude: vec!["exclude.txt".to_string()], eszip: false, self_extracting: false, + desktop: false, + hmr: false, }), import_map_path: Some("import_map.json".to_string()), no_remote: true, @@ -14837,6 +14862,8 @@ Usage: deno repl [OPTIONS] [-- [ARGS]...]\n" exclude: Default::default(), eszip: false, self_extracting: false, + desktop: false, + hmr: false, }), type_check_mode: TypeCheckMode::Local, preload: svec!["p1.js", "./p2.js"], diff --git a/cli/rt/Cargo.toml b/cli/rt/Cargo.toml index 2cc21e0fe7e8b7..3dc4a12ed44956 100644 --- a/cli/rt/Cargo.toml +++ b/cli/rt/Cargo.toml @@ -29,6 +29,7 @@ deno_runtime = { workspace = true, features = ["include_js_files_for_snapshottin deno_core = { workspace = true, features = ["include_js_files_for_snapshotting"] } [dependencies] +deno_ast = { workspace = true, features = ["transpiling"] } deno_cache_dir = { workspace = true, features = ["sync"] } deno_config = { workspace = true, features = ["sync", "workspace"] } deno_core = { workspace = true, features = ["include_js_files_for_snapshotting"] } @@ -45,6 +46,7 @@ deno_snapshots.workspace = true deno_terminal.workspace = true libsui.workspace = true node_resolver.workspace = true +notify.workspace = true async-trait.workspace = true bincode.workspace = true diff --git a/cli/rt/binary.rs b/cli/rt/binary.rs index 191b5eadcce032..6140975de5d81c 100644 --- a/cli/rt/binary.rs +++ b/cli/rt/binary.rs @@ -59,7 +59,17 @@ pub struct StandaloneData { pub fn extract_standalone( cli_args: Cow<[OsString]>, ) -> Result { - let data = find_section()?; + extract_standalone_with_finder(cli_args, find_section) +} + +/// Like `extract_standalone`, but allows providing a custom section finder. +/// This is used by the desktop cdylib to search its own image rather than +/// the main executable. +pub fn extract_standalone_with_finder( + cli_args: Cow<[OsString]>, + section_finder: fn() -> Result<&'static [u8], AnyError>, +) -> Result { + let data = section_finder()?; // read metadata first to determine the root path let (mut metadata, remaining) = read_section_metadata(data)?; diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs new file mode 100644 index 00000000000000..3e70e0963bce10 --- /dev/null +++ b/cli/rt/desktop.rs @@ -0,0 +1,92 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Desktop window management extension for `deno compile --desktop`. +//! +//! Exposes `Deno.desktop.*` APIs that control the native window +//! (title, size, navigation, JS execution in the webview, quit). +//! +//! The actual implementation is provided via [`DesktopApi`] which is +//! placed into the OpState by the caller. + +use deno_core::Extension; +use deno_core::OpState; +use deno_core::op2; + +/// Trait for desktop window operations. Implemented by the desktop +/// runtime to bridge to the WEF backend. +pub trait DesktopApi: Send + Sync + 'static { + fn set_title(&self, title: &str); + fn set_window_size(&self, width: i32, height: i32); + fn navigate(&self, url: &str); + fn execute_js(&self, script: &str); + fn quit(&self); +} + +fn get_api(state: &OpState) -> &dyn DesktopApi { + &**state.borrow::>() +} + +#[op2(fast)] +fn op_desktop_set_title(state: &OpState, #[string] title: &str) { + get_api(state).set_title(title); +} + +#[op2(fast)] +fn op_desktop_set_window_size(state: &OpState, width: i32, height: i32) { + get_api(state).set_window_size(width, height); +} + +#[op2(fast)] +fn op_desktop_navigate(state: &OpState, #[string] url: &str) { + get_api(state).navigate(url); +} + +#[op2(fast)] +fn op_desktop_execute_js(state: &OpState, #[string] script: &str) { + get_api(state).execute_js(script); +} + +#[op2(fast)] +fn op_desktop_quit(state: &OpState) { + get_api(state).quit(); +} + +deno_core::extension!( + deno_desktop, + ops = [ + op_desktop_set_title, + op_desktop_set_window_size, + op_desktop_navigate, + op_desktop_execute_js, + op_desktop_quit, + ], +); + +/// JS code that sets up `Deno.desktop.*` APIs. +pub const DESKTOP_JS: &str = r#" +(() => { + const ops = Deno[Deno.internal].core.ops; + + class BrowserWindow { + setTitle(title) { ops.op_desktop_set_title(title); } + setSize(width, height) { ops.op_desktop_set_window_size(width, height); } + navigate(url) { ops.op_desktop_navigate(url); } + executeJs(script) { ops.op_desktop_execute_js(script); } + close() { ops.op_desktop_quit(); } + } + + Deno.desktop = { + BrowserWindow, + mainWindow: new BrowserWindow(), + }; +})(); +"#; + +/// Create the desktop extension with the given API implementation. +pub fn init_extension(api: Box) -> Extension { + let mut ext = self::deno_desktop::init(); + ext.op_state_fn = Some(Box::new(move |state: &mut deno_core::OpState| { + state.put::>(api); + })); + ext +} diff --git a/cli/rt/file_system.rs b/cli/rt/file_system.rs index f14d492f11ff7f..90b54ab168d1a1 100644 --- a/cli/rt/file_system.rs +++ b/cli/rt/file_system.rs @@ -1149,9 +1149,15 @@ impl VfsRoot { let relative_path = match path.strip_prefix(&self.root_path) { Ok(p) => p, Err(_) => { + eprintln!( + "[VFS] path not found (outside root '{}'):\n path: {}\n backtrace: {:?}", + self.root_path.display(), + path.display(), + std::backtrace::Backtrace::force_capture() + ); return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "path not found", + format!("path not found (outside root): {}", path.display()), )); } }; @@ -1177,7 +1183,7 @@ impl VfsRoot { _ => { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "path not found", + format!("path not found (symlink not dir): {}", path.display()), )); } } @@ -1185,7 +1191,7 @@ impl VfsRoot { _ => { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "path not found", + format!("path not found (not dir): {}", path.display()), )); } }; @@ -1194,7 +1200,10 @@ impl VfsRoot { .entries .get_by_name(&component, case_sensitivity) .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "path not found") + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("path not found (entry missing): {}", path.display()), + ) })? .as_ref(); } diff --git a/cli/rt/hmr.rs b/cli/rt/hmr.rs new file mode 100644 index 00000000000000..71f715e53057a5 --- /dev/null +++ b/cli/rt/hmr.rs @@ -0,0 +1,505 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Simplified HMR (Hot Module Replacement) for the standalone/desktop runtime. +//! +//! Watches source files on disk, transpiles changed TypeScript/TSX/JSX files +//! using `deno_ast`, and hot-replaces them via V8's `Debugger.setScriptSource`. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicI32; + +use deno_core::LocalInspectorSession; +use deno_core::parking_lot::Mutex; +use deno_core::serde_json::Value; +use deno_core::serde_json::json; +use deno_core::serde_json::{self}; +use deno_core::url::Url; +use deno_error::JsErrorBox; +use notify::RecursiveMode; +use notify::Watcher; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +static NEXT_MSG_ID: AtomicI32 = AtomicI32::new(0); +fn next_id() -> i32 { + NEXT_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +// Minimal CDP types needed for HMR. +mod cdp { + use serde::Deserialize; + use serde_json::Value; + + #[derive(Debug, Deserialize)] + pub struct Notification { + pub method: String, + pub params: Value, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ScriptParsed { + pub script_id: String, + pub url: String, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ExceptionThrown { + pub exception_details: ExceptionDetails, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ExceptionDetails { + pub text: String, + pub exception: Option, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct RemoteObject { + pub description: Option, + } + + impl ExceptionDetails { + pub fn get_message_and_description(&self) -> (String, String) { + let description = self + .exception + .clone() + .and_then(|ex| ex.description) + .unwrap_or_else(|| "undefined".to_string()); + (self.text.to_string(), description) + } + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct SetScriptSourceResponse { + pub status: Status, + pub exception_details: Option, + } + + #[derive(Debug, Deserialize)] + pub enum Status { + Ok, + CompileError, + BlockedByActiveGenerator, + BlockedByActiveFunction, + BlockedByTopLevelEsModuleChange, + } +} + +fn explain(response: &cdp::SetScriptSourceResponse) -> String { + match response.status { + cdp::Status::Ok => "OK".to_string(), + cdp::Status::CompileError => { + if let Some(details) = &response.exception_details { + let (message, description) = details.get_message_and_description(); + format!( + "compile error: {}{}", + message, + if description == "undefined" { + "".to_string() + } else { + format!(" - {}", description) + } + ) + } else { + "compile error: No exception details available".to_string() + } + } + cdp::Status::BlockedByActiveGenerator => { + "blocked by active generator".to_string() + } + cdp::Status::BlockedByActiveFunction => { + "blocked by active function".to_string() + } + cdp::Status::BlockedByTopLevelEsModuleChange => { + "blocked by top-level ES module change".to_string() + } + } +} + +fn should_retry(status: &cdp::Status) -> bool { + matches!( + status, + cdp::Status::BlockedByActiveGenerator + | cdp::Status::BlockedByActiveFunction + ) +} + +/// Transpile a TypeScript/TSX/JSX source file to JavaScript for HMR. +fn transpile_for_hmr( + specifier: &Url, + source_code: String, +) -> Result { + use deno_ast::*; + let media_type = deno_media_type::MediaType::from_specifier(specifier); + match media_type { + deno_media_type::MediaType::TypeScript + | deno_media_type::MediaType::Mts + | deno_media_type::MediaType::Cts + | deno_media_type::MediaType::Jsx + | deno_media_type::MediaType::Tsx => { + let parsed = parse_module(ParseParams { + specifier: specifier.clone(), + text: source_code.into(), + media_type, + capture_tokens: false, + scope_analysis: false, + maybe_syntax: None, + }) + .map_err(JsErrorBox::from_err)?; + + let transpiled = parsed + .transpile( + &TranspileOptions::default(), + &TranspileModuleOptions::default(), + &EmitOptions { + source_map: SourceMapOption::None, + ..Default::default() + }, + ) + .map_err(JsErrorBox::from_err)? + .into_source(); + Ok(transpiled.text) + } + // JS files don't need transpilation + _ => Ok(source_code), + } +} + +#[derive(Debug)] +enum InspectorMessageState { + Ready(Value), + WaitingFor(oneshot::Sender), +} + +#[derive(Debug)] +struct HmrStateInner { + script_ids: HashMap, + messages: HashMap, + exception_tx: mpsc::UnboundedSender, +} + +#[derive(Clone, Debug)] +pub struct HmrState(Arc>); + +impl HmrState { + fn new(exception_tx: mpsc::UnboundedSender) -> Self { + Self(Arc::new(Mutex::new(HmrStateInner { + script_ids: HashMap::new(), + messages: HashMap::new(), + exception_tx, + }))) + } + + pub fn callback(&self, msg: deno_core::InspectorMsg) { + let deno_core::InspectorMsgKind::Message(msg_id) = msg.kind else { + let notification: cdp::Notification = + serde_json::from_str(&msg.content).unwrap(); + self.handle_notification(notification); + return; + }; + + let message: Value = serde_json::from_str(&msg.content).unwrap(); + let mut state = self.0.lock(); + let Some(message_state) = state.messages.remove(&msg_id) else { + state + .messages + .insert(msg_id, InspectorMessageState::Ready(message)); + return; + }; + let InspectorMessageState::WaitingFor(sender) = message_state else { + return; + }; + let _ = sender.send(message); + } + + fn handle_notification(&self, notification: cdp::Notification) { + if notification.method == "Runtime.exceptionThrown" { + let exception_thrown = + serde_json::from_value::(notification.params) + .unwrap(); + let (message, description) = exception_thrown + .exception_details + .get_message_and_description(); + let _ = self + .0 + .lock() + .exception_tx + .send(JsErrorBox::generic(format!("{} {}", message, description))); + } else if notification.method == "Debugger.scriptParsed" { + let params = + serde_json::from_value::(notification.params) + .unwrap(); + if params.url.starts_with("file://") { + // Store with the URL as-is (no canonicalization — VFS paths + // don't exist on disk so canonicalize would fail). + self + .0 + .lock() + .script_ids + .insert(params.url.clone(), params.script_id); + } + } + } +} + +/// Callback invoked after a module is successfully hot-replaced. +pub type HmrReloadCallback = Box; + +/// Desktop HMR runner. Watches source files and hot-replaces changed modules. +pub struct DesktopHmrRunner { + session: LocalInspectorSession, + state: HmrState, + changed_rx: mpsc::UnboundedReceiver, + exception_rx: mpsc::UnboundedReceiver, + /// The directory being watched on disk (original source location). + watch_dir: PathBuf, + /// The VFS root path inside the compiled binary. Script URLs use this base. + vfs_root: PathBuf, + _watcher: notify::RecommendedWatcher, + /// Optional callback to trigger a reload (e.g. refresh the webview). + on_reload: Option, +} + +impl DesktopHmrRunner { + /// Create a new HMR runner. Watches the given root directory for changes. + /// `vfs_root` is the embedded root path that V8 scripts are registered under. + pub fn new( + session: LocalInspectorSession, + state: HmrState, + watch_dir: PathBuf, + vfs_root: PathBuf, + exception_rx: mpsc::UnboundedReceiver, + ) -> Result { + let (changed_tx, changed_rx) = mpsc::unbounded_channel(); + + let mut watcher = + notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + if matches!( + event.kind, + notify::EventKind::Modify(_) | notify::EventKind::Create(_) + ) { + for path in event.paths { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if matches!(ext, "js" | "ts" | "jsx" | "tsx") { + let _ = changed_tx.send(path); + } + } + } + } + } + }) + .map_err(|e| JsErrorBox::generic(e.to_string()))?; + + watcher + .watch(&watch_dir, RecursiveMode::Recursive) + .map_err(|e| JsErrorBox::generic(e.to_string()))?; + + let watch_dir_canonical = watch_dir + .canonicalize() + .unwrap_or_else(|_| watch_dir.clone()); + + Ok(Self { + session, + state, + changed_rx, + exception_rx, + watch_dir: watch_dir_canonical, + vfs_root, + _watcher: watcher, + on_reload: None, + }) + } + + /// Set a callback to be invoked after each successful hot-replace. + pub fn set_on_reload(&mut self, cb: HmrReloadCallback) { + self.on_reload = Some(cb); + } + + pub fn start(&mut self) { + self + .session + .post_message::<()>(next_id(), "Debugger.enable", None); + self + .session + .post_message::<()>(next_id(), "Runtime.enable", None); + } + + pub async fn run(&mut self) -> Result<(), deno_core::error::CoreError> { + loop { + tokio::select! { + biased; + + maybe_error = self.exception_rx.recv() => { + if let Some(err) = maybe_error { + log::error!("HMR exception: {}", err); + } + } + + maybe_path = self.changed_rx.recv() => { + let Some(path) = maybe_path else { + break Ok(()); + }; + + let Ok(canonical) = path.canonicalize() else { + continue; + }; + + // Map the on-disk path to the VFS path that V8 knows about. + // e.g. /tmp/deno-desktop-test/app.tsx -> /tmp/.deno_compile_xxx/app.tsx + let relative = match canonical.strip_prefix(&self.watch_dir) { + Ok(rel) => rel, + Err(_) => continue, + }; + let vfs_path = self.vfs_root.join(relative); + let Ok(module_url) = Url::from_file_path(&vfs_path) else { + continue; + }; + + log::debug!( + "HMR: file changed {} -> VFS {}", + canonical.display(), + module_url + ); + + let Some(script_id) = self.state.0.lock().script_ids.get(module_url.as_str()).cloned() else { + // Not a tracked module, skip. + log::debug!("HMR: no script ID for {}, known scripts: {:?}", + module_url, + self.state.0.lock().script_ids.keys().collect::>() + ); + continue; + }; + + // Read from the actual file on disk (not the VFS path). + let source_code = match tokio::fs::read_to_string(&canonical).await { + Ok(s) => s, + Err(e) => { + log::warn!("HMR: failed to read {}: {}", canonical.display(), e); + continue; + } + }; + + let source_code = match transpile_for_hmr(&module_url, source_code) { + Ok(s) => s, + Err(e) => { + log::warn!("HMR: transpile error for {}: {}", module_url, e); + continue; + } + }; + + let mut tries = 1; + loop { + let msg_id = self.set_script_source(&script_id, &source_code); + let value = self.wait_for_response(msg_id).await; + let result: cdp::SetScriptSourceResponse = + match serde_json::from_value(value) { + Ok(r) => r, + Err(e) => { + log::warn!("HMR: bad CDP response: {}", e); + break; + } + }; + + if matches!(result.status, cdp::Status::Ok) { + self.dispatch_hmr_event(module_url.as_str()); + if let Some(on_reload) = &self.on_reload { + on_reload(); + } + eprintln!("HMR: replaced {}", module_url); + break; + } + + eprintln!( + "HMR: failed to reload {}: {}", + module_url, + explain(&result) + ); + if should_retry(&result.status) && tries <= 2 { + tries += 1; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + break; + } + } + } + } + } + + async fn wait_for_response(&self, msg_id: i32) -> Value { + if let Some(message_state) = self.state.0.lock().messages.remove(&msg_id) { + let InspectorMessageState::Ready(mut value) = message_state else { + unreachable!(); + }; + return value["result"].take(); + } + + let (tx, rx) = oneshot::channel(); + self + .state + .0 + .lock() + .messages + .insert(msg_id, InspectorMessageState::WaitingFor(tx)); + let mut value = rx.await.unwrap(); + value["result"].take() + } + + fn set_script_source(&mut self, script_id: &str, source: &str) -> i32 { + let msg_id = next_id(); + self.session.post_message( + msg_id, + "Debugger.setScriptSource", + Some(json!({ + "scriptId": script_id, + "scriptSource": source, + "allowTopFrameEditing": true, + })), + ); + msg_id + } + + fn dispatch_hmr_event(&mut self, module_url: &str) { + let expr = format!( + "dispatchEvent(new CustomEvent(\"hmr\", {{ detail: {{ path: \"{}\" }} }}));", + module_url + ); + self.session.post_message( + next_id(), + "Runtime.evaluate", + Some(json!({ + "expression": expr, + "contextId": Some(1), + })), + ); + } +} + +/// Set up HMR for the desktop runtime. Returns a runner that should be +/// polled concurrently with the event loop. +/// +/// `watch_dir` is the original source directory on disk. +/// `vfs_root` is the VFS root path that V8 scripts are registered under. +pub fn setup_desktop_hmr( + worker: &mut deno_lib::worker::LibMainWorker, + watch_dir: PathBuf, + vfs_root: PathBuf, +) -> Result { + let (exception_tx, exception_rx) = mpsc::unbounded_channel(); + let state = HmrState::new(exception_tx); + let state_clone = state.clone(); + let cb = Box::new(move |msg| state_clone.callback(msg)); + let session = worker.create_inspector_session(cb); + + let mut runner = + DesktopHmrRunner::new(session, state, watch_dir, vfs_root, exception_rx)?; + runner.start(); + Ok(runner) +} diff --git a/cli/rt/lib.rs b/cli/rt/lib.rs index 48e4d8de8ec446..c4b96df20b46fe 100644 --- a/cli/rt/lib.rs +++ b/cli/rt/lib.rs @@ -16,13 +16,15 @@ use indexmap::IndexMap; use self::binary::extract_standalone; use self::file_system::DenoRtSys; -mod binary; +pub mod binary; mod code_cache; -mod file_system; +pub mod desktop; +pub mod file_system; +pub mod hmr; mod node; -mod run; +pub mod run; -pub(crate) fn unstable_exit_cb(feature: &str, api_name: &str) { +pub fn unstable_exit_cb(feature: &str, api_name: &str) { log::error!( "Unstable API '{api_name}'. The `--unstable-{}` flag must be provided.", feature @@ -53,7 +55,7 @@ fn unwrap_or_exit(result: Result) -> T { } } -fn load_env_vars(env_vars: &IndexMap) { +pub fn load_env_vars(env_vars: &IndexMap) { env_vars.iter().for_each(|env_var| { if env::var(env_var.0).is_err() { #[allow(clippy::undocumented_unsafe_blocks)] @@ -106,7 +108,7 @@ pub fn main() { )); } -fn init_logging( +pub fn init_logging( maybe_level: Option, otel_config: Option, ) { diff --git a/cli/rt/run.rs b/cli/rt/run.rs index e2b4046ea1fbd7..0e926d5e5626d1 100644 --- a/cli/rt/run.rs +++ b/cli/rt/run.rs @@ -73,6 +73,7 @@ use deno_runtime::code_cache::CodeCache; use deno_runtime::deno_fs::FileSystem; use deno_runtime::deno_node::NodeRequireLoader; use deno_runtime::deno_node::create_host_defined_options; +use deno_runtime::deno_node::ops::ipc::ChildIpcSerialization; use deno_runtime::deno_permissions::Permissions; use deno_runtime::deno_permissions::PermissionsContainer; use deno_runtime::deno_tls::RootCertStoreProvider; @@ -550,6 +551,66 @@ impl ModuleLoader for EmbeddedModuleLoader { } } Ok(None) => { + // Fall back to reading from disk (used by dev entrypoint in HMR mode). + if original_specifier.scheme() == "file" { + if let Ok(path) = original_specifier.to_file_path() { + if let Ok(source) = std::fs::read_to_string(&path) { + let media_type = MediaType::from_specifier(original_specifier); + let (module_type, should_transpile) = match media_type { + MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => { + (ModuleType::JavaScript, false) + } + MediaType::TypeScript + | MediaType::Mts + | MediaType::Cts + | MediaType::Jsx + | MediaType::Tsx => (ModuleType::JavaScript, true), + MediaType::Json => (ModuleType::Json, false), + _ => (ModuleType::JavaScript, false), + }; + let code = if should_transpile { + match deno_ast::parse_module(deno_ast::ParseParams { + specifier: original_specifier.clone(), + text: source.into(), + media_type, + capture_tokens: false, + scope_analysis: false, + maybe_syntax: None, + }) { + Ok(parsed) => match parsed.transpile( + &deno_ast::TranspileOptions::default(), + &deno_ast::TranspileModuleOptions::default(), + &deno_ast::EmitOptions::default(), + ) { + Ok(transpiled) => ModuleSourceCode::String( + transpiled.into_source().text.into(), + ), + Err(e) => { + return deno_core::ModuleLoadResponse::Sync(Err( + JsErrorBox::type_error(format!("Transpile error: {e}")), + )); + } + }, + Err(e) => { + return deno_core::ModuleLoadResponse::Sync(Err( + JsErrorBox::type_error(format!("Parse error: {e}")), + )); + } + } + } else { + ModuleSourceCode::String(source.into()) + }; + return deno_core::ModuleLoadResponse::Sync(Ok( + deno_core::ModuleSource::new( + module_type, + code, + original_specifier, + None, + ), + )); + } + } + } deno_core::ModuleLoadResponse::Sync(Err(JsErrorBox::type_error( format!("Module not found: {}", original_specifier), ))) @@ -651,22 +712,30 @@ impl NodeRequireLoader for EmbeddedModuleLoader { &self, path: &std::path::Path, ) -> Result { - let file_entry = self - .shared - .vfs - .file_entry(path) - .map_err(JsErrorBox::from_err)?; - let file_bytes = self - .shared - .vfs - .read_file_offset_with_len( - file_entry.transpiled_offset.unwrap_or(file_entry.offset), - ) - .map_err(JsErrorBox::from_err)?; - Ok(match from_utf8_lossy_cow(file_bytes) { - Cow::Borrowed(s) => FastString::from_static(s), - Cow::Owned(s) => s.into(), - }) + match self.shared.vfs.file_entry(path) { + Ok(file_entry) => { + let file_bytes = self + .shared + .vfs + .read_file_offset_with_len( + file_entry.transpiled_offset.unwrap_or(file_entry.offset), + ) + .map_err(JsErrorBox::from_err)?; + Ok(match from_utf8_lossy_cow(file_bytes) { + Cow::Borrowed(s) => FastString::from_static(s), + Cow::Owned(s) => s.into(), + }) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // Fall back to the real filesystem (e.g. for paths outside VFS + // root in self-extracting/desktop dev mode). + let bytes = std::fs::read(path).map_err(JsErrorBox::from_err)?; + Ok(FastString::from( + String::from_utf8_lossy(&bytes).into_owned(), + )) + } + Err(err) => Err(JsErrorBox::from_err(err)), + } } fn is_maybe_cjs( @@ -732,11 +801,73 @@ impl RootCertStoreProvider for StandaloneRootCertStoreProvider { } } +/// Options to override default standalone runtime behavior. +/// Used by the desktop runtime to enable auto_serve and set the port. +pub struct RunOptions { + pub auto_serve: bool, + pub serve_port: Option, + pub serve_host: Option, + /// Enable HMR file watching from this directory. + pub hmr_watch_dir: Option, + /// Callback invoked after each successful HMR replacement. + pub hmr_on_reload: Option, + /// Additional extensions to load (e.g. desktop window APIs). + pub custom_extensions: Vec, + /// Override the main module to execute instead of the embedded entrypoint. + /// Used by desktop runtime for forked worker processes (child_process.fork) + /// where the child should run a specific script, not the embedded entrypoint. + pub override_main_module: Option, +} + +impl Default for RunOptions { + fn default() -> Self { + Self { + auto_serve: false, + serve_port: None, + serve_host: None, + hmr_watch_dir: None, + hmr_on_reload: None, + custom_extensions: vec![], + override_main_module: None, + } + } +} + +/// Read NODE_CHANNEL_FD from the environment to initialize IPC for +/// forked child processes (e.g. child_process.fork()). +fn node_ipc_init_from_env() -> Option<(i64, ChildIpcSerialization)> { + let fd_str = std::env::var("NODE_CHANNEL_FD").ok()?; + let serialization = std::env::var("NODE_CHANNEL_SERIALIZATION_MODE") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(ChildIpcSerialization::Json); + #[allow(clippy::undocumented_unsafe_blocks)] + unsafe { + std::env::remove_var("NODE_CHANNEL_FD"); + std::env::remove_var("NODE_CHANNEL_SERIALIZATION_MODE"); + } + let fd = fd_str.parse::().ok()?; + Some((fd, serialization)) +} + pub async fn run( fs: Arc, sys: DenoRtSys, data: StandaloneData, ) -> Result { + run_with_options(fs, sys, data, RunOptions::default()).await +} + +pub async fn run_with_options( + fs: Arc, + sys: DenoRtSys, + data: StandaloneData, + options: RunOptions, +) -> Result { + let hmr_watch_dir = options.hmr_watch_dir; + let hmr_on_reload = options.hmr_on_reload; + let custom_extensions = options.custom_extensions; + let override_main_module = options.override_main_module; let StandaloneData { metadata, modules, @@ -753,7 +884,11 @@ pub async fn run( // use a dummy npm registry url let npm_registry_url = Url::parse("https://localhost/").unwrap(); let root_dir_url = Arc::new(Url::from_directory_path(&root_path).unwrap()); - let main_module = root_dir_url.join(&metadata.entrypoint_key).unwrap(); + let main_module = if let Some(url) = override_main_module { + url + } else { + root_dir_url.join(&metadata.entrypoint_key).unwrap() + }; let npm_global_cache_dir = root_path.join(".deno_compile_node_modules"); let pkg_json_resolver = Arc::new(PackageJsonResolver::new( sys.clone(), @@ -1043,8 +1178,8 @@ pub async fn run( trace_ops: None, is_inspecting: false, is_standalone: true, - auto_serve: false, - skip_op_registration: true, + auto_serve: options.auto_serve, + skip_op_registration: custom_extensions.is_empty(), location: metadata.location, argv0: NpmPackageReqReference::from_specifier(&main_module) .ok() @@ -1055,9 +1190,9 @@ pub async fn run( seed: metadata.seed, unsafely_ignore_certificate_errors: metadata .unsafely_ignore_certificate_errors, - node_ipc_init: None, - serve_port: None, - serve_host: None, + node_ipc_init: node_ipc_init_from_env(), + serve_port: options.serve_port, + serve_host: options.serve_host, otel_config: metadata.otel_config, no_legacy_abort: false, startup_snapshot: deno_snapshots::CLI_SNAPSHOT, @@ -1113,16 +1248,73 @@ pub async fn run( .map(|key| root_dir_url.join(key).unwrap()) .collect::>(); - let mut worker = worker_factory.create_main_worker( + let has_desktop = !custom_extensions.is_empty(); + let mut worker = worker_factory.create_custom_worker( WorkerExecutionMode::Run, - permissions, main_module, preload_modules, require_modules, + permissions, + custom_extensions, + Default::default(), + None, )?; - let exit_code = worker.run().await?; - Ok(exit_code) + // Initialize desktop APIs (Deno.desktop.*). + if has_desktop { + worker + .js_runtime() + .execute_script("ext:deno_desktop/init", crate::desktop::DESKTOP_JS)?; + } + + if let Some(watch_dir) = hmr_watch_dir { + let mut hmr_runner = + crate::hmr::setup_desktop_hmr(&mut worker, watch_dir, root_path.clone())?; + if let Some(cb) = hmr_on_reload { + hmr_runner.set_on_reload(cb); + } + + // Run one event loop tick so the inspector processes the + // Debugger.enable / Runtime.enable messages before we load modules. + // This ensures scriptParsed notifications are emitted during module load. + worker.run_event_loop(false).await?; + + // Run preload modules first + worker.execute_preload_modules().await?; + worker.execute_main_module().await?; + worker.dispatch_load_event()?; + + loop { + let hmr_fut = hmr_runner.run(); + let event_loop_fut = worker.run_event_loop(false); + + tokio::select! { + hmr_result = hmr_fut => { + if let Err(e) = hmr_result { + log::error!("HMR error: {:?}", e); + } + } + event_loop_result = event_loop_fut => { + event_loop_result?; + let web_continue = worker.dispatch_beforeunload_event()?; + if !web_continue { + let node_continue = + worker.dispatch_process_beforeexit_event()?; + if !node_continue { + break; + } + } + } + } + } + + worker.dispatch_unload_event()?; + worker.dispatch_process_exit_event()?; + Ok(worker.exit_code()) + } else { + let exit_code = worker.run().await?; + Ok(exit_code) + } } fn create_default_npmrc() -> Arc { diff --git a/cli/rt_desktop/Cargo.toml b/cli/rt_desktop/Cargo.toml new file mode 100644 index 00000000000000..10606c69628dbd --- /dev/null +++ b/cli/rt_desktop/Cargo.toml @@ -0,0 +1,35 @@ +# Copyright 2018-2026 the Deno authors. MIT license. + +[package] +name = "denort_desktop" +version = "2.7.4" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +repository.workspace = true +description = "Provides libdenort shared library for WEF desktop apps" + +[lib] +name = "denort" +path = "lib.rs" +crate-type = ["cdylib"] + +[build-dependencies] +deno_runtime = { workspace = true, features = ["include_js_files_for_snapshotting", "only_snapshotted_js_sources"] } +deno_core = { workspace = true, features = ["include_js_files_for_snapshotting"] } + +[dependencies] +deno_core = { workspace = true, features = ["include_js_files_for_snapshotting"] } +deno_error.workspace = true +deno_lib.workspace = true +deno_runtime = { workspace = true, features = ["include_js_files_for_snapshotting"] } +deno_snapshots.workspace = true +deno_terminal.workspace = true +denort = { path = "../rt" } +libsui.workspace = true +wef = { path = "../../../wef/capi" } + +log = { workspace = true, features = ["serde"] } +rustls.workspace = true +tokio.workspace = true diff --git a/cli/rt_desktop/build.rs b/cli/rt_desktop/build.rs new file mode 100644 index 00000000000000..c1b8e90be7e383 --- /dev/null +++ b/cli/rt_desktop/build.rs @@ -0,0 +1,50 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +fn main() { + // Skip building from docs.rs. + if std::env::var_os("DOCS_RS").is_some() { + return; + } + + // For cdylib targets, we must explicitly export NAPI symbols so that + // native addons (e.g. next-swc) can resolve them via dlsym. + // The print_linker_flags() functions use cargo:rustc-link-arg-bin + // which only applies to binary targets, so we emit cdylib-specific + // linker args here instead. + print_cdylib_napi_linker_flags(); +} + +fn print_cdylib_napi_linker_flags() { + let symbols_file_name = match std::env::consts::OS { + "android" | "freebsd" | "openbsd" => { + "generated_symbol_exports_list_linux.def".to_string() + } + os => format!("generated_symbol_exports_list_{}.def", os), + }; + + // Path relative to this build script's Cargo.toml + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let symbols_path = std::path::Path::new(&manifest_dir) + .join("../../ext/napi") + .join(&symbols_file_name) + .canonicalize() + .expect("Missing NAPI symbols list"); + + println!("cargo:rerun-if-changed={}", symbols_path.display()); + + #[cfg(target_os = "macos")] + println!( + "cargo:rustc-cdylib-link-arg=-Wl,-exported_symbols_list,{}", + symbols_path.display(), + ); + + #[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd" + ))] + println!( + "cargo:rustc-cdylib-link-arg=-Wl,--export-dynamic-symbol-list={}", + symbols_path.display(), + ); +} diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs new file mode 100644 index 00000000000000..bf1822c7da6d51 --- /dev/null +++ b/cli/rt_desktop/lib.rs @@ -0,0 +1,435 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Desktop runtime for Deno (libdenort). +//! +//! This is a cdylib that exports the WEF C ABI (wef_runtime_init, +//! wef_runtime_start, wef_runtime_shutdown) and boots the full Deno +//! standalone runtime. A WEF backend (CEF, WebView, Servo) loads this +//! shared library and provides the browser/window layer. +//! +//! The user's code uses `Deno.serve()` or `export default { fetch }` +//! to serve an HTTP app. The desktop runtime starts it on a local port +//! and navigates the webview to it. + +use std::borrow::Cow; +use std::env; +use std::path::PathBuf; +use std::sync::Arc; + +use deno_core::anyhow::Context; +use deno_core::anyhow::bail; +use deno_core::error::AnyError; +use deno_lib::util::result::js_error_downcast_ref; +use deno_lib::version::otel_runtime_config; +use deno_runtime::fmt_errors::format_js_error; +use deno_terminal::colors; +use denort::run::RunOptions; + +/// Port used for the embedded HTTP server. +const DESKTOP_SERVE_PORT: u16 = 41520; + +/// WEF-backed implementation of [`denort::desktop::DesktopApi`]. +struct WefDesktopApi; + +impl denort::desktop::DesktopApi for WefDesktopApi { + fn set_title(&self, title: &str) { + wef::set_title(title); + } + + fn set_window_size(&self, width: i32, height: i32) { + wef::set_window_size(width, height); + } + + fn navigate(&self, url: &str) { + wef::navigate(url); + } + + fn execute_js(&self, script: &str) { + wef::execute_js(script); + } + + fn quit(&self) { + wef::quit(); + } +} + +/// Promote this dylib's symbols to the global symbol scope so that +/// native addons loaded via `dlopen` (e.g. next-swc.node) can resolve +/// NAPI function symbols from our library. +/// +/// By default, WEF loads this dylib without `RTLD_GLOBAL`, so its symbols +/// are only visible within the dylib itself. NAPI addons use +/// `-undefined dynamic_lookup` (macOS) and expect NAPI symbols to be +/// in the global symbol table. Re-opening ourselves with `RTLD_GLOBAL` +/// promotes our exports to global scope. +#[cfg(unix)] +fn promote_dylib_symbols_to_global() { + #[repr(C)] + struct DlInfo { + dli_fname: *const std::ffi::c_char, + dli_fbase: *mut std::ffi::c_void, + dli_sname: *const std::ffi::c_char, + dli_saddr: *mut std::ffi::c_void, + } + unsafe extern "C" { + fn dladdr( + addr: *const std::ffi::c_void, + info: *mut DlInfo, + ) -> std::ffi::c_int; + fn dlopen( + path: *const std::ffi::c_char, + flags: std::ffi::c_int, + ) -> *mut std::ffi::c_void; + } + const RTLD_LAZY: std::ffi::c_int = 0x1; + const RTLD_NOLOAD: std::ffi::c_int = 0x10; + const RTLD_GLOBAL: std::ffi::c_int = 0x8; + + unsafe { + let mut info: DlInfo = std::mem::zeroed(); + // Use a function in our dylib as a reference address + let addr = promote_dylib_symbols_to_global as *const std::ffi::c_void; + if dladdr(addr, &mut info) != 0 && !info.dli_fname.is_null() { + // Re-open our own dylib with RTLD_GLOBAL to promote symbols + dlopen(info.dli_fname, RTLD_LAZY | RTLD_NOLOAD | RTLD_GLOBAL); + } + } +} + +wef::main!(|| { + // Make NAPI symbols visible to native addons (e.g. next-swc). + #[cfg(unix)] + promote_dylib_symbols_to_global(); + + // Guard against re-entry: when a framework dev server (e.g. Next.js) + // forks child/worker processes, they re-execute this dylib. Detect + // forked workers and run them headless (no WEF window) by checking + // for NODE_CHANNEL_FD (set by Node's child_process.fork()) or + // NEXT_PRIVATE_WORKER (set by Next.js specifically). + let is_worker = env::var("NODE_CHANNEL_FD").is_ok() + || env::var("NEXT_PRIVATE_WORKER").is_ok(); + if is_worker { + run_headless_worker(); + return; + } + + denort::init_logging(None, None); + + deno_runtime::deno_permissions::mark_standalone(); + + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + eprintln!("[desktop] run_desktop starting"); + match run_desktop().await { + Ok(()) => eprintln!("[desktop] run_desktop completed OK"), + Err(error) => { + let error_string = match js_error_downcast_ref(&error) { + Some(js_error) => format_js_error(js_error, None), + None => format!("{:?}", error), + }; + log::error!( + "{}: {}", + colors::red_bold("error"), + error_string.trim_start_matches("error: ") + ); + } + } + }); +}); + +/// Run as a headless worker (no WEF window). Used when a framework dev +/// server forks child processes that re-execute this dylib. +fn run_headless_worker() { + denort::init_logging(None, None); + deno_runtime::deno_permissions::mark_standalone(); + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + let args: Vec<_> = env::args_os().collect(); + eprintln!("[worker] args: {:?}", args); + eprintln!("[worker] cwd: {:?}", env::current_dir()); + + // Detect if this is a child_process.fork() invocation. + // fork() translates args to: ["run", "-A", "--unstable-...", "script.js", ...] + // Extract the script path so the forked worker runs the correct module + // instead of the embedded entrypoint. + let fork_module = extract_fork_script_path(&args); + eprintln!("[worker] fork_module: {:?}", fork_module); + + let data = match denort::binary::extract_standalone_with_finder( + Cow::Owned(args), + find_section_in_dylib, + ) { + Ok(data) => data, + Err(e) => { + log::error!("Worker failed to load standalone data: {:?}", e); + return; + } + }; + + denort::load_env_vars(&data.metadata.env_vars_from_env_file); + + let sys = if data.metadata.self_extracting.is_some() { + // VFS should already be extracted by the parent process. + // In dev mode, keep the source directory as CWD (inherited from parent). + // In production mode, set CWD to extraction directory. + if env::var("DENO_DESKTOP_DEV").is_err() { + let _ = std::env::set_current_dir(&data.root_path); + } + denort::file_system::DenoRtSys::new_self_extracting(data.vfs.clone()) + } else { + denort::file_system::DenoRtSys::new(data.vfs.clone()) + }; + + let options = denort::run::RunOptions { + override_main_module: fork_module, + ..Default::default() + }; + + eprintln!("[worker] starting run_with_options"); + + // Log worker output to a file for debugging since the parent may + // call process.exit() and kill our stderr. + let log_path = std::env::temp_dir().join("deno_desktop_worker.log"); + let log_msg = format!("[worker] log file: {:?}\n", log_path); + eprint!("{}", log_msg); + let _ = std::fs::write(&log_path, &log_msg); + + match denort::run::run_with_options( + Arc::new(sys.clone()), + sys, + data, + options, + ) + .await + { + Ok(exit_code) => { + let msg = format!( + "[worker] run_with_options completed with exit code: {}\n", + exit_code + ); + eprint!("{}", msg); + let _ = std::fs::OpenOptions::new() + .append(true) + .open(&log_path) + .and_then(|mut f| std::io::Write::write_all(&mut f, msg.as_bytes())); + } + Err(error) => { + let error_string = match js_error_downcast_ref(&error) { + Some(js_error) => format_js_error(js_error, None), + None => format!("{:?}", error), + }; + let msg = + format!("[worker] run_with_options error: {}\n", error_string); + eprint!("{}", msg); + let _ = std::fs::OpenOptions::new() + .append(true) + .open(&log_path) + .and_then(|mut f| std::io::Write::write_all(&mut f, msg.as_bytes())); + log::error!( + "{}: {}", + colors::red_bold("error"), + error_string.trim_start_matches("error: ") + ); + } + } + eprintln!("[worker] block_on finished"); + }); + eprintln!("[worker] run_headless_worker returning"); +} + +/// Extract the script path from fork'd process arguments. +/// +/// When `child_process.fork(scriptPath)` is called, the args are translated +/// to Deno CLI args: `["", "run", "-A", "--unstable-...", "script.js", ...]` +/// This function finds the script path (first non-flag arg after "run"). +fn extract_fork_script_path( + args: &[std::ffi::OsString], +) -> Option { + let args: Vec = args + .iter() + .filter_map(|a| a.to_str().map(String::from)) + .collect(); + + // Skip argv[0] (the executable), expect "run" as the subcommand + if args.len() < 3 || args[1] != "run" { + return None; + } + + // Find the first arg after "run" that isn't a flag + for arg in &args[2..] { + if arg.starts_with('-') { + continue; + } + // This is the script path + let path = PathBuf::from(arg); + let path = if path.is_absolute() { + path + } else { + env::current_dir().ok()?.join(path) + }; + return deno_core::url::Url::from_file_path(path).ok(); + } + None +} + +/// Find the embedded data section in this dylib (not the main executable). +fn find_section_in_dylib() -> Result<&'static [u8], AnyError> { + match libsui::find_section_in_current_image("d3n0l4nd") + .context("Failed reading standalone binary section from dylib.") + { + Ok(Some(data)) => Ok(data), + Ok(None) => { + bail!("Could not find standalone binary section in dylib.") + } + Err(err) => Err(err), + } +} + +async fn run_desktop() -> Result<(), AnyError> { + let args: Vec<_> = env::args_os().collect(); + let data = denort::binary::extract_standalone_with_finder( + Cow::Owned(args), + find_section_in_dylib, + )?; + + deno_runtime::deno_telemetry::init( + otel_runtime_config(), + data.metadata.otel_config.clone(), + )?; + denort::init_logging( + data.metadata.log_level, + Some(data.metadata.otel_config.clone()), + ); + denort::load_env_vars(&data.metadata.env_vars_from_env_file); + + // Set DENO_SERVE_ADDRESS so Deno.serve() and Node http servers + // automatically bind to the desktop port. + #[allow(clippy::undocumented_unsafe_blocks)] + unsafe { + std::env::set_var( + "DENO_SERVE_ADDRESS", + format!("tcp:127.0.0.1:{}", DESKTOP_SERVE_PORT), + ); + } + + let sys = if data.metadata.self_extracting.is_some() { + denort::binary::extract_vfs_to_disk(&data.vfs, &data.root_path)?; + // Set CWD to extraction directory so frameworks like Next.js + // can find their build output (e.g. .next/) relative to CWD. + std::env::set_current_dir(&data.root_path)?; + denort::file_system::DenoRtSys::new_self_extracting(data.vfs.clone()) + } else { + denort::file_system::DenoRtSys::new(data.vfs.clone()) + }; + + // Enable HMR if DENO_DESKTOP_HMR is set to a directory path + // (set by `deno compile --desktop --hmr`). + let hmr_watch_dir = env::var("DENO_DESKTOP_HMR").ok().map(PathBuf::from); + + // Framework dev servers handle their own HMR via websocket. + // For non-framework apps, V8-level HMR reloads the webview. + let is_framework_dev = env::var("DENO_DESKTOP_DEV").is_ok(); + + // In dev mode, restore CWD to the source directory so the framework + // dev server watches the original source files, not the extracted VFS. + if is_framework_dev { + if let Ok(source_dir) = env::var("DENO_DESKTOP_HMR") { + std::env::set_current_dir(&source_dir)?; + } + } + + let hmr_on_reload: Option = + if hmr_watch_dir.is_some() && !is_framework_dev { + Some(Box::new(|| { + wef::execute_js("location.reload()"); + })) + } else { + None + }; + + // Desktop extension: provides Deno.desktop.* APIs + let desktop_ext = denort::desktop::init_extension(Box::new(WefDesktopApi)); + + let run_opts = RunOptions { + auto_serve: true, + serve_port: Some(DESKTOP_SERVE_PORT), + serve_host: Some("127.0.0.1".to_string()), + hmr_watch_dir: if is_framework_dev { + None + } else { + hmr_watch_dir + }, + hmr_on_reload, + custom_extensions: vec![desktop_ext], + override_main_module: None, + }; + + // Run the Deno runtime and WEF event loop concurrently. + // We spawn the runtime first, wait for the server to be ready, + // then navigate the webview. + let url = format!("http://127.0.0.1:{}", DESKTOP_SERVE_PORT); + eprintln!("[desktop] starting runtime and wef event loop"); + let run_fut = + denort::run::run_with_options(Arc::new(sys.clone()), sys, data, run_opts); + let wef_fut = wef::run(); + + // Wait for the server to start listening, then navigate the webview. + let navigate_fut = async { + for i in 0..100 { + if tokio::net::TcpStream::connect(("127.0.0.1", DESKTOP_SERVE_PORT)) + .await + .is_ok() + { + log::debug!( + "Server ready after {} attempts, navigating to {}", + i + 1, + &url + ); + wef::navigate(&url); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + log::warn!("Server not ready after 5s, navigating anyway"); + // Fallback: navigate anyway after timeout. + wef::navigate(&url); + }; + + tokio::select! { + result = async { + // Drive the navigate future alongside the runtime. + tokio::join!(navigate_fut, run_fut).1 + } => { + match result { + Ok(exit_code) => { + eprintln!("[desktop] Deno runtime exited with code {}", exit_code); + } + Err(err) => { + eprintln!("[desktop] Deno runtime error: {:?}", err); + return Err(err); + } + } + } + _ = wef_fut => { + eprintln!("[desktop] WEF event loop ended (window closed)"); + } + } + + Ok(()) +} diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index 33d0590ef66018..b6f474b8f0807c 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -273,6 +273,10 @@ impl<'a> DenoCompileBinaryWriter<'a> { &self, compile_flags: &CompileFlags, ) -> Result, AnyError> { + if compile_flags.desktop { + return self.get_desktop_base_binary(compile_flags).await; + } + // Used for testing. // // Phase 2 of the 'min sized' deno compile RFC talks @@ -324,6 +328,71 @@ impl<'a> DenoCompileBinaryWriter<'a> { Ok(base_binary) } + async fn get_desktop_base_binary( + &self, + compile_flags: &CompileFlags, + ) -> Result, AnyError> { + // For development: check DENORT_DESKTOP_BIN env var or look + // for libdenort next to the deno executable. + if let Some(path) = get_dev_desktop_binary_path() { + log::debug!("Resolved libdenort: {}", path.to_string_lossy()); + return std::fs::read(&path).with_context(|| { + format!("Could not find libdenort at '{}'", path.to_string_lossy()) + }); + } + + let target = compile_flags.resolve_target(); + let lib_ext = if target.contains("darwin") { + "dylib" + } else if target.contains("windows") { + "dll" + } else { + "so" + }; + let lib_name = if target.contains("windows") { + format!("denort.{lib_ext}") + } else { + format!("libdenort.{lib_ext}") + }; + let binary_name = format!("libdenort-{target}.zip"); + + let binary_path_suffix = match DENO_VERSION_INFO.release_channel { + ReleaseChannel::Canary => { + format!("canary/{}/{}", DENO_VERSION_INFO.git_hash, binary_name) + } + _ => { + format!("release/v{}/{}", DENO_VERSION_INFO.deno, binary_name) + } + }; + + let download_directory = self.deno_dir.dl_folder_path(); + let binary_path = download_directory.join(&binary_path_suffix); + log::debug!("Resolved libdenort: {}", binary_path.display()); + + let read_file = |path: &Path| -> Result, AnyError> { + std::fs::read(path).with_context(|| format!("Reading {}", path.display())) + }; + let archive_data = if binary_path.exists() { + read_file(&binary_path)? + } else { + self + .download_base_binary(&binary_path, &binary_path_suffix) + .await + .context("Setting up desktop base binary.")? + }; + let temp_dir = tempfile::TempDir::new()?; + let base_binary_path = archive::unpack_into_dir(archive::UnpackArgs { + exe_name: &lib_name, + archive_name: &binary_name, + archive_data: &archive_data, + is_windows: target.contains("windows"), + dest_path: temp_dir.path(), + })?; + let base_binary = read_file(&base_binary_path)?; + drop(temp_dir); + Ok(base_binary) + } + async fn download_base_binary( &self, output_path: &Path, @@ -1263,6 +1332,33 @@ fn get_dev_binary_path() -> Option { }) } +fn get_libdenort_path(deno_exe: PathBuf) -> Option { + let mut libdenort = deno_exe; + if cfg!(target_os = "macos") { + libdenort.set_file_name("libdenort.dylib"); + } else if cfg!(windows) { + libdenort.set_file_name("denort.dll"); + } else { + libdenort.set_file_name("libdenort.so"); + } + libdenort.exists().then(|| libdenort.into_os_string()) +} + +fn get_dev_desktop_binary_path() -> Option { + env::var_os("DENORT_DESKTOP_BIN").or_else(|| { + env::current_exe().ok().and_then(|exec_path| { + if exec_path + .components() + .any(|component| component == Component::Normal("target".as_ref())) + { + get_libdenort_path(exec_path) + } else { + None + } + }) + }) +} + /// This function returns the environment variables specified /// in the passed environment file. fn get_file_env_vars( diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index 9562e4b55593b9..69a54e19f3c927 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -33,10 +33,89 @@ use crate::standalone::binary::WriteBinOptions; use crate::standalone::binary::is_standalone_binary; use crate::util::temp::create_temp_node_modules_dir; +/// Environment variable for the WEF backend executable path. +const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; +/// Default WEF backend paths to search on macOS. +const WEF_BACKEND_SEARCH_PATHS: &[&str] = + &["../wef/webview/build/wef_webview.app/Contents/MacOS/wef_webview"]; + pub async fn compile( mut flags: Flags, - compile_flags: CompileFlags, + mut compile_flags: CompileFlags, ) -> Result<(), AnyError> { + // Desktop framework detection: when --desktop is used and the source is + // "." (a directory), detect the framework and generate the entrypoint. + let _desktop_entrypoint_file = if compile_flags.desktop + && compile_flags.source_file == "." + { + let cwd = flags + .initial_cwd + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap()); + if let Some(detection) = super::framework::detect_framework(&cwd)? { + let entrypoint_code = detection.entrypoint_code; + let includes = detection.include_paths; + log::info!("Detected {} framework", detection.name); + // Enable CJS detection for Node-based frameworks. + flags.unstable_config.detect_cjs = true; + // Write a temporary entrypoint file. + let entrypoint_path = cwd.join(".deno_desktop_entry.ts"); + std::fs::write(&entrypoint_path, entrypoint_code)?; + let entrypoint_str = entrypoint_path.display().to_string(); + compile_flags.source_file = entrypoint_str.clone(); + // Also update the source in flags.subcommand so resolve_main_module sees it. + if let crate::args::DenoSubcommand::Compile(ref mut cf) = flags.subcommand + { + cf.source_file = entrypoint_str; + cf.self_extracting = true; + // Use the project directory name as the output binary name. + if cf.output.is_none() { + if let Some(dir_name) = cwd.file_name() { + cf.output = Some(dir_name.to_string_lossy().into_owned()); + } + } + } + if compile_flags.output.is_none() { + if let Some(dir_name) = cwd.file_name() { + compile_flags.output = Some(dir_name.to_string_lossy().into_owned()); + } + } + // Auto-enable self-extracting for framework apps. + compile_flags.self_extracting = true; + // Add framework build output to includes. + for inc in includes { + if !compile_flags.include.contains(&inc) { + compile_flags.include.push(inc.clone()); + } + if let crate::args::DenoSubcommand::Compile(ref mut cf) = + flags.subcommand + { + if !cf.include.contains(&inc) { + cf.include.push(inc); + } + } + } + Some(entrypoint_path) + } else { + bail!( + "Could not detect a supported framework in the current directory.\nSupported frameworks: Next.js, Astro\nProvide an explicit entrypoint instead of \".\"." + ); + } + } else { + None + }; + + // Clean up temp entrypoint on exit. + struct CleanupGuard(Option); + impl Drop for CleanupGuard { + fn drop(&mut self) { + if let Some(ref path) = self.0 { + let _ = std::fs::remove_file(path); + } + } + } + let _cleanup = CleanupGuard(_desktop_entrypoint_file); + // use a temporary directory with a node_modules folder when the user // specifies an npm package for better compatibility let _temp_dir = @@ -203,6 +282,13 @@ async fn compile_binary( return Err(err); } + // When --desktop --hmr, compile and immediately run with HMR enabled. + if compile_flags.desktop && compile_flags.hmr { + let cwd = cli_options.initial_cwd(); + let framework = super::framework::detect_framework(cwd)?; + run_desktop_hmr(&output_path, cwd, framework.as_ref()).await?; + } + Ok(()) } @@ -323,6 +409,115 @@ async fn compile_eszip( Ok(()) } +/// Find the WEF backend executable. +fn find_wef_backend() -> Result { + if let Ok(path) = std::env::var(WEF_BACKEND_ENV) { + let p = PathBuf::from(&path); + if p.exists() { + return Ok(p); + } + bail!( + "WEF backend not found at {} (set via {})", + path, + WEF_BACKEND_ENV + ); + } + + // Search relative to the deno executable for development. + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + for search_path in WEF_BACKEND_SEARCH_PATHS { + let p = PathBuf::from(search_path); + if p.exists() { + return Ok(p); + } + if let Some(exe_dir) = &exe_dir { + let p = exe_dir.join(search_path); + if p.exists() { + return Ok(p); + } + } + } + + bail!( + "WEF backend not found. Set {} to the path of the WEF backend executable.", + WEF_BACKEND_ENV + ) +} + +/// Launch the desktop app with HMR enabled after compilation. +/// +/// The compiled dylib contains the entrypoint with both production and dev +/// code paths. The `dev_entrypoint_code` is parsed as KEY=VALUE env vars +/// that switch the entrypoint to dev mode (e.g. DENO_DESKTOP_DEV=1). +/// +/// Framework dev servers provide HMR via websocket. Since they run inside +/// the Deno desktop runtime, `Deno.desktop` APIs remain available. +/// `child_process.fork()` works because forked workers use +/// `override_main_module` to run the target script instead of the +/// embedded entrypoint. +async fn run_desktop_hmr( + dylib_path: &Path, + source_dir: &Path, + framework: Option<&super::framework::FrameworkDetection>, +) -> Result<(), AnyError> { + let wef_backend = find_wef_backend()?; + let dylib_abs = dylib_path + .canonicalize() + .unwrap_or(dylib_path.to_path_buf()); + let source_abs = source_dir + .canonicalize() + .unwrap_or(source_dir.to_path_buf()); + + if let Some(fw) = framework { + if fw.dev_entrypoint_code.is_some() { + log::info!( + "{} {} dev server with HMR in desktop mode", + colors::green("Running"), + fw.name, + ); + } + } + + log::info!( + "{} desktop app with HMR (watching {})", + colors::green("Running"), + source_abs.display(), + ); + + let mut cmd = std::process::Command::new(&wef_backend); + cmd + .arg("--runtime") + .arg(&dylib_abs) + .env("WEF_RUNTIME_PATH", &dylib_abs) + .env("DENO_DESKTOP_HMR", &source_abs) + .current_dir(&source_abs); + + // Set framework dev env vars (e.g. DENO_DESKTOP_DEV=1) so the + // embedded entrypoint branches into dev mode. + if let Some(fw) = framework { + if let Some(ref dev_env) = fw.dev_entrypoint_code { + for line in dev_env.lines() { + if let Some((key, value)) = line.split_once('=') { + cmd.env(key.trim(), value.trim()); + } + } + } + } + + let mut child = cmd.spawn().with_context(|| { + format!("Failed to launch WEF backend: {}", wef_backend.display()) + })?; + + let status = child.wait().context("Failed waiting for WEF backend")?; + + if !status.success() { + bail!("WEF backend exited with status: {}", status); + } + Ok(()) +} + /// This function writes out a final binary to specified path. If output path /// is not already standalone binary it will return error instead. fn validate_output_path(output_path: &Path) -> Result<(), AnyError> { @@ -522,10 +717,35 @@ async fn resolve_compile_executable_output_path( output_path.ok_or_else(|| anyhow!( "An executable name was not provided. One could not be inferred from the URL. Aborting.", )).map(|output_path| { - get_os_specific_filepath(output_path, &compile_flags.target) + if compile_flags.desktop { + get_desktop_specific_filepath(output_path, &compile_flags.target) + } else { + get_os_specific_filepath(output_path, &compile_flags.target) + } }) } +fn get_desktop_specific_filepath( + output: PathBuf, + target: &Option, +) -> PathBuf { + let is_windows = match target { + Some(target) => target.contains("windows"), + None => cfg!(windows), + }; + let is_darwin = match target { + Some(target) => target.contains("darwin"), + None => cfg!(target_os = "macos"), + }; + if is_windows { + output.with_extension("dll") + } else if is_darwin { + output.with_extension("dylib") + } else { + output.with_extension("so") + } +} + fn get_os_specific_filepath( output: PathBuf, target: &Option, @@ -575,6 +795,8 @@ mod test { exclude: Default::default(), eszip: true, self_extracting: false, + desktop: false, + hmr: false, }, &resolve_cwd(None).unwrap(), ) @@ -607,6 +829,8 @@ mod test { no_terminal: false, eszip: true, self_extracting: false, + desktop: false, + hmr: false, }, &resolve_cwd(None).unwrap(), ) diff --git a/cli/tools/framework.rs b/cli/tools/framework.rs new file mode 100644 index 00000000000000..034c7412bfad5e --- /dev/null +++ b/cli/tools/framework.rs @@ -0,0 +1,428 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Framework detection and desktop entrypoint generation for `deno compile --desktop`. +//! +//! Detects web frameworks (Next.js, Astro, Remix, SvelteKit, Nuxt, Fresh, Vite SSR) +//! and generates the appropriate entrypoint and include paths so that +//! `deno compile --desktop .` just works. + +use std::path::Path; + +use deno_core::error::AnyError; +use deno_core::serde_json; + +/// Result of framework detection. +pub struct FrameworkDetection { + /// Name of the detected framework (for display). + pub name: &'static str, + /// Generated entrypoint TypeScript/JavaScript code (production). + pub entrypoint_code: String, + /// Directories to include in the compiled binary. + pub include_paths: Vec, + /// Dev server entrypoint code for HMR mode. + /// When set, `deno compile --desktop --hmr .` will use this entrypoint + /// instead of the production one, running the framework's dev server + /// inside the Deno desktop runtime so that `Deno.desktop` APIs work. + pub dev_entrypoint_code: Option, +} + +/// Detect a web framework in the given directory. +/// +/// Detection priority (matches deployng): +/// 1. Config-file based detection (highest priority) +/// 2. Package.json dependency-based detection +/// 3. deno.json import-based detection +pub fn detect_framework( + dir: &Path, +) -> Result, AnyError> { + // --- Config-file based detection (highest priority) --- + + // Next.js: next.config.{js,mjs,ts} + if has_config_file(dir, "next.config") { + return Ok(Some(detect_nextjs(dir)?)); + } + + // Fresh: fresh.gen.ts or _fresh/ + if dir.join("fresh.gen.ts").exists() || dir.join("_fresh").is_dir() { + return Ok(Some(detect_fresh(dir))); + } + + // Astro: astro.config.{mjs,ts,js} + if has_config_file(dir, "astro.config") { + return Ok(Some(detect_astro(dir))); + } + + // Nuxt: nuxt.config.{ts,js,mjs} + if has_config_file(dir, "nuxt.config") { + return Ok(Some(detect_nuxt(dir))); + } + + // SvelteKit: svelte.config.{js,ts} + if has_config_file(dir, "svelte.config") { + return Ok(Some(detect_sveltekit(dir))); + } + + // --- Package.json dependency-based detection --- + if let Some(deps) = read_package_deps(dir) { + // Remix + if deps.has("@remix-run/react") || deps.has_dev("@remix-run/dev") { + return Ok(Some(detect_remix(dir))); + } + + // SolidStart + if deps.has("@solidjs/start") { + return Ok(Some(detect_nitro_framework(dir, "SolidStart"))); + } + + // TanStack Start + if deps.has("@tanstack/react-start") || deps.has("@tanstack/solid-start") { + return Ok(Some(detect_nitro_framework(dir, "TanStack Start"))); + } + } + + // --- Vite SSR (lower priority, needs a server.js) --- + if has_config_file(dir, "vite.config") { + if let Some(detection) = detect_vite_ssr(dir) { + return Ok(Some(detection)); + } + } + + // --- deno.json import-based detection --- + if let Some(imports) = read_deno_json_imports(dir) { + if imports + .iter() + .any(|i| i.starts_with("fresh") || i.starts_with("@fresh/core")) + { + return Ok(Some(detect_fresh(dir))); + } + } + + Ok(None) +} + +// --- Framework-specific detection --- + +fn detect_nextjs(dir: &Path) -> Result { + let version = detect_package_version(dir, "next").unwrap_or(15); + let entrypoint = format!( + r#"// @ts-nocheck +import {{ nextStart }} from "npm:next@^{version}/dist/cli/next-start.js"; +import {{ nextDev }} from "npm:next@^{version}/dist/cli/next-dev.js"; +globalThis.addEventListener("unhandledrejection", (e) => {{ + console.error("[desktop-entrypoint] Unhandled rejection:", e.reason); + if (e.reason?.stack) console.error("[desktop-entrypoint] Stack:", e.reason.stack); +}}); +// Guard: skip for forked workers (child_process.fork sets NODE_CHANNEL_FD). +// Workers use override_main_module to run their target script directly. +if (!Deno.env.get("NODE_CHANNEL_FD")) {{ + if (Deno.env.get("DENO_DESKTOP_DEV")) {{ + await nextDev({{ port: 41520, hostname: "127.0.0.1" }}, "default", "."); + // Keep alive after dev server starts — nextDev resolves once the + // worker is ready, but we need the parent to stay alive for the + // child process IPC and restart handling. + await new Promise(() => {{}}); + }} else {{ + await nextStart({{ hostname: "127.0.0.1" }}, "."); + }} +}} +"#, + ); + Ok(FrameworkDetection { + name: "Next.js", + entrypoint_code: entrypoint, + include_paths: vec![".next".into()], + dev_entrypoint_code: Some("DENO_DESKTOP_DEV=1".into()), + }) +} + +fn detect_astro(dir: &Path) -> FrameworkDetection { + let dev_entrypoint = Some( + r#"// @ts-nocheck +import { cli } from "npm:astro"; +cli(["dev", "--port", "4321", "--host", "127.0.0.1"]); +"# + .into(), + ); + // Check if it's SSR (has dist/server/entry.mjs) or static + let is_ssr = dir.join("dist/server/entry.mjs").exists(); + if is_ssr { + FrameworkDetection { + name: "Astro", + entrypoint_code: "// @ts-nocheck\nimport \"./dist/server/entry.mjs\";\n" + .into(), + include_paths: vec!["dist".into()], + dev_entrypoint_code: dev_entrypoint, + } + } else { + let mut d = static_file_server_detection("Astro", "dist"); + d.dev_entrypoint_code = dev_entrypoint; + d + } +} + +fn detect_fresh(dir: &Path) -> FrameworkDetection { + // Fresh 2.x uses _fresh/server.js, older uses main.ts + let is_fresh2 = dir.join("_fresh/server.js").exists(); + if is_fresh2 { + // Fresh 2.x: production imports _fresh/server.js, dev starts Vite + FrameworkDetection { + name: "Fresh", + entrypoint_code: r#"// @ts-nocheck +import { createServer } from "npm:vite"; +import { fresh } from "@fresh/plugin-vite"; +if (Deno.env.get("DENO_DESKTOP_DEV")) { + const server = await createServer({ + configFile: false, + plugins: [ + fresh(), + // Workaround: Fresh's dev server SSR can emit bare `fresh-island::` + // specifiers in inline scripts after snapshot re-crawl. Browsers + // treat the colon as a URL scheme and block it as cross-origin. + // Rewrite to /@id/ so Vite's module resolver handles it. + { + name: "fresh-desktop:fixup", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const origWrite = res.write.bind(res); + const origEnd = res.end.bind(res); + const chunks = []; + res.write = (chunk, ...args) => { chunks.push(Buffer.from(chunk)); return true; }; + res.end = (chunk, ...args) => { + if (chunk) chunks.push(Buffer.from(chunk)); + let body = Buffer.concat(chunks).toString(); + const ct = res.getHeader("content-type") || ""; + if (ct.includes("text/html")) { + body = body.replace(/from "(fresh-(?:island|route)::)/g, 'from "/@id/$1'); + } + res.setHeader("content-length", Buffer.byteLength(body)); + origEnd(body); + }; + next(); + }); + }, + }, + ], + server: { port: 41520, host: "127.0.0.1" }, + }); + await server.listen(); + await new Promise(() => {}); +} else { + await import("./_fresh/server.js"); +} +"# + .into(), + include_paths: vec!["_fresh".into()], + dev_entrypoint_code: Some("DENO_DESKTOP_DEV=1".into()), + } + } else { + // Fresh 1.x + FrameworkDetection { + name: "Fresh", + entrypoint_code: "// @ts-nocheck\nimport \"./main.ts\";\n".into(), + include_paths: vec!["_fresh".into()], + dev_entrypoint_code: Some( + "// @ts-nocheck\nimport \"./dev.ts\";\n".into(), + ), + } + } +} + +fn detect_remix(_dir: &Path) -> FrameworkDetection { + FrameworkDetection { + name: "Remix", + entrypoint_code: + "// @ts-nocheck\nimport \"./node_modules/.bin/remix-serve\";\n".into(), + include_paths: vec!["build".into()], + dev_entrypoint_code: Some( + r#"// @ts-nocheck +import { run } from "npm:@remix-run/dev/dist/cli/run.js"; +run(["dev"]); +"# + .into(), + ), + } +} + +fn detect_nuxt(dir: &Path) -> FrameworkDetection { + detect_nitro_framework(dir, "Nuxt") +} + +fn detect_sveltekit(dir: &Path) -> FrameworkDetection { + // SvelteKit with @deno/svelte-adapter outputs to .deno-deploy/ + let prod_entrypoint = if dir.join(".deno-deploy/server.ts").exists() { + "// @ts-nocheck\nimport \"./.deno-deploy/server.ts\";\n".to_string() + } else { + // Nitro-based output + let ext = if dir.join(".output/server/index.ts").exists() { + "ts" + } else { + "mjs" + }; + format!("// @ts-nocheck\nimport \"./.output/server/index.{ext}\";\n") + }; + let include_paths = if dir.join(".deno-deploy/server.ts").exists() { + vec![".deno-deploy".into()] + } else { + vec![".output".into()] + }; + FrameworkDetection { + name: "SvelteKit", + entrypoint_code: format!( + r#"// @ts-nocheck +import {{ createServer }} from "npm:vite"; +if (Deno.env.get("DENO_DESKTOP_DEV")) {{ + const server = await createServer({{ + server: {{ port: 41520, host: "127.0.0.1" }}, + }}); + await server.listen(); + await new Promise(() => {{}}); +}} else {{ + {prod_entrypoint} +}} +"# + ), + include_paths, + dev_entrypoint_code: Some("DENO_DESKTOP_DEV=1".into()), + } +} + +/// Nuxt, SolidStart, TanStack Start all use Nitro with the `deno_server` +/// preset, outputting to `.output/server/index.{ts,mjs}`. +fn detect_nitro_framework( + dir: &Path, + name: &'static str, +) -> FrameworkDetection { + let ext = if dir.join(".output/server/index.ts").exists() { + "ts" + } else { + "mjs" + }; + // Nuxt uses nuxi, others use nitro dev directly + let dev_entrypoint = if name == "Nuxt" { + Some( + r#"// @ts-nocheck +import { runCommand } from "npm:nuxi/cli"; +runCommand("dev", ["--port", "3000", "--host", "127.0.0.1"]); +"# + .into(), + ) + } else { + None + }; + FrameworkDetection { + name, + entrypoint_code: format!( + "// @ts-nocheck\nimport \"./.output/server/index.{ext}\";\n" + ), + include_paths: vec![".output".into()], + dev_entrypoint_code: dev_entrypoint, + } +} + +fn detect_vite_ssr(dir: &Path) -> Option { + let server_file = ["server.js", "server.ts", "server.mjs"] + .iter() + .find(|f| dir.join(f).exists())?; + Some(FrameworkDetection { + name: "Vite", + entrypoint_code: format!( + "// @ts-nocheck\nimport \"./{server_file}\";\n" + ), + include_paths: vec!["dist".into()], + dev_entrypoint_code: Some( + r#"// @ts-nocheck +import { createServer } from "npm:vite"; +const server = await createServer({ server: { port: 5173, host: "127.0.0.1" } }); +await server.listen(); +"# + .into(), + ), + }) +} + +/// Generate a detection result that serves a static directory using Deno.serve. +fn static_file_server_detection( + name: &'static str, + static_dir: &str, +) -> FrameworkDetection { + FrameworkDetection { + name, + entrypoint_code: format!( + r#"// @ts-nocheck +import {{ serveDir }} from "jsr:@std/http@^1/file-server"; +Deno.serve((req) => serveDir(req, {{ fsRoot: "./{static_dir}" }})); +"# + ), + include_paths: vec![static_dir.into()], + dev_entrypoint_code: None, + } +} + +// --- Helpers --- + +/// Check if a config file exists with any common extension. +fn has_config_file(dir: &Path, base_name: &str) -> bool { + ["js", "mjs", "ts", "mts", "cjs"] + .iter() + .any(|ext| dir.join(format!("{base_name}.{ext}")).exists()) +} + +/// Read package.json dependencies. +fn read_package_deps(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("package.json")).ok()?; + let pkg: serde_json::Value = serde_json::from_str(&content).ok()?; + Some(PackageDeps { + deps: pkg + .get("dependencies") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())), + dev_deps: pkg + .get("devDependencies") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())), + }) +} + +struct PackageDeps { + deps: serde_json::Value, + dev_deps: serde_json::Value, +} + +impl PackageDeps { + fn has(&self, name: &str) -> bool { + self.deps.get(name).is_some() + } + + fn has_dev(&self, name: &str) -> bool { + self.dev_deps.get(name).is_some() + } +} + +/// Extract the major version number from a package.json dependency. +fn detect_package_version(dir: &Path, package: &str) -> Option { + let content = std::fs::read_to_string(dir.join("package.json")).ok()?; + let pkg: serde_json::Value = serde_json::from_str(&content).ok()?; + let ver_str = pkg + .get("dependencies") + .and_then(|d| d.get(package)) + .or_else(|| pkg.get("devDependencies").and_then(|d| d.get(package)))? + .as_str()?; + // Extract major version from "^16.1.6", "~15.0.0", "14.2.3", etc. + ver_str + .chars() + .skip_while(|c: &char| !c.is_ascii_digit()) + .take_while(|c: &char| c.is_ascii_digit()) + .collect::() + .parse() + .ok() +} + +/// Read the `imports` keys from deno.json. +fn read_deno_json_imports(dir: &Path) -> Option> { + let content = std::fs::read_to_string(dir.join("deno.json")) + .or_else(|_| std::fs::read_to_string(dir.join("deno.jsonc"))) + .ok()?; + let config: serde_json::Value = serde_json::from_str(&content).ok()?; + let imports = config.get("imports")?.as_object()?; + Some(imports.keys().cloned().collect()) +} diff --git a/cli/tools/mod.rs b/cli/tools/mod.rs index fca9414a387ae6..1c9f54cd3acdc5 100644 --- a/cli/tools/mod.rs +++ b/cli/tools/mod.rs @@ -9,6 +9,7 @@ pub mod coverage; pub mod deploy; pub mod doc; pub mod fmt; +pub mod framework; pub mod info; pub mod init; pub mod installer; diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index a17750dd2df681..ec76c0d602459d 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -539,6 +539,13 @@ const NOT_IMPORTED_OPS = [ "op_set_exit_code", "op_napi_open", + // Related to `Deno.desktop` API (deno compile --desktop) + "op_desktop_set_title", + "op_desktop_set_window_size", + "op_desktop_navigate", + "op_desktop_execute_js", + "op_desktop_quit", + // deno deploy subcommand "op_deploy_token_get", "op_deploy_token_set", From e3c5eba2d300bd1b0275b30debc715a0c3e3c1cb Mon Sep 17 00:00:00 2001 From: Divy Srivastava Date: Tue, 10 Mar 2026 22:03:17 +0530 Subject: [PATCH 002/137] optimizationx --- cli/args/flags.rs | 12 + cli/rt_desktop/lib.rs | 46 ++-- cli/standalone/binary.rs | 13 +- cli/tools/compile.rs | 458 ++++++++++++++++++++++++++++++++++++- cli/tools/framework.rs | 3 +- cli/tools/installer/mod.rs | 3 + 6 files changed, 508 insertions(+), 27 deletions(-) diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 1cd53871f2d539..1d84a774ccc180 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -177,6 +177,7 @@ pub struct CompileFlags { pub self_extracting: bool, pub desktop: bool, pub hmr: bool, + pub backend: Option, } impl CompileFlags { @@ -2832,6 +2833,15 @@ On the first invocation of `deno compile`, Deno will download the relevant binar .action(ArgAction::SetTrue) .help_heading(COMPILE_HEADING), ) + .arg( + Arg::new("backend") + .long("backend") + .help("WEF backend to use for the desktop app") + .value_parser(["webview", "cef", "servo"]) + .default_value("webview") + .requires("desktop") + .help_heading(COMPILE_HEADING), + ) .arg(executable_ext_arg()) .arg(env_file_arg()) .arg( @@ -6154,6 +6164,7 @@ fn compile_parse( let self_extracting = matches.get_flag("self-extracting"); let desktop = matches.get_flag("desktop"); let hmr = matches.get_flag("hmr"); + let backend = matches.remove_one::("backend"); let include = matches .remove_many::("include") .map(|f| f.collect::>()) @@ -6179,6 +6190,7 @@ fn compile_parse( self_extracting, desktop, hmr, + backend, }); Ok(()) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index bf1822c7da6d51..b002750187930e 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -389,25 +389,43 @@ async fn run_desktop() -> Result<(), AnyError> { denort::run::run_with_options(Arc::new(sys.clone()), sys, data, run_opts); let wef_fut = wef::run(); - // Wait for the server to start listening, then navigate the webview. + // Wait for the server to be ready, then navigate the webview. + // Do a full HTTP request instead of just a TCP connect — frameworks + // like Vite accept connections before they're ready to serve. let navigate_fut = async { - for i in 0..100 { - if tokio::net::TcpStream::connect(("127.0.0.1", DESKTOP_SERVE_PORT)) - .await - .is_ok() + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + for i in 0..60 { + if let Ok(mut stream) = + tokio::net::TcpStream::connect(("127.0.0.1", DESKTOP_SERVE_PORT)).await { - log::debug!( - "Server ready after {} attempts, navigating to {}", - i + 1, - &url + let req = format!( + "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", + DESKTOP_SERVE_PORT ); - wef::navigate(&url); - return; + if stream.write_all(req.as_bytes()).await.is_ok() { + let mut buf = vec![0u8; 256]; + if let Ok(n) = stream.read(&mut buf).await { + let response = String::from_utf8_lossy(&buf[..n]); + if response.starts_with("HTTP/1.1 2") + || response.starts_with("HTTP/1.1 3") + || response.starts_with("HTTP/1.0 2") + || response.starts_with("HTTP/1.0 3") + { + log::debug!( + "Server ready after {} attempts, navigating to {}", + i + 1, + &url + ); + wef::navigate(&url); + return; + } + } + } } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + tokio::time::sleep(std::time::Duration::from_millis(250)).await; } - log::warn!("Server not ready after 5s, navigating anyway"); - // Fallback: navigate anyway after timeout. + log::warn!("Server not ready after 15s, navigating anyway"); wef::navigate(&url); }; diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index b6f474b8f0807c..e02dd16ed156fd 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -259,7 +259,8 @@ impl<'a> DenoCompileBinaryWriter<'a> { } if options.compile_flags.icon.is_some() { let target = options.compile_flags.resolve_target(); - if !target.contains("windows") { + // Desktop builds handle icons during app bundle packaging. + if !target.contains("windows") && !options.compile_flags.desktop { bail!( "The `--icon` flag is only available when targeting Windows (current: {})", target, @@ -1351,7 +1352,15 @@ fn get_dev_desktop_binary_path() -> Option { .components() .any(|component| component == Component::Normal("target".as_ref())) { - get_libdenort_path(exec_path) + // Prefer release libdenort (optimized) over debug. + let target_dir = exec_path + .parent() + .and_then(|p| p.parent()); + target_dir + .and_then(|d| { + get_libdenort_path(d.join("release").join("libdenort.dylib")) + }) + .or_else(|| get_libdenort_path(exec_path.clone())) } else { None } diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index 69a54e19f3c927..294ee93d958bac 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -35,9 +35,27 @@ use crate::util::temp::create_temp_node_modules_dir; /// Environment variable for the WEF backend executable path. const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; -/// Default WEF backend paths to search on macOS. -const WEF_BACKEND_SEARCH_PATHS: &[&str] = - &["../wef/webview/build/wef_webview.app/Contents/MacOS/wef_webview"]; + +/// Find the deno repo root from the current exe path. +/// Dev builds live at `/target/{debug,release}/deno`. +/// Resolve WEF backend binary search paths based on the chosen backend. +fn wef_backend_search_paths(backend: &str) -> Vec { + match backend { + "cef" => vec![ + PathBuf::from("/Users/divy/gh/wef/result/Applications/wef.app/Contents/MacOS/wef"), + PathBuf::from("/Users/divy/gh/wef/cef/build/wef.app/Contents/MacOS/wef"), + ], + "servo" => vec![ + PathBuf::from("/Users/divy/gh/wef/servo/target/release/wef_servo"), + PathBuf::from("/Users/divy/gh/wef/servo/target/debug/wef_servo"), + ], + _ => vec![ + PathBuf::from("/Users/divy/gh/wef/result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview"), + PathBuf::from("/Users/divy/gh/wef/result/Applications/wef_webview.app/Contents/MacOS/wef_webview"), + PathBuf::from("/Users/divy/gh/wef/webview/build/wef_webview.app/Contents/MacOS/wef_webview"), + ], + } +} pub async fn compile( mut flags: Flags, @@ -286,9 +304,425 @@ async fn compile_binary( if compile_flags.desktop && compile_flags.hmr { let cwd = cli_options.initial_cwd(); let framework = super::framework::detect_framework(cwd)?; - run_desktop_hmr(&output_path, cwd, framework.as_ref()).await?; + let backend = compile_flags.backend.as_deref().unwrap_or("webview"); + run_desktop_hmr(&output_path, cwd, framework.as_ref(), backend) + .await?; + } else if compile_flags.desktop { + // Package the dylib into a platform-specific app bundle. + let bundle_path = + package_desktop_app(&output_path, &compile_flags, cli_options)?; + let initial_cwd = + deno_path_util::url_from_directory_path(cli_options.initial_cwd())?; + log::info!( + "{} {}", + colors::green("Bundle"), + if let Ok(bundle_url) = deno_path_util::url_from_file_path(&bundle_path) { + crate::util::path::relative_specifier_path_for_display( + &initial_cwd, + &bundle_url, + ) + } else { + bundle_path.display().to_string() + } + ); + } + + Ok(()) +} + +/// Package a compiled desktop dylib into a platform-specific app bundle. +fn package_desktop_app( + dylib_path: &Path, + compile_flags: &CompileFlags, + cli_options: &CliOptions, +) -> Result { + let is_darwin = match &compile_flags.target { + Some(target) => target.contains("darwin"), + None => cfg!(target_os = "macos"), + }; + + if is_darwin { + package_macos_app_bundle(dylib_path, compile_flags, cli_options) + } else { + // TODO(divy): Windows and Linux packaging + Ok(dylib_path.to_path_buf()) + } +} + +/// Environment variable pointing to a WEF backend .app bundle. +const WEF_BACKEND_APP_ENV: &str = "WEF_BACKEND_APP"; + +/// Resolve WEF backend .app search paths based on the chosen backend. +fn wef_backend_app_search_paths(backend: &str) -> Vec { + match backend { + "cef" => vec![ + "/Users/divy/gh/wef/result/Applications/wef.app".to_string(), + "/Users/divy/gh/wef/cef/build/wef.app".to_string(), + ], + "servo" => vec![], // Servo is not an .app bundle + _ => vec![ + "/Users/divy/gh/wef/result-1/Applications/wef_webview.app".to_string(), + "/Users/divy/gh/wef/result/Applications/wef_webview.app".to_string(), + "/Users/divy/gh/wef/webview/build/wef_webview.app".to_string(), + ], + } +} + +/// Find the WEF backend .app bundle directory. +fn find_wef_backend_app_bundle(backend: &str) -> Result { + // Check explicit env var first. + if let Ok(path) = std::env::var(WEF_BACKEND_APP_ENV) { + let p = PathBuf::from(&path); + if p.exists() && p.extension().map_or(false, |e| e == "app") { + return Ok(p); + } + bail!( + "WEF backend .app not found at {} (set via {})", + path, + WEF_BACKEND_APP_ENV + ); + } + + // Search well-known paths. + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + for search_path in wef_backend_app_search_paths(backend) { + let p = PathBuf::from(&search_path); + if p.exists() { + return Ok(p); + } + if let Some(exe_dir) = &exe_dir { + let p = exe_dir.join(&search_path); + if p.exists() { + return Ok(p); + } + } + } + + // Derive from the WEF backend binary path (walk up to .app). + if let Ok(backend_binary) = find_wef_backend(backend) { + let mut current = backend_binary.as_path(); + while let Some(parent) = current.parent() { + if parent.extension().map_or(false, |e| e == "app") { + return Ok(parent.to_path_buf()); + } + current = parent; + } + } + + bail!( + "WEF backend '{}' .app bundle not found. Set {} to the path of the WEF .app bundle.", + backend, + WEF_BACKEND_APP_ENV + ) +} + +/// Extract a string value from a plist XML by key. +fn extract_plist_string(plist_xml: &str, key: &str) -> Option { + let key_tag = format!("{}", key); + let pos = plist_xml.find(&key_tag)?; + let after_key = &plist_xml[pos + key_tag.len()..]; + let start = after_key.find("")? + "".len(); + let end = after_key.find("")?; + Some(after_key[start..end].to_string()) +} + +/// Create a macOS .app bundle from the compiled desktop dylib. +/// +/// Bundle structure: +/// ```text +/// AppName.app/ +/// Contents/ +/// Info.plist +/// MacOS/ +/// AppName (launcher script) +/// wef_webview (WEF backend binary) +/// libapp.dylib (compiled Deno runtime + user code) +/// Resources/ +/// AppIcon.icns (optional) +/// ``` +fn package_macos_app_bundle( + dylib_path: &Path, + compile_flags: &CompileFlags, + cli_options: &CliOptions, +) -> Result { + let app_name = dylib_path + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + let app_bundle = dylib_path + .parent() + .unwrap() + .join(format!("{}.app", app_name)); + + // Find the WEF backend .app and its main executable. + let backend = compile_flags.backend.as_deref().unwrap_or("webview"); + let wef_app = find_wef_backend_app_bundle(backend)?; + let wef_plist_path = wef_app.join("Contents/Info.plist"); + let wef_executable_name = if wef_plist_path.exists() { + let plist_content = std::fs::read_to_string(&wef_plist_path)?; + extract_plist_string(&plist_content, "CFBundleExecutable") + .unwrap_or_else(|| "wef_webview".to_string()) + } else { + "wef_webview".to_string() + }; + let wef_binary = wef_app.join("Contents/MacOS").join(&wef_executable_name); + if !wef_binary.exists() { + bail!( + "WEF backend executable not found at '{}'", + wef_binary.display() + ); + } + + // Remove existing bundle. + if app_bundle.exists() { + std::fs::remove_dir_all(&app_bundle)?; + } + + // Copy the entire WEF .app as the shell (CEF needs Frameworks/, Resources/, etc.). + copy_dir_all(&wef_app, &app_bundle)?; + + let contents_dir = app_bundle.join("Contents"); + let macos_dir = contents_dir.join("MacOS"); + let resources_dir = contents_dir.join("Resources"); + std::fs::create_dir_all(&resources_dir)?; + + // Strip unnecessary bulk from the CEF framework. + strip_cef_bloat(&contents_dir); + + // Copy the compiled dylib. + let dylib_filename = dylib_path.file_name().unwrap(); + std::fs::copy(dylib_path, macos_dir.join(dylib_filename))?; + + // Create launcher script as the main executable. + let launcher_path = macos_dir.join(&app_name); + std::fs::write( + &launcher_path, + format!( + "#!/bin/bash\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + exec \"$DIR/{wef_binary}\" --runtime \"$DIR/{dylib}\" \"$@\"\n", + wef_binary = wef_executable_name, + dylib = dylib_filename.to_string_lossy(), + ), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &launcher_path, + std::fs::Permissions::from_mode(0o755), + )?; + } + + // Generate Info.plist. + let has_icon = compile_flags.icon.is_some(); + let bundle_id = app_name.to_lowercase().replace(' ', "-"); + let info_plist = format!( + r#" + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + {app_name} + CFBundleIconFile + {icon_file} + CFBundleIdentifier + com.deno.desktop.{bundle_id} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {app_name} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 10.15 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + +"#, + app_name = app_name, + bundle_id = bundle_id, + icon_file = if has_icon { "AppIcon" } else { "" }, + ); + std::fs::write(contents_dir.join("Info.plist"), info_plist)?; + + // Handle icon. + if let Some(ref icon) = compile_flags.icon { + let icon_path = cli_options.initial_cwd().join(icon); + if icon_path.exists() { + let dest = resources_dir.join("AppIcon.icns"); + match icon_path.extension().and_then(|e| e.to_str()) { + Some("icns") => { + std::fs::copy(&icon_path, &dest)?; + } + Some("png") => { + convert_png_to_icns(&icon_path, &dest)?; + } + _ => { + log::warn!( + "Icon '{}' is not .icns or .png, skipping", + icon_path.display() + ); + } + } + } else { + log::warn!("Icon '{}' not found, skipping", icon_path.display()); + } + } + + // Remove the standalone dylib (it's now inside the .app). + let _ = std::fs::remove_file(dylib_path); + + Ok(app_bundle) +} + +/// Convert a PNG image to macOS .icns format using `sips` and `iconutil`. +fn convert_png_to_icns( + png_path: &Path, + icns_path: &Path, +) -> Result<(), AnyError> { + let iconset_dir = icns_path.with_extension("iconset"); + std::fs::create_dir_all(&iconset_dir)?; + + let sizes: &[(u32, &str)] = &[ + (16, "icon_16x16.png"), + (32, "icon_16x16@2x.png"), + (32, "icon_32x32.png"), + (64, "icon_32x32@2x.png"), + (128, "icon_128x128.png"), + (256, "icon_128x128@2x.png"), + (256, "icon_256x256.png"), + (512, "icon_256x256@2x.png"), + (512, "icon_512x512.png"), + (1024, "icon_512x512@2x.png"), + ]; + + for (size, name) in sizes { + let dest = iconset_dir.join(name); + let status = std::process::Command::new("sips") + .args([ + "-z", + &size.to_string(), + &size.to_string(), + &png_path.display().to_string(), + "--out", + &dest.display().to_string(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + if status.map_or(true, |s| !s.success()) { + std::fs::copy(png_path, &dest)?; + } + } + + let status = std::process::Command::new("iconutil") + .args([ + "-c", + "icns", + &iconset_dir.display().to_string(), + "-o", + &icns_path.display().to_string(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + + let _ = std::fs::remove_dir_all(&iconset_dir); + + if !status.success() { + bail!("Failed to convert PNG to ICNS. Provide an .icns file directly or ensure iconutil is available."); + } + + Ok(()) +} + +/// Recursively copy a directory tree, ensuring writable permissions on the +/// destination. This is needed because source files from the Nix store are +/// read-only. +/// Strip unnecessary files from a CEF-based app bundle to reduce size. +/// +/// Removes: +/// - Non-English locale packs (~47MB) +/// - SwiftShader software Vulkan renderer (~16MB, not needed on macOS with Metal) +/// - OpenGL ES emulation library (~6.5MB, not needed on macOS with Metal) +fn strip_cef_bloat(contents_dir: &Path) { + let frameworks_dir = contents_dir.join("Frameworks"); + let cef_framework = frameworks_dir + .join("Chromium Embedded Framework.framework") + .join("Versions") + .join("A"); + + if !cef_framework.exists() { + return; } + let cef_resources = cef_framework.join("Resources"); + let cef_libraries = cef_framework.join("Libraries"); + + // Remove non-English locale packs. + if let Ok(entries) = std::fs::read_dir(&cef_resources) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(".lproj") && name != "en.lproj" { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } + + // Remove SwiftShader (software Vulkan fallback, not needed on macOS with Metal). + let _ = std::fs::remove_file(cef_libraries.join("libvk_swiftshader.dylib")); + let _ = std::fs::remove_file(cef_libraries.join("vk_swiftshader_icd.json")); +} + +fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), AnyError> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src).with_context(|| { + format!("Reading directory '{}'", src.display()) + })? { + let entry = entry?; + let ty = entry.file_type()?; + let dest = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_all(&entry.path(), &dest)?; + } else if ty.is_symlink() { + let target = std::fs::read_link(entry.path())?; + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &dest)?; + #[cfg(windows)] + { + if target.is_dir() { + std::os::windows::fs::symlink_dir(&target, &dest)?; + } else { + std::os::windows::fs::symlink_file(&target, &dest)?; + } + } + } else { + std::fs::copy(entry.path(), &dest)?; + // Ensure the copied file is writable (nix store files are read-only). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(&dest)?; + let mut perms = meta.permissions(); + perms.set_mode(perms.mode() | 0o200); + std::fs::set_permissions(&dest, perms)?; + } + } + } Ok(()) } @@ -410,7 +844,7 @@ async fn compile_eszip( } /// Find the WEF backend executable. -fn find_wef_backend() -> Result { +fn find_wef_backend(backend: &str) -> Result { if let Ok(path) = std::env::var(WEF_BACKEND_ENV) { let p = PathBuf::from(&path); if p.exists() { @@ -427,13 +861,13 @@ fn find_wef_backend() -> Result { let exe_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|p| p.to_path_buf())); - for search_path in WEF_BACKEND_SEARCH_PATHS { - let p = PathBuf::from(search_path); + for search_path in wef_backend_search_paths(backend) { + let p = PathBuf::from(&search_path); if p.exists() { return Ok(p); } if let Some(exe_dir) = &exe_dir { - let p = exe_dir.join(search_path); + let p = exe_dir.join(&search_path); if p.exists() { return Ok(p); } @@ -441,7 +875,8 @@ fn find_wef_backend() -> Result { } bail!( - "WEF backend not found. Set {} to the path of the WEF backend executable.", + "WEF backend '{}' not found. Set {} to the path of the WEF backend executable.", + backend, WEF_BACKEND_ENV ) } @@ -461,8 +896,9 @@ async fn run_desktop_hmr( dylib_path: &Path, source_dir: &Path, framework: Option<&super::framework::FrameworkDetection>, + backend: &str, ) -> Result<(), AnyError> { - let wef_backend = find_wef_backend()?; + let wef_backend = find_wef_backend(backend)?; let dylib_abs = dylib_path .canonicalize() .unwrap_or(dylib_path.to_path_buf()); @@ -797,6 +1233,7 @@ mod test { self_extracting: false, desktop: false, hmr: false, + backend: None, }, &resolve_cwd(None).unwrap(), ) @@ -831,6 +1268,7 @@ mod test { self_extracting: false, desktop: false, hmr: false, + backend: None, }, &resolve_cwd(None).unwrap(), ) diff --git a/cli/tools/framework.rs b/cli/tools/framework.rs index 034c7412bfad5e..5b659c32dfd005 100644 --- a/cli/tools/framework.rs +++ b/cli/tools/framework.rs @@ -207,7 +207,8 @@ if (Deno.env.get("DENO_DESKTOP_DEV")) { await server.listen(); await new Promise(() => {}); } else { - await import("./_fresh/server.js"); + const mod = await import("./_fresh/server.js"); + Deno.serve({ port: 41520, hostname: "127.0.0.1" }, mod.default.fetch); } "# .into(), diff --git a/cli/tools/installer/mod.rs b/cli/tools/installer/mod.rs index ce5352930ced5f..b81adc4d3d62ae 100644 --- a/cli/tools/installer/mod.rs +++ b/cli/tools/installer/mod.rs @@ -1112,6 +1112,9 @@ async fn install_global_compiled( exclude: vec![], eszip: false, self_extracting: false, + desktop: false, + hmr: false, + backend: None, }; let mut new_flags = flags.as_ref().clone(); From ab3aa52221d14fc3cef301c3e2e84ae24bc198f2 Mon Sep 17 00:00:00 2001 From: Divy Srivastava Date: Fri, 13 Mar 2026 20:01:07 +0530 Subject: [PATCH 003/137] stuff --- Cargo.lock | 77 ++++++++++++ Cargo.toml | 1 + cli/lib/standalone/binary.rs | 4 + cli/rt/Cargo.toml | 1 - cli/rt/desktop.rs | 225 +++++++++++++++++++++++------------ cli/rt/run.rs | 53 +++++++-- cli/rt_desktop/Cargo.toml | 1 + cli/rt_desktop/lib.rs | 186 +++++++++++++++++++++++++++-- cli/standalone/binary.rs | 5 + cli/tools/compile.rs | 16 +++ cli/tools/framework.rs | 89 +++++++++----- runtime/Cargo.toml | 1 + runtime/js/99_main.js | 8 +- runtime/ops/desktop.rs | 192 ++++++++++++++++++++++++++++++ runtime/ops/mod.rs | 1 + runtime/snapshot.rs | 1 + runtime/snapshot_info.rs | 1 + runtime/web_worker.rs | 1 + runtime/worker.rs | 1 + 19 files changed, 736 insertions(+), 128 deletions(-) create mode 100644 runtime/ops/desktop.rs diff --git a/Cargo.lock b/Cargo.lock index 579288823d00d4..40e9163aba564a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -613,6 +613,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.3", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -820,6 +840,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cache_control" version = "0.2.0" @@ -903,6 +932,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cdivsufsort" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edefce019197609da416762da75bb000bbd2224b2d89a7e722c2296cbff79b8c" +dependencies = [ + "cc", + "sacabase", +] + [[package]] name = "cexpr" version = "0.6.0" @@ -3110,6 +3149,7 @@ dependencies = [ "notify", "ntapi", "once_cell", + "qbsdiff", "regex", "rustyline", "same-file", @@ -3531,6 +3571,7 @@ dependencies = [ "libsui", "log", "rustls", + "serde_json", "tokio", "wef", ] @@ -6262,6 +6303,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.172" @@ -7869,6 +7916,18 @@ dependencies = [ "unicase", ] +[[package]] +name = "qbsdiff" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc7f24528be166f08f2c7becaca5618865499b6ded2565d5afcd795cc0d7596" +dependencies = [ + "byteorder", + "bzip2", + "rayon", + "suffix_array", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -8651,6 +8710,15 @@ version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16c7f49c9d5caa3bf4b3106900484b447b9253fe99670ceb81cb6cb5027855e1" +[[package]] +name = "sacabase" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9883fc3d6ce3d78bb54d908602f8bc1f7b5f983afe601dabe083009d86267a84" +dependencies = [ + "num-traits", +] + [[package]] name = "safe_arch" version = "0.7.4" @@ -9366,6 +9434,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +[[package]] +name = "suffix_array" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907d9ca9637a22e3a7d7c7818f6105a7898857359e187ad3325d986684b9ec3f" +dependencies = [ + "cdivsufsort", +] + [[package]] name = "swc_allocator" version = "4.0.1" diff --git a/Cargo.toml b/Cargo.toml index c139b2809acecc..91d3e5df65780f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -268,6 +268,7 @@ prettyplease = "0.2.31" proc-macro2 = "1.0" prost = "0.13" prost-build = "0.13" +qbsdiff = "1.4" quick-junit = "0.3.5" quinn = { version = "0.11.8", default-features = false } rand = "=0.8.5" diff --git a/cli/lib/standalone/binary.rs b/cli/lib/standalone/binary.rs index 2db2cbf6166ea7..ee7f88ea0a9937 100644 --- a/cli/lib/standalone/binary.rs +++ b/cli/lib/standalone/binary.rs @@ -96,6 +96,10 @@ pub struct Metadata { /// hash of the VFS data used for versioning the extraction directory. #[serde(default, skip_serializing_if = "Option::is_none")] pub self_extracting: Option, + /// Application version from deno.json or package.json, used for + /// auto-update support in desktop apps. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_version: Option, } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] diff --git a/cli/rt/Cargo.toml b/cli/rt/Cargo.toml index 3dc4a12ed44956..5c14486235de4e 100644 --- a/cli/rt/Cargo.toml +++ b/cli/rt/Cargo.toml @@ -47,7 +47,6 @@ deno_terminal.workspace = true libsui.workspace = true node_resolver.workspace = true notify.workspace = true - async-trait.workspace = true bincode.workspace = true import_map.workspace = true diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 3e70e0963bce10..456a0dfd8e0ef4 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -1,92 +1,169 @@ // Copyright 2018-2026 the Deno authors. MIT license. -//! Desktop window management extension for `deno compile --desktop`. +//! Desktop window management for `deno compile --desktop`. //! -//! Exposes `Deno.desktop.*` APIs that control the native window -//! (title, size, navigation, JS execution in the webview, quit). -//! -//! The actual implementation is provided via [`DesktopApi`] which is -//! placed into the OpState by the caller. - -use deno_core::Extension; -use deno_core::OpState; -use deno_core::op2; - -/// Trait for desktop window operations. Implemented by the desktop -/// runtime to bridge to the WEF backend. -pub trait DesktopApi: Send + Sync + 'static { - fn set_title(&self, title: &str); - fn set_window_size(&self, width: i32, height: i32); - fn navigate(&self, url: &str); - fn execute_js(&self, script: &str); - fn quit(&self); -} - -fn get_api(state: &OpState) -> &dyn DesktopApi { - &**state.borrow::>() -} - -#[op2(fast)] -fn op_desktop_set_title(state: &OpState, #[string] title: &str) { - get_api(state).set_title(title); -} +//! The ops are defined in `deno_runtime::ops::desktop` and included in the +//! V8 snapshot. This module re-exports the key types and provides the JS +//! initialization code. -#[op2(fast)] -fn op_desktop_set_window_size(state: &OpState, width: i32, height: i32) { - get_api(state).set_window_size(width, height); -} - -#[op2(fast)] -fn op_desktop_navigate(state: &OpState, #[string] url: &str) { - get_api(state).navigate(url); -} +use std::sync::Arc; -#[op2(fast)] -fn op_desktop_execute_js(state: &OpState, #[string] script: &str) { - get_api(state).execute_js(script); -} +use deno_core::OpState; -#[op2(fast)] -fn op_desktop_quit(state: &OpState) { - get_api(state).quit(); -} +// Re-export from runtime so denort_desktop can use them. +pub use deno_runtime::ops::desktop::AutoUpdateState; +pub use deno_runtime::ops::desktop::DesktopApi; -deno_core::extension!( - deno_desktop, - ops = [ +/// JS code that exposes desktop APIs via `Deno.BrowserWindow` and `Deno.desktop`. +pub const DESKTOP_JS: &str = r#" +(() => { + const { op_desktop_set_title, - op_desktop_set_window_size, + op_desktop_set_size, op_desktop_navigate, op_desktop_execute_js, - op_desktop_quit, - ], -); - -/// JS code that sets up `Deno.desktop.*` APIs. -pub const DESKTOP_JS: &str = r#" -(() => { - const ops = Deno[Deno.internal].core.ops; + op_desktop_close, + op_desktop_set_application_menu, + op_desktop_recv_menu_click, + } = Deno[Deno.internal].core.ops; class BrowserWindow { - setTitle(title) { ops.op_desktop_set_title(title); } - setSize(width, height) { ops.op_desktop_set_window_size(width, height); } - navigate(url) { ops.op_desktop_navigate(url); } - executeJs(script) { ops.op_desktop_execute_js(script); } - close() { ops.op_desktop_quit(); } + constructor(options = {}) { + if (options.title) op_desktop_set_title(options.title); + if (options.width && options.height) op_desktop_set_size(options.width, options.height); + } + setTitle(title) { op_desktop_set_title(title); } + setSize(width, height) { op_desktop_set_size(width, height); } + navigate(url) { op_desktop_navigate(url); } + executeJs(script) { op_desktop_execute_js(script); } + close() { op_desktop_close(); } + setApplicationMenu(templateJson) { op_desktop_set_application_menu(templateJson); } } + Deno.BrowserWindow = BrowserWindow; - Deno.desktop = { - BrowserWindow, - mainWindow: new BrowserWindow(), - }; + // Poll for menu click events from the native side and dispatch + // them as CustomEvents on globalThis. Defer start so the pending + // op doesn't block the pre-module event loop tick used by HMR. + addEventListener("load", () => { + (async () => { + while (true) { + const id = await op_desktop_recv_menu_click(); + if (id == null) break; + dispatchEvent(new CustomEvent("menuclick", { detail: { id } })); + } + })(); + }, { once: true }); })(); "#; -/// Create the desktop extension with the given API implementation. -pub fn init_extension(api: Box) -> Extension { - let mut ext = self::deno_desktop::init(); - ext.op_state_fn = Some(Box::new(move |state: &mut deno_core::OpState| { - state.put::>(api); - })); - ext +/// JS code that initializes auto-update APIs. Executed separately so +/// version and rollback state can be baked in as literals. +pub fn desktop_auto_update_js( + version: Option<&str>, + rolled_back: bool, +) -> String { + format!( + r#"(() => {{ + const {{ + op_desktop_apply_patch, + op_desktop_confirm_update, + }} = Deno[Deno.internal].core.ops; + + const _version = {version}; + const _rolledBack = {rolled_back}; + + if (_rolledBack) {{ + queueMicrotask(() => {{ + dispatchEvent( + new CustomEvent("desktop-update-rollback", {{ + detail: {{ reason: "Update failed to start, rolled back." }}, + }}), + ); + }}); + }} else {{ + op_desktop_confirm_update(); + }} + + let autoUpdateTimer = null; + + Deno.desktop = {{ + get version() {{ return _version; }}, + + autoUpdate(urlOrOpts) {{ + const opts = typeof urlOrOpts === "string" + ? {{ url: urlOrOpts }} + : urlOrOpts; + const {{ url, interval }} = opts; + if (!_version) {{ + console.warn("Deno.desktop.autoUpdate: no version in deno.json, skipping"); + return; + }} + + const check = async () => {{ + try {{ + const manifestUrl = url.replace(/\/$/, "") + "/latest.json"; + const resp = await fetch(manifestUrl); + if (!resp.ok) return; + const manifest = await resp.json(); + if (manifest.version === _version) return; + + const patchName = manifest.patches?.[_version]; + if (patchName) {{ + const patchUrl = url.replace(/\/$/, "") + "/" + patchName; + const patchResp = await fetch(patchUrl); + if (patchResp.ok) {{ + const patchBytes = new Uint8Array(await patchResp.arrayBuffer()); + op_desktop_apply_patch(patchBytes); + dispatchEvent( + new CustomEvent("desktop-update-ready", {{ + detail: {{ version: manifest.version }}, + }}), + ); + if (autoUpdateTimer) clearInterval(autoUpdateTimer); + return; + }} + }} + console.warn("Deno.desktop.autoUpdate: no patch available for", + _version, "->", manifest.version); + }} catch (e) {{ + console.warn("Deno.desktop.autoUpdate: check failed:", e.message); + }} + }}; + + setTimeout(check, 1000); + if (interval) {{ + autoUpdateTimer = setInterval(check, interval); + }} + }}, + }}; +}})(); +"#, + version = match version { + Some(v) => format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\"")), + None => "null".to_string(), + }, + rolled_back = if rolled_back { "true" } else { "false" }, + ) +} + +pub use deno_runtime::ops::desktop::MenuClickSender; +pub use deno_runtime::ops::desktop::create_menu_click_channel; + +/// Place the DesktopApi and optional AutoUpdateState into OpState. +/// The ops are already registered in the snapshot; this just provides +/// the runtime implementation. +pub fn init_desktop_state( + state: &mut OpState, + api: Box, + auto_update: Option, +) { + let api: Arc = Arc::from(api); + state.put::>(api); + if let Some(au) = auto_update { + state.put::(au); + } + // Create menu click channel so op_desktop_recv_menu_click can work + let (tx, rx) = create_menu_click_channel(); + state.put(rx); + state.put(tx); } diff --git a/cli/rt/run.rs b/cli/rt/run.rs index 0e926d5e5626d1..16a1435f98821c 100644 --- a/cli/rt/run.rs +++ b/cli/rt/run.rs @@ -811,12 +811,16 @@ pub struct RunOptions { pub hmr_watch_dir: Option, /// Callback invoked after each successful HMR replacement. pub hmr_on_reload: Option, - /// Additional extensions to load (e.g. desktop window APIs). - pub custom_extensions: Vec, + /// Callback to initialize additional OpState (e.g. desktop APIs). + /// Called during worker creation to inject state without adding extensions. + pub op_state_init: Option>, /// Override the main module to execute instead of the embedded entrypoint. /// Used by desktop runtime for forked worker processes (child_process.fork) /// where the child should run a specific script, not the embedded entrypoint. pub override_main_module: Option, + /// Version and rollback state for desktop auto-update JS initialization. + pub auto_update_version: Option, + pub auto_update_rolled_back: bool, } impl Default for RunOptions { @@ -827,8 +831,10 @@ impl Default for RunOptions { serve_host: None, hmr_watch_dir: None, hmr_on_reload: None, - custom_extensions: vec![], + op_state_init: None, override_main_module: None, + auto_update_version: None, + auto_update_rolled_back: false, } } } @@ -866,7 +872,7 @@ pub async fn run_with_options( ) -> Result { let hmr_watch_dir = options.hmr_watch_dir; let hmr_on_reload = options.hmr_on_reload; - let custom_extensions = options.custom_extensions; + let op_state_init = options.op_state_init; let override_main_module = options.override_main_module; let StandaloneData { metadata, @@ -884,6 +890,11 @@ pub async fn run_with_options( // use a dummy npm registry url let npm_registry_url = Url::parse("https://localhost/").unwrap(); let root_dir_url = Arc::new(Url::from_directory_path(&root_path).unwrap()); + let workspace_root_path = hmr_watch_dir + .clone() + .unwrap_or_else(|| root_path.clone()); + let workspace_root_dir_url = + Arc::new(Url::from_directory_path(&workspace_root_path).unwrap()); let main_module = if let Some(url) = override_main_module { url } else { @@ -930,7 +941,7 @@ pub async fn run_with_options( )); let snapshot = npm_snapshot.unwrap(); let maybe_node_modules_path = node_modules_dir - .map(|node_modules_dir| root_path.join(node_modules_dir)); + .map(|node_modules_dir| workspace_root_path.join(node_modules_dir)); let in_npm_pkg_checker = DenoInNpmPackageChecker::new(CreateInNpmPkgCheckerOptions::Managed( ManagedInNpmPkgCheckerCreateOptions { @@ -956,7 +967,7 @@ pub async fn run_with_options( root_node_modules_dir, }) => { let root_node_modules_dir = - root_node_modules_dir.map(|p| vfs.root().join(p)); + root_node_modules_dir.map(|p| workspace_root_path.join(p)); let in_npm_pkg_checker = DenoInNpmPackageChecker::new(CreateInNpmPkgCheckerOptions::Byonm); let npm_resolver = NpmResolver::::new::( @@ -964,7 +975,7 @@ pub async fn run_with_options( sys: node_resolution_sys.clone(), pkg_json_resolver: pkg_json_resolver.clone(), root_node_modules_dir, - search_stop_dir: Some(root_path.clone()), + search_stop_dir: Some(workspace_root_path.clone()), }), ); (in_npm_pkg_checker, npm_resolver) @@ -1050,7 +1061,7 @@ pub async fn run_with_options( let import_map = match metadata.workspace_resolver.import_map { Some(import_map) => Some( import_map::parse_from_json_with_options( - root_dir_url.join(&import_map.specifier).unwrap(), + workspace_root_dir_url.join(&import_map.specifier).unwrap(), &import_map.json, import_map::ImportMapOptions { address_hook: None, @@ -1066,7 +1077,7 @@ pub async fn run_with_options( .package_jsons .into_iter() .map(|(relative_path, json)| { - let path = root_dir_url + let path = workspace_root_dir_url .join(&relative_path) .unwrap() .to_file_path() @@ -1077,7 +1088,7 @@ pub async fn run_with_options( }) .collect::, AnyError>>()?; WorkspaceResolver::new_raw( - root_dir_url.clone(), + workspace_root_dir_url.clone(), import_map, metadata .workspace_resolver @@ -1179,7 +1190,7 @@ pub async fn run_with_options( is_inspecting: false, is_standalone: true, auto_serve: options.auto_serve, - skip_op_registration: custom_extensions.is_empty(), + skip_op_registration: true, location: metadata.location, argv0: NpmPackageReqReference::from_specifier(&main_module) .ok() @@ -1248,23 +1259,39 @@ pub async fn run_with_options( .map(|key| root_dir_url.join(key).unwrap()) .collect::>(); - let has_desktop = !custom_extensions.is_empty(); + let has_desktop = op_state_init.is_some(); let mut worker = worker_factory.create_custom_worker( WorkerExecutionMode::Run, main_module, preload_modules, require_modules, permissions, - custom_extensions, + vec![], Default::default(), None, )?; + // Inject desktop API state into OpState after worker creation. + if let Some(init_fn) = op_state_init { + let op_state = worker.js_runtime().op_state(); + init_fn(&mut op_state.borrow_mut()); + } + // Initialize desktop APIs (Deno.desktop.*). if has_desktop { worker .js_runtime() .execute_script("ext:deno_desktop/init", crate::desktop::DESKTOP_JS)?; + + // Initialize auto-update JS (DesktopUpdater cppgc object is already + // registered via the desktop extension's objects). + let js = crate::desktop::desktop_auto_update_js( + options.auto_update_version.as_deref(), + options.auto_update_rolled_back, + ); + worker + .js_runtime() + .execute_script("ext:deno_desktop/auto_update", js)?; } if let Some(watch_dir) = hmr_watch_dir { diff --git a/cli/rt_desktop/Cargo.toml b/cli/rt_desktop/Cargo.toml index 10606c69628dbd..6d8242133c9119 100644 --- a/cli/rt_desktop/Cargo.toml +++ b/cli/rt_desktop/Cargo.toml @@ -31,5 +31,6 @@ libsui.workspace = true wef = { path = "../../../wef/capi" } log = { workspace = true, features = ["serde"] } +serde_json.workspace = true rustls.workspace = true tokio.workspace = true diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index b002750187930e..6e14c4eb91b28b 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -13,12 +13,14 @@ use std::borrow::Cow; use std::env; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use deno_core::anyhow::Context; use deno_core::anyhow::bail; use deno_core::error::AnyError; +use deno_core::serde_json; use deno_lib::util::result::js_error_downcast_ref; use deno_lib::version::otel_runtime_config; use deno_runtime::fmt_errors::format_js_error; @@ -51,6 +53,18 @@ impl denort::desktop::DesktopApi for WefDesktopApi { fn quit(&self) { wef::quit(); } + + fn set_application_menu(&self, template_json: &str) { + let json: serde_json::Value = match serde_json::from_str(template_json) { + Ok(v) => v, + Err(e) => { + log::error!("Invalid menu template JSON: {}", e); + return; + } + }; + let wef_value = json_to_wef_value(&json); + wef::set_application_menu_raw(wef_value); + } } /// Promote this dylib's symbols to the global symbol scope so that @@ -96,7 +110,103 @@ fn promote_dylib_symbols_to_global() { } } +/// Get the filesystem path of this dylib using `dladdr`. +#[cfg(unix)] +fn get_dylib_path() -> Option { + #[repr(C)] + struct DlInfo { + dli_fname: *const std::ffi::c_char, + dli_fbase: *mut std::ffi::c_void, + dli_sname: *const std::ffi::c_char, + dli_saddr: *mut std::ffi::c_void, + } + unsafe extern "C" { + fn dladdr( + addr: *const std::ffi::c_void, + info: *mut DlInfo, + ) -> std::ffi::c_int; + } + unsafe { + let mut info: DlInfo = std::mem::zeroed(); + let addr = get_dylib_path as *const std::ffi::c_void; + if dladdr(addr, &mut info) != 0 && !info.dli_fname.is_null() { + let c_str = std::ffi::CStr::from_ptr(info.dli_fname); + Some(PathBuf::from(c_str.to_string_lossy().into_owned())) + } else { + None + } + } +} + +/// Manages pending updates and rollback on startup. +/// +/// Uses a sentinel file (`.update-ok`) to detect if the last update +/// booted successfully: +/// +/// - `.update` exists → apply it (current → `.backup`, `.update` → current) +/// - `.backup` exists but `.update-ok` doesn't → last update crashed, rollback +/// - `.backup` exists and `.update-ok` exists → previous update succeeded, clean up +/// +/// Returns `true` if a rollback occurred (so we can dispatch an event in JS). +fn apply_pending_update(dylib_path: &Path) -> bool { + let ext = dylib_path.extension().unwrap_or_default().to_string_lossy(); + let update_path = dylib_path.with_extension(format!("{}.update", ext)); + let backup_path = dylib_path.with_extension(format!("{}.backup", ext)); + let sentinel_path = dylib_path.with_extension(format!("{}.update-ok", ext)); + + if update_path.exists() { + // New update pending — apply it. + // Remove stale sentinel so we can detect if *this* update fails. + let _ = std::fs::remove_file(&sentinel_path); + let _ = std::fs::remove_file(&backup_path); + if std::fs::rename(dylib_path, &backup_path).is_ok() { + if std::fs::rename(&update_path, dylib_path).is_err() { + // Failed to move update into place — rollback immediately. + let _ = std::fs::rename(&backup_path, dylib_path); + } + } + return false; + } + + if backup_path.exists() && !sentinel_path.exists() { + // Last update didn't write the sentinel → it crashed. Rollback. + eprintln!("[desktop] Last update failed to start, rolling back..."); + let _ = std::fs::rename(&backup_path, dylib_path); + return true; + } + + if backup_path.exists() && sentinel_path.exists() { + // Previous update booted fine — clean up backup and sentinel. + let _ = std::fs::remove_file(&backup_path); + let _ = std::fs::remove_file(&sentinel_path); + } + + false +} + wef::main!(|| { + // Apply any pending update before anything else. + #[cfg(unix)] + let update_rolled_back = { + match std::panic::catch_unwind(|| { + if let Some(ref dylib_path) = get_dylib_path() { + eprintln!("[desktop] dylib path: {:?}", dylib_path); + apply_pending_update(dylib_path) + } else { + eprintln!("[desktop] could not determine dylib path"); + false + } + }) { + Ok(v) => v, + Err(e) => { + eprintln!("[desktop] update check panicked: {:?}", e); + false + } + } + }; + #[cfg(not(unix))] + let update_rolled_back = false; + // Make NAPI symbols visible to native addons (e.g. next-swc). #[cfg(unix)] promote_dylib_symbols_to_global(); @@ -106,8 +216,10 @@ wef::main!(|| { // forked workers and run them headless (no WEF window) by checking // for NODE_CHANNEL_FD (set by Node's child_process.fork()) or // NEXT_PRIVATE_WORKER (set by Next.js specifically). + let args: Vec<_> = env::args_os().collect(); let is_worker = env::var("NODE_CHANNEL_FD").is_ok() - || env::var("NEXT_PRIVATE_WORKER").is_ok(); + || env::var("NEXT_PRIVATE_WORKER").is_ok() + || extract_fork_script_path(&args).is_some(); if is_worker { run_headless_worker(); return; @@ -128,7 +240,7 @@ wef::main!(|| { rt.block_on(async { eprintln!("[desktop] run_desktop starting"); - match run_desktop().await { + match run_desktop(update_rolled_back).await { Ok(()) => eprintln!("[desktop] run_desktop completed OK"), Err(error) => { let error_string = match js_error_downcast_ref(&error) { @@ -288,6 +400,32 @@ fn extract_fork_script_path( None } +/// Convert a serde_json::Value to a wef::Value for the menu template. +fn json_to_wef_value(v: &serde_json::Value) -> wef::Value { + match v { + serde_json::Value::Null => wef::Value::Null, + serde_json::Value::Bool(b) => wef::Value::Bool(*b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + wef::Value::Int(i as i32) + } else { + wef::Value::Double(n.as_f64().unwrap_or(0.0)) + } + } + serde_json::Value::String(s) => wef::Value::String(s.clone()), + serde_json::Value::Array(arr) => { + wef::Value::List(arr.iter().map(json_to_wef_value).collect()) + } + serde_json::Value::Object(obj) => { + let mut map = std::collections::HashMap::new(); + for (k, v) in obj { + map.insert(k.clone(), json_to_wef_value(v)); + } + wef::Value::Dict(map) + } + } +} + /// Find the embedded data section in this dylib (not the main executable). fn find_section_in_dylib() -> Result<&'static [u8], AnyError> { match libsui::find_section_in_current_image("d3n0l4nd") @@ -301,7 +439,7 @@ fn find_section_in_dylib() -> Result<&'static [u8], AnyError> { } } -async fn run_desktop() -> Result<(), AnyError> { +async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let args: Vec<_> = env::args_os().collect(); let data = denort::binary::extract_standalone_with_finder( Cow::Owned(args), @@ -363,8 +501,24 @@ async fn run_desktop() -> Result<(), AnyError> { None }; - // Desktop extension: provides Deno.desktop.* APIs - let desktop_ext = denort::desktop::init_extension(Box::new(WefDesktopApi)); + // Desktop extension: provides Deno.desktop.* APIs + auto-update + #[cfg(unix)] + let auto_update_state = get_dylib_path().map(|p| { + denort::desktop::AutoUpdateState { + dylib_path: p, + app_version: data.metadata.app_version.clone(), + rolled_back: update_rolled_back, // from wef::main! startup check + } + }); + #[cfg(not(unix))] + let auto_update_state: Option = None; + + let auto_update_version = auto_update_state + .as_ref() + .and_then(|s| s.app_version.clone()); + let auto_update_rolled_back = auto_update_state + .as_ref() + .is_some_and(|s| s.rolled_back); let run_opts = RunOptions { auto_serve: true, @@ -376,8 +530,24 @@ async fn run_desktop() -> Result<(), AnyError> { hmr_watch_dir }, hmr_on_reload, - custom_extensions: vec![desktop_ext], + op_state_init: Some(Box::new(move |state| { + denort::desktop::init_desktop_state( + state, + Box::new(WefDesktopApi), + auto_update_state, + ); + // Wire WEF menu click callback to the Deno runtime channel + let menu_tx = state + .borrow::() + .0 + .clone(); + wef::set_menu_click_handler(move |id| { + let _ = menu_tx.send(id.to_string()); + }); + })), override_main_module: None, + auto_update_version, + auto_update_rolled_back, }; // Run the Deno runtime and WEF event loop concurrently. @@ -412,8 +582,8 @@ async fn run_desktop() -> Result<(), AnyError> { || response.starts_with("HTTP/1.0 2") || response.starts_with("HTTP/1.0 3") { - log::debug!( - "Server ready after {} attempts, navigating to {}", + eprintln!( + "[desktop] Server ready after {} attempts, navigating to {}", i + 1, &url ); diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index e02dd16ed156fd..e3557e80d97956 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -903,6 +903,11 @@ impl<'a> DenoCompileBinaryWriter<'a> { } else { None }, + app_version: self + .cli_options + .workspace() + .root_deno_json() + .and_then(|c| c.json.version.clone()), }; let (data_section_bytes, section_sizes) = serialize_binary_data_section( diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index 294ee93d958bac..f16304cb8232b0 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -28,6 +28,7 @@ use crate::args::CliOptions; use crate::args::CompileFlags; use crate::args::ConfigFlag; use crate::args::Flags; +use crate::args::TypeCheckMode; use crate::factory::CliFactory; use crate::standalone::binary::WriteBinOptions; use crate::standalone::binary::is_standalone_binary; @@ -42,6 +43,7 @@ const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; fn wef_backend_search_paths(backend: &str) -> Vec { match backend { "cef" => vec![ + PathBuf::from("/Users/divy/gh/wef/result-cef/Applications/wef.app/Contents/MacOS/wef"), PathBuf::from("/Users/divy/gh/wef/result/Applications/wef.app/Contents/MacOS/wef"), PathBuf::from("/Users/divy/gh/wef/cef/build/wef.app/Contents/MacOS/wef"), ], @@ -76,6 +78,14 @@ pub async fn compile( log::info!("Detected {} framework", detection.name); // Enable CJS detection for Node-based frameworks. flags.unstable_config.detect_cjs = true; + if detection.name == "Next.js" + && !matches!(flags.type_check_mode, TypeCheckMode::None) + { + log::info!( + "Disabling Deno type checking for Next.js desktop compile; Next handles app compilation itself" + ); + flags.type_check_mode = TypeCheckMode::None; + } // Write a temporary entrypoint file. let entrypoint_path = cwd.join(".deno_desktop_entry.ts"); std::fs::write(&entrypoint_path, entrypoint_code)?; @@ -356,6 +366,7 @@ const WEF_BACKEND_APP_ENV: &str = "WEF_BACKEND_APP"; fn wef_backend_app_search_paths(backend: &str) -> Vec { match backend { "cef" => vec![ + "/Users/divy/gh/wef/result-cef/Applications/wef.app".to_string(), "/Users/divy/gh/wef/result/Applications/wef.app".to_string(), "/Users/divy/gh/wef/cef/build/wef.app".to_string(), ], @@ -549,6 +560,11 @@ fn package_macos_app_bundle( NSSupportsAutomaticGraphicsSwitching + NSAppTransportSecurity + + NSAllowsLocalNetworking + + "#, diff --git a/cli/tools/framework.rs b/cli/tools/framework.rs index 5b659c32dfd005..79e999201c9c6c 100644 --- a/cli/tools/framework.rs +++ b/cli/tools/framework.rs @@ -164,6 +164,25 @@ fn detect_fresh(dir: &Path) -> FrameworkDetection { // Fresh 2.x uses _fresh/server.js, older uses main.ts let is_fresh2 = dir.join("_fresh/server.js").exists(); if is_fresh2 { + let vite_config_loader = + if let Some(vite_config) = find_config_file(dir, "vite.config") { + format!( + r#" +const userConfigMod = await import("./{vite_config}"); +const exportedConfig = userConfigMod.default ?? userConfigMod; +const userConfig = (typeof exportedConfig === "function" + ? await exportedConfig({{ + command: "serve", + mode: Deno.env.get("NODE_ENV") ?? "development", + isSsrBuild: false, + isPreview: false, + }}) + : await exportedConfig) ?? {{}}; +"# + ) + } else { + "const userConfig = { plugins: [fresh()] };".to_string() + }; // Fresh 2.x: production imports _fresh/server.js, dev starts Vite FrameworkDetection { name: "Fresh", @@ -171,38 +190,38 @@ fn detect_fresh(dir: &Path) -> FrameworkDetection { import { createServer } from "npm:vite"; import { fresh } from "@fresh/plugin-vite"; if (Deno.env.get("DENO_DESKTOP_DEV")) { + __DENO_DESKTOP_VITE_CONFIG__ + const freshDesktopFixup = { + name: "fresh-desktop:fixup", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const origWrite = res.write.bind(res); + const origEnd = res.end.bind(res); + const chunks = []; + res.write = (chunk, ...args) => { chunks.push(Buffer.from(chunk)); return true; }; + res.end = (chunk, ...args) => { + if (chunk) chunks.push(Buffer.from(chunk)); + let body = Buffer.concat(chunks).toString(); + const ct = res.getHeader("content-type") || ""; + if (ct.includes("text/html")) { + body = body.replace(/from "(fresh-(?:island|route)::)/g, 'from "/@id/$1'); + } + res.setHeader("content-length", Buffer.byteLength(body)); + origEnd(body); + }; + next(); + }); + }, + }; const server = await createServer({ + ...userConfig, configFile: false, - plugins: [ - fresh(), - // Workaround: Fresh's dev server SSR can emit bare `fresh-island::` - // specifiers in inline scripts after snapshot re-crawl. Browsers - // treat the colon as a URL scheme and block it as cross-origin. - // Rewrite to /@id/ so Vite's module resolver handles it. - { - name: "fresh-desktop:fixup", - configureServer(server) { - server.middlewares.use((req, res, next) => { - const origWrite = res.write.bind(res); - const origEnd = res.end.bind(res); - const chunks = []; - res.write = (chunk, ...args) => { chunks.push(Buffer.from(chunk)); return true; }; - res.end = (chunk, ...args) => { - if (chunk) chunks.push(Buffer.from(chunk)); - let body = Buffer.concat(chunks).toString(); - const ct = res.getHeader("content-type") || ""; - if (ct.includes("text/html")) { - body = body.replace(/from "(fresh-(?:island|route)::)/g, 'from "/@id/$1'); - } - res.setHeader("content-length", Buffer.byteLength(body)); - origEnd(body); - }; - next(); - }); - }, - }, - ], - server: { port: 41520, host: "127.0.0.1" }, + plugins: [...(userConfig.plugins ?? [fresh()]), freshDesktopFixup], + server: { + ...(userConfig.server ?? {}), + port: 41520, + host: "127.0.0.1", + }, }); await server.listen(); await new Promise(() => {}); @@ -211,6 +230,7 @@ if (Deno.env.get("DENO_DESKTOP_DEV")) { Deno.serve({ port: 41520, hostname: "127.0.0.1" }, mod.default.fetch); } "# + .replace("__DENO_DESKTOP_VITE_CONFIG__", &vite_config_loader) .into(), include_paths: vec!["_fresh".into()], dev_entrypoint_code: Some("DENO_DESKTOP_DEV=1".into()), @@ -368,6 +388,15 @@ fn has_config_file(dir: &Path, base_name: &str) -> bool { .any(|ext| dir.join(format!("{base_name}.{ext}")).exists()) } +fn find_config_file(dir: &Path, base_name: &str) -> Option { + ["js", "mjs", "ts", "mts", "cjs"] + .iter() + .find_map(|ext| { + let file_name = format!("{base_name}.{ext}"); + dir.join(&file_name).exists().then_some(file_name) + }) +} + /// Read package.json dependencies. fn read_package_deps(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("package.json")).ok()?; diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 843723a73e155a..ba746b4443f074 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -87,6 +87,7 @@ indexmap.workspace = true libc.workspace = true log.workspace = true notify.workspace = true +qbsdiff.workspace = true once_cell.workspace = true regex.workspace = true rustyline = { workspace = true, features = ["custom-bindings"] } diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index ec76c0d602459d..421d0ed1e4e35a 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -541,10 +541,14 @@ const NOT_IMPORTED_OPS = [ // Related to `Deno.desktop` API (deno compile --desktop) "op_desktop_set_title", - "op_desktop_set_window_size", + "op_desktop_set_size", "op_desktop_navigate", "op_desktop_execute_js", - "op_desktop_quit", + "op_desktop_close", + "op_desktop_set_application_menu", + "op_desktop_apply_patch", + "op_desktop_confirm_update", + "op_desktop_recv_menu_click", // deno deploy subcommand "op_deploy_token_get", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs new file mode 100644 index 00000000000000..be895bc7651609 --- /dev/null +++ b/runtime/ops/desktop.rs @@ -0,0 +1,192 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Desktop window management ops for `deno compile --desktop`. +//! +//! These ops are included in the V8 snapshot so their external references +//! are stable. When `DesktopApi` is not present in OpState (non-desktop +//! builds), the ops silently no-op. + +use std::sync::Arc; + +use deno_core::OpState; +use deno_core::op2; + +/// Channel for receiving menu click events in the Deno runtime. +pub struct MenuClickReceiver(pub tokio::sync::mpsc::UnboundedReceiver); +pub struct MenuClickSender(pub tokio::sync::mpsc::UnboundedSender); + +pub fn create_menu_click_channel() -> (MenuClickSender, MenuClickReceiver) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + (MenuClickSender(tx), MenuClickReceiver(rx)) +} + +/// Trait for desktop window operations. Implemented by the desktop +/// runtime (denort_desktop) to bridge to the WEF backend. +pub trait DesktopApi: Send + Sync + 'static { + fn set_title(&self, title: &str); + fn set_window_size(&self, width: i32, height: i32); + fn navigate(&self, url: &str); + fn execute_js(&self, script: &str); + fn quit(&self); + fn set_application_menu(&self, template_json: &str); +} + +fn try_api(state: &OpState) -> Option> { + state.try_borrow::>().map(|a| a.clone()) +} + +#[op2(fast)] +pub fn op_desktop_set_title(state: &mut OpState, #[string] title: &str) { + if let Some(api) = try_api(state) { + api.set_title(title); + } +} + +#[op2(fast)] +pub fn op_desktop_set_size( + state: &mut OpState, + #[smi] width: i32, + #[smi] height: i32, +) { + if let Some(api) = try_api(state) { + api.set_window_size(width, height); + } +} + +#[op2(fast)] +pub fn op_desktop_navigate(state: &mut OpState, #[string] url: &str) { + if let Some(api) = try_api(state) { + api.navigate(url); + } +} + +#[op2(fast)] +pub fn op_desktop_execute_js(state: &mut OpState, #[string] script: &str) { + if let Some(api) = try_api(state) { + api.execute_js(script); + } +} + +#[op2(fast)] +pub fn op_desktop_close(state: &mut OpState) { + if let Some(api) = try_api(state) { + api.quit(); + } +} + +#[op2(fast)] +pub fn op_desktop_set_application_menu( + state: &mut OpState, + #[string] template_json: &str, +) { + if let Some(api) = try_api(state) { + api.set_application_menu(template_json); + } +} + +/// State for the auto-update system, placed into OpState at init. +pub struct AutoUpdateState { + /// Path to the currently running dylib on disk. + pub dylib_path: std::path::PathBuf, + /// App version from metadata (deno.json `version` field). + pub app_version: Option, + /// Whether we rolled back from a failed update on this launch. + pub rolled_back: bool, +} + +#[op2(fast)] +pub fn op_desktop_apply_patch( + state: &mut OpState, + #[buffer] patch_bytes: &[u8], +) -> Result<(), deno_error::JsErrorBox> { + let update_state = + state.try_borrow::().ok_or_else(|| { + deno_error::JsErrorBox::generic("Auto-update state not initialized") + })?; + let dylib_path = &update_state.dylib_path; + + let original = std::fs::read(dylib_path).map_err(|e| { + deno_error::JsErrorBox::generic(format!( + "Failed to read dylib at {}: {}", + dylib_path.display(), + e + )) + })?; + + let patcher = qbsdiff::Bspatch::new(patch_bytes).map_err(|e| { + deno_error::JsErrorBox::generic(format!("Invalid patch: {}", e)) + })?; + let target_size = patcher.hint_target_size() as usize; + let mut patched = Vec::with_capacity(target_size); + patcher + .apply(&original, std::io::Cursor::new(&mut patched)) + .map_err(|e| { + deno_error::JsErrorBox::generic(format!("bspatch failed: {}", e)) + })?; + + let update_path = dylib_path.with_extension(format!( + "{}.update", + dylib_path.extension().unwrap_or_default().to_string_lossy() + )); + std::fs::write(&update_path, &patched).map_err(|e| { + deno_error::JsErrorBox::generic(format!( + "Failed to write update to {}: {}", + update_path.display(), + e + )) + })?; + + log::info!( + "Update written to {}. Will be applied on next launch.", + update_path.display() + ); + + Ok(()) +} + +#[op2] +#[string] +async fn op_desktop_recv_menu_click( + state: std::rc::Rc>, +) -> Option { + let rx = { + let mut s = state.borrow_mut(); + s.try_take::() + }; + if let Some(mut rx) = rx { + let result = rx.0.recv().await; + state.borrow_mut().put(rx); + result + } else { + // No receiver — desktop not initialized, pend forever + std::future::pending().await + } +} + +#[op2(fast)] +pub fn op_desktop_confirm_update(state: &mut OpState) { + if let Some(s) = state.try_borrow::() { + let ext = s + .dylib_path + .extension() + .unwrap_or_default() + .to_string_lossy(); + let sentinel = s.dylib_path.with_extension(format!("{}.update-ok", ext)); + let _ = std::fs::write(&sentinel, b"ok"); + } +} + +deno_core::extension!( + deno_desktop, + ops = [ + op_desktop_set_title, + op_desktop_set_size, + op_desktop_navigate, + op_desktop_execute_js, + op_desktop_close, + op_desktop_set_application_menu, + op_desktop_apply_patch, + op_desktop_confirm_update, + op_desktop_recv_menu_click, + ], +); diff --git a/runtime/ops/mod.rs b/runtime/ops/mod.rs index 9d68dd13c46956..935003db6c1e34 100644 --- a/runtime/ops/mod.rs +++ b/runtime/ops/mod.rs @@ -1,6 +1,7 @@ // Copyright 2018-2026 the Deno authors. MIT license. pub mod bootstrap; +pub mod desktop; pub mod fs_events; pub mod http; pub mod permissions; diff --git a/runtime/snapshot.rs b/runtime/snapshot.rs index f6d76eb898260d..b73ca5aec9810a 100644 --- a/runtime/snapshot.rs +++ b/runtime/snapshot.rs @@ -59,6 +59,7 @@ pub fn create_runtime_snapshot( ops::tty::deno_tty::lazy_init(), ops::http::deno_http_runtime::lazy_init(), deno_bundle_runtime::deno_bundle_runtime::lazy_init(), + ops::desktop::deno_desktop::lazy_init(), ops::bootstrap::deno_bootstrap::init(Some(snapshot_options), false), runtime::lazy_init(), ops::web_worker::deno_web_worker::lazy_init(), diff --git a/runtime/snapshot_info.rs b/runtime/snapshot_info.rs index 960949616805d6..43642f8eb08b1b 100644 --- a/runtime/snapshot_info.rs +++ b/runtime/snapshot_info.rs @@ -60,6 +60,7 @@ pub fn get_extensions_in_snapshot() -> Vec { ops::tty::deno_tty::init(), ops::http::deno_http_runtime::init(), deno_bundle_runtime::deno_bundle_runtime::init(None), + ops::desktop::deno_desktop::init(), ops::bootstrap::deno_bootstrap::init(None, false), runtime::init(), ops::web_worker::deno_web_worker::init(), diff --git a/runtime/web_worker.rs b/runtime/web_worker.rs index f08f708f2e8c7f..52827c8dd97be3 100644 --- a/runtime/web_worker.rs +++ b/runtime/web_worker.rs @@ -600,6 +600,7 @@ impl WebWorker { ops::tty::deno_tty::init(), ops::http::deno_http_runtime::init(), deno_bundle_runtime::deno_bundle_runtime::init(services.bundle_provider), + ops::desktop::deno_desktop::init(), ops::bootstrap::deno_bootstrap::init( options.startup_snapshot.and_then(|_| Default::default()), false, diff --git a/runtime/worker.rs b/runtime/worker.rs index b412952af6a7a6..1655c13f587dc8 100644 --- a/runtime/worker.rs +++ b/runtime/worker.rs @@ -1085,6 +1085,7 @@ fn common_extensions< ops::tty::deno_tty::init(), ops::http::deno_http_runtime::init(), deno_bundle_runtime::deno_bundle_runtime::lazy_init(), + ops::desktop::deno_desktop::init(), ops::bootstrap::deno_bootstrap::init( has_snapshot.then(Default::default), unconfigured_runtime, From 35303e6895cd8da123c0256bdeed838d52587a9d Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Mon, 16 Mar 2026 00:33:50 +0100 Subject: [PATCH 004/137] subcommand, config file, typings, various new apis, objectwrap --- Cargo.lock | 1 - cli/args/flags.rs | 194 ++++++++++++++++++++++- cli/args/mod.rs | 9 +- cli/factory.rs | 1 + cli/lib.rs | 3 + cli/rt/Cargo.toml | 10 +- cli/rt/desktop.rs | 31 +--- cli/rt/run.rs | 5 +- cli/rt_desktop/Cargo.toml | 2 +- cli/rt_desktop/lib.rs | 255 ++++++++++++++++++++++++++++-- cli/schemas/config-file.v1.json | 72 +++++++++ cli/standalone/binary.rs | 4 +- cli/tools/compile.rs | 33 ++-- cli/tools/framework.rs | 10 +- cli/tools/mod.rs | 1 + cli/tsc/dts/lib.deno.desktop.d.ts | 103 ++++++++++++ libs/config/deno_json/mod.rs | 115 ++++++++++++++ libs/config/workspace/mod.rs | 34 ++++ runtime/Cargo.toml | 2 +- runtime/ops/desktop.rs | 221 +++++++++++++++++++++----- 20 files changed, 989 insertions(+), 117 deletions(-) create mode 100644 cli/tsc/dts/lib.deno.desktop.d.ts diff --git a/Cargo.lock b/Cargo.lock index 40e9163aba564a..6dde882c3566a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11354,7 +11354,6 @@ name = "wef" version = "0.1.0" dependencies = [ "bindgen 0.71.1", - "tokio", ] [[package]] diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 1d84a774ccc180..86eb888ab3576c 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -180,6 +180,20 @@ pub struct CompileFlags { pub backend: Option, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DesktopFlags { + pub source_file: String, + pub output: Option, + pub args: Vec, + pub target: Option, + pub icon: Option, + pub include: Vec, + pub exclude: Vec, + pub hmr: bool, + pub backend: Option, + pub all_targets: bool, +} + impl CompileFlags { pub fn resolve_target(&self) -> String { self @@ -618,6 +632,7 @@ pub enum DenoSubcommand { Clean(CleanFlags), Compile(CompileFlags), Completions(CompletionsFlags), + Desktop(DesktopFlags), Coverage(CoverageFlags), Deploy(DeployFlags), Doc(DocFlags), @@ -716,6 +731,10 @@ impl DenoSubcommand { DenoSubcommand::Compile(CompileFlags { target: Some(target), .. + }) + | DenoSubcommand::Desktop(DesktopFlags { + target: Some(target), + .. }) => { // the values of NpmSystemInfo align with the possible values for the // `arch` and `platform` fields of Node.js' `process` global: @@ -1335,6 +1354,10 @@ impl Flags { | Compile(CompileFlags { source_file: script, .. + }) + | Desktop(DesktopFlags { + source_file: script, + .. }) => resolve_single_folder_path(script, current_dir, |mut p| { if p.pop() { Some(p) } else { None } }) @@ -1876,6 +1899,7 @@ pub fn flags_from_vec_with_initial_cwd( "clean" => clean_parse(&mut flags, &mut m), "compile" => compile_parse(&mut flags, &mut m)?, "create" => create_parse(&mut flags, &mut m)?, + "desktop" => desktop_parse(&mut flags, &mut m)?, "completions" => completions_parse(&mut flags, &mut m, app), "coverage" => coverage_parse(&mut flags, &mut m)?, "doc" => doc_parse(&mut flags, &mut m)?, @@ -1985,6 +2009,7 @@ heading! { DOC_HEADING = "Documentation options", FMT_HEADING = "Formatting options", COMPILE_HEADING = "Compile options", + DESKTOP_HEADING = "Desktop options", LINT_HEADING = "Linting options", TEST_HEADING = "Testing options", UPGRADE_HEADING = "Upgrade options", @@ -2143,6 +2168,7 @@ pub fn clap_root() -> Command { .subcommand(clean_subcommand()) .subcommand(compile_subcommand()) .subcommand(create_subcommand()) + .subcommand(desktop_subcommand()) .subcommand(completions_subcommand()) .subcommand(coverage_subcommand()) .subcommand(doc_subcommand()) @@ -2725,6 +2751,14 @@ Unless --reload is specified, this command will not re-download already cached d ) } +const SUPPORTED_OS: [&str; 5] = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "x86_64-apple-darwin", + "aarch64-apple-darwin", +]; + fn compile_subcommand() -> Command { command( "compile", @@ -2787,13 +2821,7 @@ On the first invocation of `deno compile`, Deno will download the relevant binar Arg::new("target") .long("target") .help("Target OS architecture") - .value_parser([ - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - ]) + .value_parser(SUPPORTED_OS) .help_heading(COMPILE_HEADING), ) .arg(no_code_cache_arg()) @@ -2852,6 +2880,106 @@ On the first invocation of `deno compile`, Deno will download the relevant binar }) } +fn desktop_subcommand() -> Command { + command( + "desktop", + cstr!("Build and run desktop applications. + + deno desktop main.tsx + deno desktop --hmr main.tsx + deno desktop --output MyApp.app main.tsx + +Compiles the given script into a desktop application using a WEF backend for +the UI layer. The entrypoint can be a file or . to auto-detect a supported +framework (Next.js, Astro, etc.). + +Read more: https://docs.deno.com/go/desktop +"), + UnstableArgsConfig::ResolutionAndRuntime, + ) + .defer(|cmd| { + runtime_args(cmd, true, false, true) + .arg(check_arg(true)) + .arg( + Arg::new("include") + .long("include") + .help( + cstr!("Includes an additional module or file/directory in the compiled executable. + Use this flag if a dynamically imported module or a web worker main module + fails to load in the executable or to embed a file or directory in the executable. + This flag can be passed multiple times, to include multiple additional modules.", + )) + .action(ArgAction::Append) + .value_hint(ValueHint::FilePath) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("exclude") + .long("exclude") + .help( + cstr!("Excludes a file/directory in the compiled executable. + Use this flag to exclude a specific file or directory within the included files.", + )) + .action(ArgAction::Append) + .value_hint(ValueHint::FilePath) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .value_parser(value_parser!(String)) + .help(cstr!("Output path (e.g. MyApp.app, MyApp.dmg, MyApp.msi)")) + .value_hint(ValueHint::FilePath) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("target") + .long("target") + .help("Target OS architecture") + .value_parser(SUPPORTED_OS) + .help_heading(DESKTOP_HEADING), + ) + .arg(no_code_cache_arg()) + .arg( + Arg::new("icon") + .long("icon") + .help("Set the application icon (.ico on Windows, .icns or .png on macOS)") + .value_parser(value_parser!(String)) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("hmr") + .long("hmr") + .help("Run the desktop app with Hot Module Replacement enabled") + .action(ArgAction::SetTrue) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("backend") + .long("backend") + .help("WEF backend to use for the desktop app") + .value_parser(["webview", "cef", "servo"]) + .default_value("webview") + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("all-targets") + .long("all-targets") + .help("Build for all supported target platforms") + .action(ArgAction::SetTrue) + .help_heading(DESKTOP_HEADING), + ) + .arg(executable_ext_arg()) + .arg(env_file_arg()) + .arg( + script_arg() + .required_unless_present("help") + .trailing_var_arg(true), + ) + }) +} + fn completions_subcommand() -> Command { command( "completions", @@ -6196,6 +6324,58 @@ fn compile_parse( Ok(()) } +fn desktop_parse( + flags: &mut Flags, + matches: &mut ArgMatches, +) -> clap::error::Result<()> { + flags.type_check_mode = TypeCheckMode::Local; + runtime_args_parse(flags, matches, true, false, true)?; + + if let Some(initial_cwd) = flags.initial_cwd.take() { + flags.initial_cwd = Some( + crate::util::fs::canonicalize_path(&initial_cwd) + .ok() + .unwrap_or(initial_cwd), + ); + } + + let mut script = matches.remove_many::("script_arg").unwrap(); + let source_file = script.next().unwrap(); + let args = script.collect(); + let output = matches.remove_one::("output"); + let target = matches.remove_one::("target"); + let icon = matches.remove_one::("icon"); + let hmr = matches.get_flag("hmr"); + let backend = matches.remove_one::("backend"); + let all_targets = matches.get_flag("all-targets"); + let include = matches + .remove_many::("include") + .map(|f| f.collect::>()) + .unwrap_or_default(); + let exclude = matches + .remove_many::("exclude") + .map(|f| f.collect::>()) + .unwrap_or_default(); + ext_arg_parse(flags, matches); + + flags.code_cache_enabled = !matches.get_flag("no-code-cache"); + + flags.subcommand = DenoSubcommand::Desktop(DesktopFlags { + source_file, + output, + args, + target, + icon, + include, + exclude, + hmr, + backend, + all_targets, + }); + + Ok(()) +} + fn completions_parse( flags: &mut Flags, matches: &mut ArgMatches, diff --git a/cli/args/mod.rs b/cli/args/mod.rs index 75b7a8650d9274..60d99c95a63869 100644 --- a/cli/args/mod.rs +++ b/cli/args/mod.rs @@ -701,6 +701,9 @@ impl CliOptions { DenoSubcommand::Compile(compile_flags) => { resolve_url_or_path(&compile_flags.source_file, self.initial_cwd())? } + DenoSubcommand::Desktop(desktop_flags) => { + resolve_url_or_path(&desktop_flags.source_file, self.initial_cwd())? + } DenoSubcommand::Eval(_) => { let specifier = format!( "./$deno$eval.{}", @@ -1070,7 +1073,9 @@ impl CliOptions { if name.is_empty() { let maybe_subcommand_permissions = match &self.flags.subcommand { DenoSubcommand::Bench(_) => dir.to_bench_permissions_config()?, - DenoSubcommand::Compile(_) => dir.to_compile_permissions_config()?, + DenoSubcommand::Compile(_) | DenoSubcommand::Desktop(_) => { + dir.to_compile_permissions_config()? + } DenoSubcommand::Test(_) => dir.to_test_permissions_config()?, _ => None, }; @@ -1090,7 +1095,7 @@ impl CliOptions { .to_bench_permissions_config()? .filter(|permissions| !permissions.permissions.is_empty()) .map(|permissions| ("Bench", &permissions.base)), - DenoSubcommand::Compile(_) => dir + DenoSubcommand::Compile(_) | DenoSubcommand::Desktop(_) => dir .to_compile_permissions_config()? .filter(|permissions| !permissions.permissions.is_empty()) .map(|permissions| ("Compile", &permissions.base)), diff --git a/cli/factory.rs b/cli/factory.rs index 5b4f4e7681eb0d..548ba6d963dce8 100644 --- a/cli/factory.rs +++ b/cli/factory.rs @@ -635,6 +635,7 @@ impl CliFactory { | DenoSubcommand::Clean { .. } | DenoSubcommand::Compile { .. } | DenoSubcommand::Completions { .. } + | DenoSubcommand::Desktop { .. } | DenoSubcommand::Coverage { .. } | DenoSubcommand::Deploy { .. } | DenoSubcommand::Doc { .. } diff --git a/cli/lib.rs b/cli/lib.rs index 8766a9b4b8e3be..d0c1b7a71203e2 100644 --- a/cli/lib.rs +++ b/cli/lib.rs @@ -179,6 +179,9 @@ async fn run_subcommand( DenoSubcommand::Compile(compile_flags) => spawn_subcommand(async { tools::compile::compile(flags, compile_flags).await }), + DenoSubcommand::Desktop(desktop_flags) => spawn_subcommand(async { + tools::desktop::desktop(flags, desktop_flags).await + }), DenoSubcommand::Coverage(coverage_flags) => spawn_subcommand(async move { let reporter = crate::tools::coverage::reporter::create(coverage_flags.r#type.clone()); diff --git a/cli/rt/Cargo.toml b/cli/rt/Cargo.toml index 5c14486235de4e..9b0b52e4337307 100644 --- a/cli/rt/Cargo.toml +++ b/cli/rt/Cargo.toml @@ -29,6 +29,8 @@ deno_runtime = { workspace = true, features = ["include_js_files_for_snapshottin deno_core = { workspace = true, features = ["include_js_files_for_snapshotting"] } [dependencies] +async-trait.workspace = true +bincode.workspace = true deno_ast = { workspace = true, features = ["transpiling"] } deno_cache_dir = { workspace = true, features = ["sync"] } deno_config = { workspace = true, features = ["sync", "workspace"] } @@ -44,14 +46,12 @@ deno_runtime = { workspace = true, features = ["include_js_files_for_snapshottin deno_semver.workspace = true deno_snapshots.workspace = true deno_terminal.workspace = true -libsui.workspace = true -node_resolver.workspace = true -notify.workspace = true -async-trait.workspace = true -bincode.workspace = true import_map.workspace = true indexmap.workspace = true +libsui.workspace = true log = { workspace = true, features = ["serde"] } +node_resolver.workspace = true +notify.workspace = true rustls.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 456a0dfd8e0ef4..a28aa3cdd55a24 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -9,7 +9,6 @@ use std::sync::Arc; use deno_core::OpState; - // Re-export from runtime so denort_desktop can use them. pub use deno_runtime::ops::desktop::AutoUpdateState; pub use deno_runtime::ops::desktop::DesktopApi; @@ -17,30 +16,13 @@ pub use deno_runtime::ops::desktop::DesktopApi; /// JS code that exposes desktop APIs via `Deno.BrowserWindow` and `Deno.desktop`. pub const DESKTOP_JS: &str = r#" (() => { - const { - op_desktop_set_title, - op_desktop_set_size, - op_desktop_navigate, - op_desktop_execute_js, - op_desktop_close, - op_desktop_set_application_menu, - op_desktop_recv_menu_click, - } = Deno[Deno.internal].core.ops; - - class BrowserWindow { - constructor(options = {}) { - if (options.title) op_desktop_set_title(options.title); - if (options.width && options.height) op_desktop_set_size(options.width, options.height); - } - setTitle(title) { op_desktop_set_title(title); } - setSize(width, height) { op_desktop_set_size(width, height); } - navigate(url) { op_desktop_navigate(url); } - executeJs(script) { op_desktop_execute_js(script); } - close() { op_desktop_close(); } - setApplicationMenu(templateJson) { op_desktop_set_application_menu(templateJson); } - } + const { BrowserWindow } = Deno[Deno.internal].core.ops; + const BrowserWindowPrototype = BrowserWindow.prototype; Deno.BrowserWindow = BrowserWindow; + ObjectSetPrototypeOf(BrowserWindowPrototype, EventTargetPrototype); + defineEventHandler(BrowserWindowPrototype, ""); + // Poll for menu click events from the native side and dispatch // them as CustomEvents on globalThis. Defer start so the pending // op doesn't block the pre-module event loop tick used by HMR. @@ -139,7 +121,8 @@ pub fn desktop_auto_update_js( }})(); "#, version = match version { - Some(v) => format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\"")), + Some(v) => + format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\"")), None => "null".to_string(), }, rolled_back = if rolled_back { "true" } else { "false" }, diff --git a/cli/rt/run.rs b/cli/rt/run.rs index 16a1435f98821c..a9221544b50c48 100644 --- a/cli/rt/run.rs +++ b/cli/rt/run.rs @@ -890,9 +890,8 @@ pub async fn run_with_options( // use a dummy npm registry url let npm_registry_url = Url::parse("https://localhost/").unwrap(); let root_dir_url = Arc::new(Url::from_directory_path(&root_path).unwrap()); - let workspace_root_path = hmr_watch_dir - .clone() - .unwrap_or_else(|| root_path.clone()); + let workspace_root_path = + hmr_watch_dir.clone().unwrap_or_else(|| root_path.clone()); let workspace_root_dir_url = Arc::new(Url::from_directory_path(&workspace_root_path).unwrap()); let main_module = if let Some(url) = override_main_module { diff --git a/cli/rt_desktop/Cargo.toml b/cli/rt_desktop/Cargo.toml index 6d8242133c9119..ef4694d3a33446 100644 --- a/cli/rt_desktop/Cargo.toml +++ b/cli/rt_desktop/Cargo.toml @@ -31,6 +31,6 @@ libsui.workspace = true wef = { path = "../../../wef/capi" } log = { workspace = true, features = ["serde"] } -serde_json.workspace = true rustls.workspace = true +serde_json.workspace = true tokio.workspace = true diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 6e14c4eb91b28b..9636b987afeebc 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -21,10 +21,13 @@ use deno_core::anyhow::Context; use deno_core::anyhow::bail; use deno_core::error::AnyError; use deno_core::serde_json; +use deno_core::v8; use deno_lib::util::result::js_error_downcast_ref; use deno_lib::version::otel_runtime_config; use deno_runtime::fmt_errors::format_js_error; use deno_terminal::colors; +use tokio::sync::oneshot::error::RecvError; +use wef::Value; use denort::run::RunOptions; /// Port used for the embedded HTTP server. @@ -38,16 +41,155 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::set_title(title); } + fn get_window_size(&self) -> (i32, i32) { + wef::get_window_size() + } + fn set_window_size(&self, width: i32, height: i32) { wef::set_window_size(width, height); } + fn get_window_position(&self) -> (i32, i32) { + wef::get_window_position() + } + + fn set_window_position(&self, width: i32, height: i32) { + wef::set_window_position(width, height); + } + + fn is_resizeable(&self) -> bool { + wef::is_resizable() + } + + fn set_resizeable(&self, resizeable: bool) { + wef::set_resizable(resizeable); + } + + fn is_always_on_top(&self) -> bool { + wef::is_always_on_top() + } + + fn set_always_on_top(&self, always_on_top: bool) { + wef::set_always_on_top(always_on_top); + } + + fn is_visible(&self) -> bool { + wef::is_visible() + } + + fn show(&self) { + wef::show(); + } + + fn hide(&self) { + wef::hide(); + } + + fn focus(&self) { + wef::focus(); + } + + fn bind( + &self, + name: &str, + scope: &mut v8::PinScope<'_, '_>, + this: v8::Global, + cb: v8::Local, + ) { + wef::bind(name, |mut js_call| { + let this = v8::Local::new(scope, this); + let args = std::mem::take(&mut js_call.args) + .into_iter() + .map(|v| wef_value_to_v8(scope, v)) + .collect::>(); + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = &tc.init(); + + if let Some(ret) = cb.call(tc, this.into(), &args) { + // Attach .then()/.catch() to resolve/reject the JsCall + // when the promise settles. + if ret.is_promise() { + let promise: v8::Local = ret.try_into().unwrap(); + + // Box the JsCall into an Option so either handler can take + // ownership exactly once. Store as v8::External data. + let js_call_ptr = + Box::into_raw(Box::new(Some(js_call))) as *mut std::ffi::c_void; + let external = v8::External::new(tc, js_call_ptr); + + let on_fulfilled = v8::Function::builder( + |scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + _rv: v8::ReturnValue| { + let data = + v8::Local::::try_from(args.data()).unwrap(); + let js_call = unsafe { + Box::>::from_raw( + data.value() as *mut Option + ) + }; + if let Some(call) = *js_call { + call.resolve(v8_to_wef_value(scope, args.get(0))); + } + }, + ) + .data(external.into()) + .build(tc) + .unwrap(); + + let on_rejected = v8::Function::builder( + |scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + _rv: v8::ReturnValue| { + let data = + v8::Local::::try_from(args.data()).unwrap(); + let js_call = unsafe { + Box::>::from_raw( + data.value() as *mut Option + ) + }; + if let Some(call) = *js_call { + call.reject(v8_to_wef_value(scope, args.get(0))); + } + }, + ) + .data(external.into()) + .build(tc) + .unwrap(); + + promise.then2(tc, on_fulfilled, on_rejected); + } else { + js_call.resolve(v8_to_wef_value(tc, ret)); + } + } else if let Some(err) = tc.exception() { + js_call.reject(v8_to_wef_value(tc, err)); + } else { + let message = v8::String::new(tc, "unknown error").unwrap(); + let err = v8::Exception::error(tc, message.into()); + js_call.reject(v8_to_wef_value(tc, err.into())); + } + }); + } + + fn unbind(&self, name: &str) { + wef::unbind(name); + } + fn navigate(&self, url: &str) { wef::navigate(url); } - fn execute_js(&self, script: &str) { - wef::execute_js(script); + async fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, script: &str) -> Result, v8::Local> { + let (tx, rx) = tokio::sync::oneshot::channel(); + wef::execute_js(script, Some(|res| { + let _ = tx.send(res); + })); + + match rx.await.expect("execute_js channel failed") { + Ok(val) => Ok(wef_value_to_v8(scope, val)), + Err(err) => Err(wef_value_to_v8(scope, err)), + } } fn quit(&self) { @@ -67,6 +209,102 @@ impl denort::desktop::DesktopApi for WefDesktopApi { } } +fn wef_value_to_v8( + scope: &v8::PinScope<'_, '_>, + val: wef::Value, +) -> v8::Local { + match val { + wef::Value::Null => v8::null(scope).into(), + wef::Value::Bool(bool) => v8::Boolean::new(scope, bool).into(), + wef::Value::Int(int) => v8::Integer::new(scope, int).into(), + wef::Value::Double(double) => v8::Number::new(scope, double).into(), + wef::Value::String(str) => v8::String::new(scope, &str).into(), + wef::Value::List(list) => { + let elements = list + .into_iter() + .map(|v| wef_value_to_v8(scope, v)) + .collect::>(); + v8::Array::new_with_elements(scope, &elements).into() + } + wef::Value::Dict(dict) => { + let mut names = Vec::with_capacity(dict.len()); + let mut values = Vec::with_capacity(dict.len()); + + for (k, v) in dict { + names.push(v8::String::new(scope, &k).into()); + values.push(wef_value_to_v8(scope, v)); + } + + let prototype = v8::null(scope).into(); + v8::Object::with_prototype_and_properties( + scope, prototype, &names, &values, + ) + .into() + } + wef::Value::Binary(bin) => { + let len = bin.len(); + let backing_store = v8::ArrayBuffer::new_backing_store_from_vec(bin); + let backing_store = backing_store.make_shared(); + let ab = v8::ArrayBuffer::with_backing_store(scope, &backing_store); + let uint8_array = v8::Uint8Array::new(scope, ab, 0, len).unwrap(); + uint8_array.into() + } + } +} + +fn v8_to_wef_value( + scope: &v8::PinScope<'_, '_>, + val: v8::Local, +) -> wef::Value { + if val.is_null_or_undefined() { + wef::Value::Null + } else if val.is_boolean() { + wef::Value::Bool(val.boolean_value(scope)) + } else if val.is_int32() { + wef::Value::Int(val.int32_value(scope).unwrap_or(0)) + } else if val.is_number() { + wef::Value::Double(val.number_value(scope).unwrap_or(0.0)) + } else if val.is_string() { + let s = val.to_rust_string_lossy(scope); + wef::Value::String(s) + } else if val.is_array_buffer_view() { + let view: v8::Local = val.try_into().unwrap(); + let len = view.byte_length(); + let mut buf = vec![0u8; len]; + view.copy_contents(&mut buf); + wef::Value::Binary(buf) + } else if val.is_array() { + let arr: v8::Local = val.try_into().unwrap(); + let len = arr.length(); + let mut list = Vec::with_capacity(len as usize); + for i in 0..len { + if let Some(elem) = arr.get_index(scope, i) { + list.push(v8_to_wef_value(scope, elem)); + } + } + wef::Value::List(list) + } else if val.is_object() { + let obj: v8::Local = val.try_into().unwrap(); + let mut map = std::collections::HashMap::new(); + if let Some(names) = + obj.get_own_property_names(scope, v8::GetPropertyNamesArgs::default()) + { + for i in 0..names.length() { + if let Some(key) = names.get_index(scope, i) { + let key_str = key.to_rust_string_lossy(scope); + if let Some(value) = obj.get(scope, key) { + map.insert(key_str, v8_to_wef_value(scope, value)); + } + } + } + } + wef::Value::Dict(map) + } else { + // Fallback: coerce to string + wef::Value::String(val.to_rust_string_lossy(scope)) + } +} + /// Promote this dylib's symbols to the global symbol scope so that /// native addons loaded via `dlopen` (e.g. next-swc.node) can resolve /// NAPI function symbols from our library. @@ -495,7 +733,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let hmr_on_reload: Option = if hmr_watch_dir.is_some() && !is_framework_dev { Some(Box::new(|| { - wef::execute_js("location.reload()"); + wef::execute_js("location.reload()", None); })) } else { None @@ -516,9 +754,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let auto_update_version = auto_update_state .as_ref() .and_then(|s| s.app_version.clone()); - let auto_update_rolled_back = auto_update_state - .as_ref() - .is_some_and(|s| s.rolled_back); + let auto_update_rolled_back = + auto_update_state.as_ref().is_some_and(|s| s.rolled_back); let run_opts = RunOptions { auto_serve: true, @@ -537,10 +774,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { auto_update_state, ); // Wire WEF menu click callback to the Deno runtime channel - let menu_tx = state - .borrow::() - .0 - .clone(); + let menu_tx = + state.borrow::().0.clone(); wef::set_menu_click_handler(move |id| { let _ = menu_tx.send(id.to_string()); }); diff --git a/cli/schemas/config-file.v1.json b/cli/schemas/config-file.v1.json index 8742c1ad81d7bb..1fdc893873dc7e 100644 --- a/cli/schemas/config-file.v1.json +++ b/cli/schemas/config-file.v1.json @@ -132,6 +132,78 @@ } } }, + "desktop": { + "type": "object", + "description": "Configuration for `deno desktop`.", + "additionalProperties": false, + "properties": { + "app": { + "type": "object", + "description": "Application metadata.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The application name." + }, + "icons": { + "type": "object", + "description": "Platform-specific icon paths.", + "additionalProperties": false, + "properties": { + "macos": { + "type": "string", + "description": "Icon path for macOS (.icns or .png)." + }, + "windows": { + "type": "string", + "description": "Icon path for Windows (.ico)." + }, + "linux": { + "type": "string", + "description": "Icon path for Linux (.png)." + } + } + } + } + }, + "backend": { + "type": "string", + "description": "WEF backend to use for the desktop app.", + "enum": ["webview", "cef", "servo"] + }, + "output": { + "type": "object", + "description": "Platform-specific output paths.", + "additionalProperties": false, + "properties": { + "macos": { + "type": "string", + "description": "Output path for macOS (e.g. MyApp.app, MyApp.dmg)." + }, + "windows": { + "type": "string", + "description": "Output path for Windows (e.g. MyApp.msi, MyApp.exe)." + }, + "linux": { + "type": "string", + "description": "Output path for Linux (e.g. MyApp.AppImage, MyApp.deb)." + } + } + }, + "release": { + "type": "object", + "description": "Release and auto-update configuration.", + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string", + "description": "Base URL for auto-update release artifacts." + } + } + } + } + }, "compilerOptions": { "type": "object", "description": "Instructs the TypeScript compiler how to compile .ts files.", diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index e3557e80d97956..728011243d3a40 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -1358,9 +1358,7 @@ fn get_dev_desktop_binary_path() -> Option { .any(|component| component == Component::Normal("target".as_ref())) { // Prefer release libdenort (optimized) over debug. - let target_dir = exec_path - .parent() - .and_then(|p| p.parent()); + let target_dir = exec_path.parent().and_then(|p| p.parent()); target_dir .and_then(|d| { get_libdenort_path(d.join("release").join("libdenort.dylib")) diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index f16304cb8232b0..285e523cf6eed9 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -43,8 +43,12 @@ const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; fn wef_backend_search_paths(backend: &str) -> Vec { match backend { "cef" => vec![ - PathBuf::from("/Users/divy/gh/wef/result-cef/Applications/wef.app/Contents/MacOS/wef"), - PathBuf::from("/Users/divy/gh/wef/result/Applications/wef.app/Contents/MacOS/wef"), + PathBuf::from( + "/Users/divy/gh/wef/result-cef/Applications/wef.app/Contents/MacOS/wef", + ), + PathBuf::from( + "/Users/divy/gh/wef/result/Applications/wef.app/Contents/MacOS/wef", + ), PathBuf::from("/Users/divy/gh/wef/cef/build/wef.app/Contents/MacOS/wef"), ], "servo" => vec![ @@ -52,9 +56,15 @@ fn wef_backend_search_paths(backend: &str) -> Vec { PathBuf::from("/Users/divy/gh/wef/servo/target/debug/wef_servo"), ], _ => vec![ - PathBuf::from("/Users/divy/gh/wef/result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview"), - PathBuf::from("/Users/divy/gh/wef/result/Applications/wef_webview.app/Contents/MacOS/wef_webview"), - PathBuf::from("/Users/divy/gh/wef/webview/build/wef_webview.app/Contents/MacOS/wef_webview"), + PathBuf::from( + "/Users/divy/gh/wef/result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview", + ), + PathBuf::from( + "/Users/divy/gh/wef/result/Applications/wef_webview.app/Contents/MacOS/wef_webview", + ), + PathBuf::from( + "/Users/divy/gh/wef/webview/build/wef_webview.app/Contents/MacOS/wef_webview", + ), ], } } @@ -315,8 +325,7 @@ async fn compile_binary( let cwd = cli_options.initial_cwd(); let framework = super::framework::detect_framework(cwd)?; let backend = compile_flags.backend.as_deref().unwrap_or("webview"); - run_desktop_hmr(&output_path, cwd, framework.as_ref(), backend) - .await?; + run_desktop_hmr(&output_path, cwd, framework.as_ref(), backend).await?; } else if compile_flags.desktop { // Package the dylib into a platform-specific app bundle. let bundle_path = @@ -659,7 +668,9 @@ fn convert_png_to_icns( let _ = std::fs::remove_dir_all(&iconset_dir); if !status.success() { - bail!("Failed to convert PNG to ICNS. Provide an .icns file directly or ensure iconutil is available."); + bail!( + "Failed to convert PNG to ICNS. Provide an .icns file directly or ensure iconutil is available." + ); } Ok(()) @@ -706,9 +717,9 @@ fn strip_cef_bloat(contents_dir: &Path) { fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), AnyError> { std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src).with_context(|| { - format!("Reading directory '{}'", src.display()) - })? { + for entry in std::fs::read_dir(src) + .with_context(|| format!("Reading directory '{}'", src.display()))? + { let entry = entry?; let ty = entry.file_type()?; let dest = dst.join(entry.file_name()); diff --git a/cli/tools/framework.rs b/cli/tools/framework.rs index 79e999201c9c6c..bd761d94831263 100644 --- a/cli/tools/framework.rs +++ b/cli/tools/framework.rs @@ -389,12 +389,10 @@ fn has_config_file(dir: &Path, base_name: &str) -> bool { } fn find_config_file(dir: &Path, base_name: &str) -> Option { - ["js", "mjs", "ts", "mts", "cjs"] - .iter() - .find_map(|ext| { - let file_name = format!("{base_name}.{ext}"); - dir.join(&file_name).exists().then_some(file_name) - }) + ["js", "mjs", "ts", "mts", "cjs"].iter().find_map(|ext| { + let file_name = format!("{base_name}.{ext}"); + dir.join(&file_name).exists().then_some(file_name) + }) } /// Read package.json dependencies. diff --git a/cli/tools/mod.rs b/cli/tools/mod.rs index 1c9f54cd3acdc5..04abad0ef4f6e7 100644 --- a/cli/tools/mod.rs +++ b/cli/tools/mod.rs @@ -7,6 +7,7 @@ pub mod clean; pub mod compile; pub mod coverage; pub mod deploy; +pub mod desktop; pub mod doc; pub mod fmt; pub mod framework; diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts new file mode 100644 index 00000000000000..40887627fb0f32 --- /dev/null +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -0,0 +1,103 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +/// +/// +/// +/// + +// TODO: hook this file up + +declare namespace Deno { + export {}; // stop default export type behavior + + export interface BrowserWindowOptions { + title?: string; + /** @default {800} */ + width?: number; + /** @default {600} */ + height?: number; + x?: number; + y?: number; + /** @default {true} */ + resizable?: boolean; + /** @default {false} */ + alwaysOnTop?: boolean; + } + + interface BrowserWindowObject { + [key: string]: BrowserWindowValue; + } + + type BrowserWindowValue = + | null + | boolean + | number + | string + | BrowserWindowObject + | BrowserWindowValue[] + | Uint8Array; + + export type WindowBindings = Record< + string, + ( + this: BrowserWindow, + ...args: BrowserWindowValue[] + ) => Promise + >; + + interface BrowserWindowEventMap { + } + + export class BrowserWindow extends EventTarget { + constructor(options?: BrowserWindowOptions); + + bind(name: N, fn: T[N]): void; + unbind(name: N): void; + executeJs(script: string): Promise; + + setTitle(title: string); + + getSize(): [number, number]; + setSize(width: number, height: number); + + getPosition(): [number, number]; + setPosition(x: number, y: number); + + isResizable(): boolean; + setResizable(resizable: boolean); + + isAlwaysOnTop(): boolean; + setAlwaysOnTop(alwaysOnTop: boolean); + + isClosed(): boolean; + close(): void; + + isVisible(): boolean; + show(): void; + hide(): void; + focus(): void; + navigate(url: string): void; + reload(): void; + + addEventListener( + type: K, + listener: (this: BrowserWindow, ev: BrowserWindowEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: BrowserWindow, ev: BrowserWindowEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; + } +} diff --git a/libs/config/deno_json/mod.rs b/libs/config/deno_json/mod.rs index 46d1424207b957..f334dadf70678f 100644 --- a/libs/config/deno_json/mod.rs +++ b/libs/config/deno_json/mod.rs @@ -770,6 +770,102 @@ pub struct CompileConfig { pub permissions: Option>, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopIconsConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopAppConfig { + pub name: Option, + pub icons: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopOutputConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopReleaseConfig { + #[serde(rename = "baseUrl")] + pub base_url: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopConfig { + pub app: Option, + pub backend: Option, + pub output: Option, + pub release: Option, +} + +impl SerializedDesktopConfig { + pub fn into_resolved(self) -> DesktopConfig { + DesktopConfig { + app: self.app.map(|a| DesktopAppConfig { + name: a.name, + icons: a.icons.map(|i| DesktopIconsConfig { + macos: i.macos, + windows: i.windows, + linux: i.linux, + }), + }), + backend: self.backend, + output: self.output.map(|o| DesktopOutputConfig { + macos: o.macos, + windows: o.windows, + linux: o.linux, + }), + release: self.release.map(|r| DesktopReleaseConfig { + base_url: r.base_url, + }), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopIconsConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopAppConfig { + pub name: Option, + pub icons: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopOutputConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopReleaseConfig { + pub base_url: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopConfig { + pub app: Option, + pub backend: Option, + pub output: Option, + pub release: Option, +} + #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(untagged)] pub enum LockConfig { @@ -1117,6 +1213,7 @@ pub struct ConfigFileJson { pub test: Option, pub bench: Option, pub compile: Option, + pub desktop: Option, pub lock: Option, pub exclude: Option, pub minimum_dependency_age: Option, @@ -1847,6 +1944,24 @@ impl ConfigFile { } } + pub fn to_desktop_config( + &self, + ) -> Result { + match self.json.desktop.clone() { + Some(config) => { + let serialized: SerializedDesktopConfig = + serde_json::from_value(config).map_err(|error| { + ToInvalidConfigError::Parse { + config: "desktop", + source: error, + } + })?; + Ok(serialized.into_resolved()) + } + None => Ok(DesktopConfig::default()), + } + } + pub fn to_fmt_config(&self) -> Result { match self.json.fmt.clone() { Some(config) => { diff --git a/libs/config/workspace/mod.rs b/libs/config/workspace/mod.rs index ccbe10eab9d5df..7077414271565f 100644 --- a/libs/config/workspace/mod.rs +++ b/libs/config/workspace/mod.rs @@ -49,6 +49,7 @@ use crate::deno_json::ConfigFileError; use crate::deno_json::ConfigFileRc; use crate::deno_json::ConfigFileReadError; use crate::deno_json::DeployConfig; +use crate::deno_json::DesktopConfig; use crate::deno_json::FmtConfig; use crate::deno_json::FmtOptionsConfig; use crate::deno_json::LinkConfigParseError; @@ -1602,6 +1603,7 @@ struct CachedDirectoryValues { permissions: OnceLock, bench: OnceLock, compile: OnceLock, + desktop: OnceLock, test: OnceLock, } @@ -2183,6 +2185,38 @@ impl WorkspaceDirectory { }) } + pub fn to_desktop_config( + &self, + ) -> Result<&DesktopConfig, ToInvalidConfigError> { + if let Some(config) = &self.cached.desktop.get() { + Ok(config) + } else { + let config = self.to_desktop_config_no_cache()?; + _ = self.cached.desktop.set(config); + Ok(self.cached.desktop.get().unwrap()) + } + } + + fn to_desktop_config_no_cache( + &self, + ) -> Result { + let member_config = match &self.deno_json.member { + Some(member) => member.to_desktop_config()?, + None => Default::default(), + }; + let root_config = match &self.deno_json.root { + Some(root) => root.to_desktop_config()?, + None => Default::default(), + }; + // Member config takes precedence over root for each field. + Ok(DesktopConfig { + app: member_config.app.or(root_config.app), + backend: member_config.backend.or(root_config.backend), + output: member_config.output.or(root_config.output), + release: member_config.release.or(root_config.release), + }) + } + pub fn to_tasks_config( &self, ) -> Result { diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index ba746b4443f074..8580d35458ffc1 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -87,8 +87,8 @@ indexmap.workspace = true libc.workspace = true log.workspace = true notify.workspace = true -qbsdiff.workspace = true once_cell.workspace = true +qbsdiff.workspace = true regex.workspace = true rustyline = { workspace = true, features = ["custom-bindings"] } same-file.workspace = true diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index be895bc7651609..9960ae4af11761 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -6,10 +6,13 @@ //! are stable. When `DesktopApi` is not present in OpState (non-desktop //! builds), the ops silently no-op. +use std::borrow::Cow; use std::sync::Arc; +use deno_core::FromV8; use deno_core::OpState; use deno_core::op2; +use deno_core::v8; /// Channel for receiving menu click events in the Deno runtime. pub struct MenuClickReceiver(pub tokio::sync::mpsc::UnboundedReceiver); @@ -24,64 +27,201 @@ pub fn create_menu_click_channel() -> (MenuClickSender, MenuClickReceiver) { /// runtime (denort_desktop) to bridge to the WEF backend. pub trait DesktopApi: Send + Sync + 'static { fn set_title(&self, title: &str); + + fn get_window_size(&self) -> (i32, i32); fn set_window_size(&self, width: i32, height: i32); + + fn get_window_position(&self) -> (i32, i32); + fn set_window_position(&self, width: i32, height: i32); + + fn is_resizeable(&self) -> bool; + fn set_resizeable(&self, resizeable: bool); + + fn is_always_on_top(&self) -> bool; + fn set_always_on_top(&self, always_on_top: bool); + fn is_visible(&self) -> bool; + fn show(&self); + fn hide(&self); + fn focus(&self); + + fn bind( + &self, + name: &str, + scope: &mut v8::PinScope<'_, '_>, + this: v8::Global, + cb: v8::Local, + ); + fn unbind(&self, name: &str); + fn navigate(&self, url: &str); - fn execute_js(&self, script: &str); + fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, script: &str) -> Result, v8::Local>; fn quit(&self); fn set_application_menu(&self, template_json: &str); } -fn try_api(state: &OpState) -> Option> { - state.try_borrow::>().map(|a| a.clone()) +struct BrowserWindow { + api: Arc, } -#[op2(fast)] -pub fn op_desktop_set_title(state: &mut OpState, #[string] title: &str) { - if let Some(api) = try_api(state) { - api.set_title(title); +// SAFETY: we're sure this can be GCed +unsafe impl deno_core::GarbageCollected for BrowserWindow { + fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} + + fn get_name(&self) -> &'static std::ffi::CStr { + c"BrowserWindow" } } -#[op2(fast)] -pub fn op_desktop_set_size( - state: &mut OpState, - #[smi] width: i32, - #[smi] height: i32, -) { - if let Some(api) = try_api(state) { - api.set_window_size(width, height); +impl deno_core::Resource for BrowserWindow { + fn name(&self) -> Cow<'_, str> { + "BrowserWindow".into() } } -#[op2(fast)] -pub fn op_desktop_navigate(state: &mut OpState, #[string] url: &str) { - if let Some(api) = try_api(state) { - api.navigate(url); +#[op2] +impl BrowserWindow { + #[constructor] + #[cppgc] + fn new( + state: &OpState, + #[scoped] options: Option, + ) -> BrowserWindow { + let api = state + .try_borrow::>() + .expect("desktop mode enabled") + .clone(); + if let Some(options) = options { + if let Some(title) = options.title { + api.set_title(&title); + } + api.set_window_size( + options.width.unwrap_or(800), + options.height.unwrap_or(600), + ); + } + + BrowserWindow { api } } -} -#[op2(fast)] -pub fn op_desktop_execute_js(state: &mut OpState, #[string] script: &str) { - if let Some(api) = try_api(state) { - api.execute_js(script); + #[fast] + fn bind( + &self, + scope: &mut v8::PinScope<'_, '_>, + #[this] this: v8::Global, + #[string] name: &str, + cb: v8::Local, + ) { + self.api.bind(name, scope, this, cb); } -} -#[op2(fast)] -pub fn op_desktop_close(state: &mut OpState) { - if let Some(api) = try_api(state) { - api.quit(); + #[fast] + fn unbind(&self, #[string] name: &str) { + self.api.unbind(name); } -} -#[op2(fast)] -pub fn op_desktop_set_application_menu( - state: &mut OpState, - #[string] template_json: &str, -) { - if let Some(api) = try_api(state) { - api.set_application_menu(template_json); + #[fast] + fn set_title(&self, #[string] title: &str) { + self.api.set_title(title); + } + + fn get_size(&self) -> (i32, i32) { + self.api.get_window_size() } + + #[fast] + fn set_size(&self, #[smi] width: i32, #[smi] height: i32) { + self.api.set_window_size(width, height); + } + + fn get_position(&self) -> (i32, i32) { + self.api.get_window_position() + } + + #[fast] + fn set_position(&self, #[smi] x: i32, #[smi] y: i32) { + self.api.set_window_position(x, y); + } + + #[fast] + fn is_resizeable(&self) -> bool { + self.api.is_resizeable() + } + + #[fast] + fn set_resizeable(&self, resizeable: bool) { + self.api.set_resizeable(resizeable); + } + + #[fast] + fn is_always_on_top(&self) -> bool { + self.api.is_always_on_top() + } + + #[fast] + fn set_always_on_top(&self, always_on_top: bool) { + self.api.set_always_on_top(always_on_top); + } + + #[fast] + fn is_closed(&self) -> bool { + todo!("implement") + } + + #[fast] + fn close(&self) { + self.api.quit(); + } + + #[fast] + fn is_visible(&self) -> bool { + self.api.is_visible() + } + + #[fast] + fn show(&self) { + self.api.show(); + } + + #[fast] + fn hide(&self) { + self.api.hide(); + } + + #[fast] + fn focus(&self) { + self.api.focus(); + } + + #[fast] + fn navigate(&self, #[string] url: &str) { + self.api.navigate(url); + } + + #[fast] + fn reload(&self) { + todo!("implement") + } + + fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, #[string] script: &str) -> Result, v8::Local> { + self.api.execute_js(scope, script) + } + + #[fast] + fn set_application_menu(&self, #[string] template_json: &str) { + // TODO + self.api.set_application_menu(template_json); + } +} + +#[derive(FromV8)] +struct BrowserWindowOptions { + title: Option, + width: Option, + height: Option, + x: Option, + y: Option, + resizable: Option, + always_on_top: Option, } /// State for the auto-update system, placed into OpState at init. @@ -179,14 +319,9 @@ pub fn op_desktop_confirm_update(state: &mut OpState) { deno_core::extension!( deno_desktop, ops = [ - op_desktop_set_title, - op_desktop_set_size, - op_desktop_navigate, - op_desktop_execute_js, - op_desktop_close, - op_desktop_set_application_menu, op_desktop_apply_patch, op_desktop_confirm_update, op_desktop_recv_menu_click, ], + objects = [BrowserWindow,], ); From ae266a673b209cd8aca1d0adc0fed5941043cb37 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Mon, 16 Mar 2026 01:26:33 +0100 Subject: [PATCH 005/137] keyboard events --- cli/rt/desktop.rs | 32 ++++++++++++++++++--- cli/rt_desktop/lib.rs | 21 ++++++++++++++ cli/tsc/dts/lib.deno.desktop.d.ts | 23 ++++++++++++++- runtime/ops/desktop.rs | 47 ++++++++++++++++++++++++++++++- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index a28aa3cdd55a24..a2cdf20d7dc6ef 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -21,12 +21,13 @@ pub const DESKTOP_JS: &str = r#" Deno.BrowserWindow = BrowserWindow; ObjectSetPrototypeOf(BrowserWindowPrototype, EventTargetPrototype); - defineEventHandler(BrowserWindowPrototype, ""); + defineEventHandler(BrowserWindowPrototype, "keydown"); + defineEventHandler(BrowserWindowPrototype, "keyup"); - // Poll for menu click events from the native side and dispatch - // them as CustomEvents on globalThis. Defer start so the pending - // op doesn't block the pre-module event loop tick used by HMR. + // Defer start so the pending op doesn't block the pre-module event loop tick used by HMR. addEventListener("load", () => { + // Poll for menu click events from the native side and dispatch + // them as CustomEvents on globalThis. (async () => { while (true) { const id = await op_desktop_recv_menu_click(); @@ -34,6 +35,23 @@ pub const DESKTOP_JS: &str = r#" dispatchEvent(new CustomEvent("menuclick", { detail: { id } })); } })(); + // Poll for keyboard events from the native side and dispatch + // them as KeyboardEvent on globalThis. + (async () => { + while (true) { + const ev = await op_desktop_recv_keyboard_event(); + if (ev == null) break; + dispatchEvent(new KeyboardEvent(ev.type, { + key: ev.key, + code: ev.code, + shiftKey: ev.shift, + ctrlKey: ev.control, + altKey: ev.alt, + metaKey: ev.meta, + repeat: ev.repeat, + })); + } + })(); }, { once: true }); })(); "#; @@ -129,7 +147,9 @@ pub fn desktop_auto_update_js( ) } +pub use deno_runtime::ops::desktop::KeyboardEventSender; pub use deno_runtime::ops::desktop::MenuClickSender; +pub use deno_runtime::ops::desktop::create_keyboard_event_channel; pub use deno_runtime::ops::desktop::create_menu_click_channel; /// Place the DesktopApi and optional AutoUpdateState into OpState. @@ -149,4 +169,8 @@ pub fn init_desktop_state( let (tx, rx) = create_menu_click_channel(); state.put(rx); state.put(tx); + // Create keyboard event channel so op_desktop_recv_keyboard_event can work + let (kb_tx, kb_rx) = create_keyboard_event_channel(); + state.put(kb_rx); + state.put(kb_tx); } diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 9636b987afeebc..1201e8472d1c65 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -779,6 +779,27 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { wef::set_menu_click_handler(move |id| { let _ = menu_tx.send(id.to_string()); }); + // Wire WEF keyboard events to the Deno runtime channel + let kb_tx = state + .borrow::() + .0 + .clone(); + wef::on_keyboard_event(move |ev| { + let _ = + kb_tx.send(deno_runtime::ops::desktop::KeyboardEventData { + r#type: match ev.state { + wef::KeyState::Pressed => "keydown", + wef::KeyState::Released => "keyup", + }, + key: ev.key, + code: ev.code, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + repeat: ev.repeat, + }); + }); })), override_main_module: None, auto_update_version, diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 40887627fb0f32..349f7a2b328bb2 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -45,10 +45,31 @@ declare namespace Deno { ) => Promise >; + /** Constrains T to a record of async binding functions. */ + type ValidBindings = { + [K in keyof T]: ( + this: BrowserWindow, + ...args: BrowserWindowValue[] + ) => Promise; + }; + interface BrowserWindowEventMap { + keydown: KeyboardEvent; + keyup: KeyboardEvent; } - export class BrowserWindow extends EventTarget { + type BrowserWindowEventHandlers = { + [K in keyof BrowserWindowEventMap as `on${K}`]: + | ((this: BrowserWindow, ev: BrowserWindowEventMap[K]) => any) + | null; + }; + + export interface BrowserWindow = WindowBindings> + extends BrowserWindowEventHandlers {} + + export class BrowserWindow< + T extends ValidBindings = WindowBindings, + > extends EventTarget { constructor(options?: BrowserWindowOptions); bind(name: N, fn: T[N]): void; diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 9960ae4af11761..8f5bb455eaaff0 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -9,7 +9,8 @@ use std::borrow::Cow; use std::sync::Arc; -use deno_core::FromV8; +use deno_core::{FromV8}; +use deno_core::{ToV8}; use deno_core::OpState; use deno_core::op2; use deno_core::v8; @@ -23,6 +24,32 @@ pub fn create_menu_click_channel() -> (MenuClickSender, MenuClickReceiver) { (MenuClickSender(tx), MenuClickReceiver(rx)) } +#[derive(Debug, Clone, ToV8)] +pub struct KeyboardEventData { + /// "keydown" or "keyup" + pub r#type: &'static str, + pub key: String, + pub code: String, + pub shift: bool, + pub control: bool, + pub alt: bool, + pub meta: bool, + pub repeat: bool, +} + +pub struct KeyboardEventReceiver( + pub tokio::sync::mpsc::UnboundedReceiver, +); +pub struct KeyboardEventSender( + pub tokio::sync::mpsc::UnboundedSender, +); + +pub fn create_keyboard_event_channel( +) -> (KeyboardEventSender, KeyboardEventReceiver) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + (KeyboardEventSender(tx), KeyboardEventReceiver(rx)) +} + /// Trait for desktop window operations. Implemented by the desktop /// runtime (denort_desktop) to bridge to the WEF backend. pub trait DesktopApi: Send + Sync + 'static { @@ -303,6 +330,23 @@ async fn op_desktop_recv_menu_click( } } +#[op2] +async fn op_desktop_recv_keyboard_event( + state: std::rc::Rc>, +) -> Option { + let rx = { + let mut s = state.borrow_mut(); + s.try_take::() + }; + if let Some(mut rx) = rx { + let result = rx.0.recv().await; + state.borrow_mut().put(rx); + result + } else { + std::future::pending().await + } +} + #[op2(fast)] pub fn op_desktop_confirm_update(state: &mut OpState) { if let Some(s) = state.try_borrow::() { @@ -322,6 +366,7 @@ deno_core::extension!( op_desktop_apply_patch, op_desktop_confirm_update, op_desktop_recv_menu_click, + op_desktop_recv_keyboard_event, ], objects = [BrowserWindow,], ); From f88040d9a4f731b3f2a0e1c4a495db00b81f1ea9 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Mon, 16 Mar 2026 10:19:37 +0100 Subject: [PATCH 006/137] fixes --- cli/rt_desktop/lib.rs | 12 +++++++----- cli/tsc/dts/lib.deno.desktop.d.ts | 1 + runtime/ops/desktop.rs | 8 +++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 1201e8472d1c65..5c4da4be9b23e4 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -180,16 +180,18 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::navigate(url); } - async fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, script: &str) -> Result, v8::Local> { + fn execute_js<'a>(&self, scope: &mut v8::PinScope<'a, '_>, script: &str) -> std::pin::Pin, v8::Local<'a, v8::Value>>> + 'a>> { let (tx, rx) = tokio::sync::oneshot::channel(); wef::execute_js(script, Some(|res| { let _ = tx.send(res); })); - match rx.await.expect("execute_js channel failed") { - Ok(val) => Ok(wef_value_to_v8(scope, val)), - Err(err) => Err(wef_value_to_v8(scope, err)), - } + Box::pin(async move { + match rx.await.expect("execute_js channel failed") { + Ok(val) => Ok(wef_value_to_v8(scope, val)), + Err(err) => Err(wef_value_to_v8(scope, err)), + } + }) } fn quit(&self) { diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 349f7a2b328bb2..a2cdded5a427e1 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -74,6 +74,7 @@ declare namespace Deno { bind(name: N, fn: T[N]): void; unbind(name: N): void; + /** @throws {BrowserWindowValue} */ executeJs(script: string): Promise; setTitle(title: string); diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 8f5bb455eaaff0..d9e7db94937954 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -7,6 +7,8 @@ //! builds), the ops silently no-op. use std::borrow::Cow; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use deno_core::{FromV8}; @@ -81,7 +83,7 @@ pub trait DesktopApi: Send + Sync + 'static { fn unbind(&self, name: &str); fn navigate(&self, url: &str); - fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, script: &str) -> Result, v8::Local>; + fn execute_js<'a>(&self, scope: &mut v8::PinScope<'a, '_>, script: &str) -> Pin, v8::Local<'a, v8::Value>>> + 'a>>; fn quit(&self); fn set_application_menu(&self, template_json: &str); } @@ -229,8 +231,8 @@ impl BrowserWindow { todo!("implement") } - fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, #[string] script: &str) -> Result, v8::Local> { - self.api.execute_js(scope, script) + async fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, #[string] script: &str) -> Result, v8::Local> { + self.api.execute_js(scope, script).await } #[fast] From 7e427099a840d74d13ac2f9059acf513d3f65a21 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Mon, 16 Mar 2026 17:42:42 +0100 Subject: [PATCH 007/137] fix --- cli/tools/desktop.rs | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 cli/tools/desktop.rs diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs new file mode 100644 index 00000000000000..3d885b3d5a2e21 --- /dev/null +++ b/cli/tools/desktop.rs @@ -0,0 +1,105 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +use std::sync::Arc; + +use deno_core::error::AnyError; + +use crate::args::CompileFlags; +use crate::args::DenoSubcommand; +use crate::args::DesktopFlags; +use crate::args::Flags; +use crate::factory::CliFactory; + +pub async fn desktop( + mut flags: Flags, + desktop_flags: DesktopFlags, +) -> Result<(), AnyError> { + let all_targets = desktop_flags.all_targets; + + // Read desktop config from deno.json to use as defaults. + // CLI flags take precedence over config file values. + let desktop_config = { + let config_flags = flags.clone(); + let factory = CliFactory::from_flags(Arc::new(config_flags)); + let cli_options = factory.cli_options()?; + cli_options.start_dir.to_desktop_config()?.clone() + }; + + // Resolve icon: CLI --icon > config icons (platform-specific) + let icon = desktop_flags.icon.or_else(|| { + desktop_config.app.as_ref().and_then(|app| { + app.icons.as_ref().and_then(|icons| { + if cfg!(target_os = "macos") { + icons.macos.clone() + } else if cfg!(target_os = "windows") { + icons.windows.clone() + } else { + icons.linux.clone() + } + }) + }) + }); + + // Resolve backend: CLI --backend > config backend + let backend = desktop_flags + .backend + .or_else(|| desktop_config.backend.clone()); + + // Resolve output: CLI --output > config output (platform-specific) + let output = desktop_flags.output.or_else(|| { + desktop_config.output.as_ref().and_then(|out| { + if cfg!(target_os = "macos") { + out.macos.clone() + } else if cfg!(target_os = "windows") { + out.windows.clone() + } else { + out.linux.clone() + } + }) + }); + + // Use app name from config if no output specified + let output = output + .or_else(|| desktop_config.app.as_ref().and_then(|app| app.name.clone())); + + let compile_flags = CompileFlags { + source_file: desktop_flags.source_file, + output, + args: desktop_flags.args, + target: desktop_flags.target, + no_terminal: false, + icon, + include: desktop_flags.include, + exclude: desktop_flags.exclude, + eszip: false, + self_extracting: false, + desktop: true, + hmr: desktop_flags.hmr, + backend, + }; + + // Update the subcommand in flags so compile internals see it correctly. + flags.subcommand = DenoSubcommand::Compile(compile_flags.clone()); + + if all_targets { + let targets = [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ]; + for target in targets { + log::info!("Building for target: {}", target); + let mut target_flags = compile_flags.clone(); + target_flags.target = Some(target.to_string()); + let mut target_outer_flags = flags.clone(); + target_outer_flags.subcommand = + DenoSubcommand::Compile(target_flags.clone()); + super::compile::compile(target_outer_flags, target_flags).await?; + } + Ok(()) + } else { + super::compile::compile(flags, compile_flags).await + } +} From c53a06a27a748bacc6a01bc0e9f940ebb3267c45 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 17 Mar 2026 16:19:08 +0100 Subject: [PATCH 008/137] fix --- cli/args/flags.rs | 2 +- cli/rt_desktop/lib.rs | 27 ++- grafana.json | 300 ++++++++++++++++++++++++ grafana_README.md | 23 ++ grafanav3.json | 508 +++++++++++++++++++++++++++++++++++++++++ input.png | Bin 0 -> 1174 bytes main.ts | 0 offscreen_canvas.js | 73 ++++++ otel | 38 +++ output.png | Bin 0 -> 1814 bytes replay.md | 324 ++++++++++++++++++++++++++ runtime/ops/desktop.rs | 24 +- test2.js | 9 + test3.ts | 20 ++ tests/util/std | 2 +- 15 files changed, 1323 insertions(+), 27 deletions(-) create mode 100644 grafana.json create mode 100644 grafana_README.md create mode 100644 grafanav3.json create mode 100644 input.png create mode 100644 main.ts create mode 100644 offscreen_canvas.js create mode 100644 otel create mode 100644 output.png create mode 100644 replay.md create mode 100644 test2.js create mode 100644 test3.ts diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 86eb888ab3576c..5ec5f6270e20e3 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -2022,7 +2022,7 @@ heading! { DEPENDENCY_MANAGEMENT_HEADING = "Dependency management options", UNSTABLE_HEADING = "Unstable options"; - 12 + 13 } fn help_parse(flags: &mut Flags, mut subcommand: Command) { diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 5c4da4be9b23e4..147ff8be2d8feb 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -89,15 +89,14 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::focus(); } - fn bind( + fn bind<'a>( &self, name: &str, - scope: &mut v8::PinScope<'_, '_>, - this: v8::Global, - cb: v8::Local, + scope: &mut v8::PinScope<'a, '_>, + cb: v8::Local<'a, v8::Function>, ) { wef::bind(name, |mut js_call| { - let this = v8::Local::new(scope, this); + let this = v8::null(scope); let args = std::mem::take(&mut js_call.args) .into_iter() .map(|v| wef_value_to_v8(scope, v)) @@ -180,7 +179,7 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::navigate(url); } - fn execute_js<'a>(&self, scope: &mut v8::PinScope<'a, '_>, script: &str) -> std::pin::Pin, v8::Local<'a, v8::Value>>> + 'a>> { + fn execute_js<'a>(&self, scope: &'a mut v8::PinScope<'a, '_>, script: &str) -> std::pin::Pin, v8::Local<'a, v8::Value>>> + 'a>> { let (tx, rx) = tokio::sync::oneshot::channel(); wef::execute_js(script, Some(|res| { let _ = tx.send(res); @@ -211,16 +210,16 @@ impl denort::desktop::DesktopApi for WefDesktopApi { } } -fn wef_value_to_v8( - scope: &v8::PinScope<'_, '_>, +fn wef_value_to_v8<'a>( + scope: &v8::PinScope<'a, '_>, val: wef::Value, -) -> v8::Local { +) -> v8::Local<'a, v8::Value> { match val { wef::Value::Null => v8::null(scope).into(), wef::Value::Bool(bool) => v8::Boolean::new(scope, bool).into(), wef::Value::Int(int) => v8::Integer::new(scope, int).into(), wef::Value::Double(double) => v8::Number::new(scope, double).into(), - wef::Value::String(str) => v8::String::new(scope, &str).into(), + wef::Value::String(str) => v8::String::new(scope, &str).unwrap().into(), wef::Value::List(list) => { let elements = list .into_iter() @@ -233,7 +232,7 @@ fn wef_value_to_v8( let mut values = Vec::with_capacity(dict.len()); for (k, v) in dict { - names.push(v8::String::new(scope, &k).into()); + names.push(v8::String::new(scope, &k).unwrap().into()); values.push(wef_value_to_v8(scope, v)); } @@ -254,9 +253,9 @@ fn wef_value_to_v8( } } -fn v8_to_wef_value( - scope: &v8::PinScope<'_, '_>, - val: v8::Local, +fn v8_to_wef_value<'a>( + scope: &v8::PinScope<'a, '_>, + val: v8::Local<'a, v8::Value>, ) -> wef::Value { if val.is_null_or_undefined() { wef::Value::Null diff --git a/grafana.json b/grafana.json new file mode 100644 index 00000000000000..576f1ba9c063e5 --- /dev/null +++ b/grafana.json @@ -0,0 +1,300 @@ +{ + "__inputs": [ + { + "name": "DS_LOKI", + "label": "Loki", + "description": "Loki datasource for OTel logs", + "type": "datasource", + "pluginId": "loki", + "pluginName": "Loki" + } + ], + "annotations": { + "list": [] + }, + "description": "Visualizes Deno permission access audit events emitted via OpenTelemetry (DENO_AUDIT_PERMISSIONS)", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "title": "Permission Accesses Over Time", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 50, + "stacking": { + "mode": "normal" + }, + "barWidthFactor": 0.9 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "interval": "$interval", + "targets": [ + { + "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$interval]))", + "legendFormat": "{{deno_permission_type}}", + "refId": "A" + } + ] + }, + { + "title": "Permission Access Count by Type", + "type": "piechart", + "gridPos": { + "h": 10, + "w": 8, + "x": 0, + "y": 8 + }, + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "sum" + ] + } + }, + "targets": [ + { + "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range]))", + "legendFormat": "{{deno_permission_type}}", + "refId": "A" + } + ] + }, + { + "title": "Top Accessed Resources", + "type": "table", + "gridPos": { + "h": 10, + "w": 16, + "x": 8, + "y": 8 + }, + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "deno_permission_type" + }, + "properties": [ + { + "id": "displayName", + "value": "Permission" + }, + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "deno_permission_value" + }, + "properties": [ + { + "id": "displayName", + "value": "Resource" + }, + { + "id": "custom.width", + "value": 300 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Value #A" + }, + "properties": [ + { + "id": "displayName", + "value": "Count" + } + ] + } + ] + }, + "options": { + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Count" + } + ] + }, + "targets": [ + { + "expr": "topk(10, sum by (deno_permission_type, deno_permission_value) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range])))", + "legendFormat": "{{deno_permission_type}}: {{deno_permission_value}}", + "refId": "A", + "instant": true + } + ] + }, + { + "title": "Recent Permission Accesses", + "description": "Click a row to expand and see the full stack trace (requires DENO_TRACE_PERMISSIONS=1).", + "type": "logs", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 28 + }, + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "options": { + "showTime": true, + "showLabels": false, + "showCommonLabels": false, + "wrapLogMessage": false, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + }, + "targets": [ + { + "expr": "{service_name=~\"$service_name\"} | scope_name = `deno.permission.access` | line_format `{{.deno_permission_outcome}} [{{.deno_permission_type}}] {{.deno_permission_value}}`", + "refId": "A" + } + ] + } + ], + "schemaVersion": 39, + "tags": [ + "deno", + "permissions", + "otel" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": "1m", + "value": "1m" + }, + "label": "Interval", + "name": "interval", + "options": [ + { "selected": false, "text": "1s", "value": "1s" }, + { "selected": false, "text": "5s", "value": "5s" }, + { "selected": false, "text": "10s", "value": "10s" }, + { "selected": false, "text": "15s", "value": "15s" }, + { "selected": false, "text": "30s", "value": "30s" }, + { "selected": true, "text": "1m", "value": "1m" }, + { "selected": false, "text": "5m", "value": "5m" }, + { "selected": false, "text": "15m", "value": "15m" }, + { "selected": false, "text": "30m", "value": "30m" }, + { "selected": false, "text": "1h", "value": "1h" } + ], + "query": "1s,5s,10s,15s,30s,1m,5m,15m,30m,1h", + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "type": "interval" + }, + { + "name": "service_name", + "label": "Service", + "type": "query", + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "query": { + "type": 1, + "label": "service_name", + "stream": "" + }, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "includeAll": true, + "allValue": ".+", + "multi": false, + "refresh": 2, + "sort": 1 + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deno Permission Audit", + "uid": "", + "version": 1 +} diff --git a/grafana_README.md b/grafana_README.md new file mode 100644 index 00000000000000..893abd047a2017 --- /dev/null +++ b/grafana_README.md @@ -0,0 +1,23 @@ +# Deno Permission Audit - Grafana Dashboard + +Visualizes Deno permission access audit events emitted via OpenTelemetry. + +## Setup + +Run Deno with OTel permission auditing enabled: + +```bash +OTEL_DENO=true DENO_AUDIT_PERMISSIONS=otel deno run main.ts +``` + +Set `DENO_TRACE_PERMISSIONS=1` to include stack traces. + +## Panels + +- **Permission Accesses Over Time** - Stacked bar chart of accesses bucketed by + the configurable **Interval** variable +- **Permission Access Count by Type** - Donut chart of total accesses by + permission type over the selected time range +- **Top Accessed Resources** - Table of the 10 most accessed resources +- **Recent Permission Accesses** - Log panel of individual events (expand rows + for stack traces) diff --git a/grafanav3.json b/grafanav3.json new file mode 100644 index 00000000000000..cef0e27e99fa8e --- /dev/null +++ b/grafanav3.json @@ -0,0 +1,508 @@ +{ + "__inputs": [ + { + "name": "DS_LOKI", + "label": "Loki", + "description": "", + "type": "datasource", + "pluginId": "loki", + "pluginName": "Loki" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.4.0" + }, + { + "type": "panel", + "id": "logs", + "name": "Logs", + "version": "" + }, + { + "type": "datasource", + "id": "loki", + "name": "Loki", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "piechart", + "name": "Pie chart", + "version": "" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualizes Deno permission access audit events emitted via OpenTelemetry (DENO_AUDIT_PERMISSIONS)", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.9, + "drawStyle": "bars", + "fillOpacity": 50, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "interval": "$interval", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$interval]))", + "legendFormat": "{{deno_permission_type}}", + "refId": "A", + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + } + } + ], + "title": "Permission Accesses Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 9 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "value", + "percent" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range]))", + "legendFormat": "{{deno_permission_type}}", + "refId": "A", + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + } + } + ], + "title": "Permission Access Count by Type", + "type": "piechart" + }, + { + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "deno_permission_type" + }, + "properties": [ + { + "id": "displayName", + "value": "Permission" + }, + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "deno_permission_value" + }, + "properties": [ + { + "id": "displayName", + "value": "Resource" + }, + { + "id": "custom.width", + "value": 300 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Value #A" + }, + "properties": [ + { + "id": "displayName", + "value": "Count" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 16, + "x": 8, + "y": 9 + }, + "id": 3, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Count" + } + ] + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "expr": "topk(10, sum by (deno_permission_type, deno_permission_value) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range])))", + "instant": true, + "legendFormat": "{{deno_permission_type}}: {{deno_permission_value}}", + "refId": "A", + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + } + } + ], + "title": "Top Accessed Resources", + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "description": "Click a row to expand and see the full stack trace (requires DENO_TRACE_PERMISSIONS=1).", + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 4, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "expr": "{service_name=~\"$service_name\"} | scope_name = `deno.permission.access` | line_format `{{.deno_permission_outcome}} [{{.deno_permission_type}}] {{.deno_permission_value}}`", + "refId": "A", + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + } + } + ], + "title": "Recent Permission Accesses", + "type": "logs" + } + ], + "refresh": "", + "schemaVersion": 40, + "tags": [ + "deno", + "permissions", + "otel" + ], + "templating": { + "list": [ + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "10s", + "value": "10s" + }, + "label": "Interval", + "name": "interval", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "5s", + "value": "5s" + }, + { + "selected": true, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "15s", + "value": "15s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "15m", + "value": "15m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + } + ], + "query": "1s,5s,10s,15s,30s,1m,5m,15m,30m,1h", + "type": "interval" + }, + { + "allValue": ".+", + "current": {}, + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "includeAll": true, + "label": "Service", + "name": "service_name", + "options": [], + "query": { + "label": "service_name", + "stream": "", + "type": 1 + }, + "refresh": 2, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deno Permission Audit", + "uid": "eff70btahqn0ga", + "version": 2, + "weekStart": "" +} diff --git a/input.png b/input.png new file mode 100644 index 0000000000000000000000000000000000000000..d2c597bc418ee00253016b50fc66dfb9298aba04 GIT binary patch literal 1174 zcmWkuaZD3e93}^cz*(4J&(Q@i;I^dFXc&cv5;MkuKs7Lpcpo%_MjkRi2*kd7sZYQwz%gX@4YX--6WNp`4XP%J1gqL_yx{?y&UW?{EDqsvN56jWS>?( z=-)5IZj*ix>+jX0)&}R_Eqt*}vKWxwLXc!()uDXp+A+j?PiVq*E^+hOIi&ZB^T#l< zz*W~{81IP}ZecD9+bHFtMA>X(ux?#6@yg>wqM)g)V~ozWcSdk&@=_!dcd+tFZ(D^2 zms&aX38Sm+nj4o!Idx$pO`V#ksGCmbCI|mf#7@mEohQB@0)Z&$bt9fYxGQi%F*2UIge_ zk|_+9&MXzm0doDBQx|kQ+j5cXrl{y1q$z(D*BLJir8=PVf=U<)()~UG!2ZoAoGBbrAg5(=2p(9x81VApc5d2LP!40U#bJ*Tm z)?XbAe)}3xW10WDvO%&#B?O1?vD4R-ieE_PM36K(-K$jGC7JOcsc4#4DsGcZ0+Lf zXg1J&`@U5PEfKt1>vlJLMEC|X&}}O9RdHaCV0Ww>=|$*WAvW9l`~|vDnq9~Ot`3Cs zY-2Wo+8lDZjXj1jf@>Re~}#ArWp`J5@qk>aIjsU)QP< zR#FL6n^`28V{{w|3cWs(8K$F2Fe5!6nJ5rNg6NeBf**y!Hh9R7rn7oE3V70HG4q5{ zbSRBRG|2sl778qD&@Iy4BhUH_K5d)3r@x0G6F_l znO0qGv2Z;DFMf2GUMl9Wyv6cn3bgBeSl-waZiBh%LEK(_7qh|om=W9_YfVptuf&Hs;Ms%23>gKV$Jbw%2J+wBA+e;oI<6-wWA{Iw5^t4ZBU@O5B1$t~pQ yeoB(S1ZJ_k_N^AGbMBuqRQo$h8XJ3C{cPWII31_?F8}q<6Ocn`GW#>w&;AENbs4At literal 0 HcmV?d00001 diff --git a/main.ts b/main.ts new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/offscreen_canvas.js b/offscreen_canvas.js new file mode 100644 index 00000000000000..b0300bae5f5f3b --- /dev/null +++ b/offscreen_canvas.js @@ -0,0 +1,73 @@ +const offscreenCanvas = new OffscreenCanvas(200, 200); +const webgpuContext = offscreenCanvas.getContext("webgpu"); +const adapter = await navigator.gpu.requestAdapter(); +const device = await adapter?.requestDevice(); +device.onuncapturederror = (e) => console.error(e.error.message); + +webgpuContext.configure({ + device, + format: "rgba8unorm", +}); + +const shaderCode = ` +@vertex +fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4 { + let x = f32(i32(in_vertex_index) - 1); + let y = f32(i32(in_vertex_index & 1u) * 2 - 1); + return vec4(x, y, 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(1.0, 0.0, 0.0, 1.0); +} +`; + +const shaderModule = device.createShaderModule({ + code: shaderCode, +}); + +const pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [], +}); + +const renderPipeline = device.createRenderPipeline({ + layout: pipelineLayout, + vertex: { + module: shaderModule, + entryPoint: "vs_main", + }, + fragment: { + module: shaderModule, + entryPoint: "fs_main", + targets: [ + { + format: "rgba8unorm", + }, + ], + }, +}); + +const view = webgpuContext.getCurrentTexture().createView(); + +const encoder = device.createCommandEncoder(); +const renderPass = encoder.beginRenderPass({ + colorAttachments: [ + { + view, + storeOp: "store", + loadOp: "clear", + clearValue: [0, 1, 0, 1], + }, + ], +}); +renderPass.setPipeline(renderPipeline); +renderPass.draw(3, 1); +renderPass.end(); +device.queue.submit([encoder.finish()]); + +const outputBlob = await offscreenCanvas.convertToBlob({ + type: "image/png", +}); + +await Deno.writeFile("./output.png", outputBlob.stream()); diff --git a/otel b/otel new file mode 100644 index 00000000000000..c6fbe830acd089 --- /dev/null +++ b/otel @@ -0,0 +1,38 @@ +{"v":1,"datetime":"2026-03-05T00:23:19Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:20Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:21Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:22Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:23Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:24Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:25Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:26Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:27Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:28Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:29Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:30Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:31Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:32Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:33Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:34Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:35Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:36Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:37Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:38Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:39Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:40Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:41Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:42Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:43Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:44Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:45Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:46Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:47Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:48Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:49Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:50Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:51Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:52Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:53Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:54Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:55Z","permission":"sys","value":"hostname"} +{"v":1,"datetime":"2026-03-05T00:23:56Z","permission":"sys","value":"hostname"} diff --git a/output.png b/output.png new file mode 100644 index 0000000000000000000000000000000000000000..2453ae60b617d09ed08007eff64d9253d34e97af GIT binary patch literal 1814 zcmcIl{ZCs}7;Xs&D>B^TZWmZ!8z>80*F{Mt`>LA-L?@tG*EV6m6v`5#h>o-yiC1BB z!DCY0R3tOQxgRub($61;gQei;Kt{ZoNwb*6bi2lwkSJ>m;kuRk`d)k9{Rf_;ZBCx& zJkR@ePKR5Y8;eXOCWFCH)bxVwRs5aPKLt7Xy>(aJj|Rgdzc$(SAMzUiykq>NvgvGP z(|YC~ooKY#+-~<_J1&pDRu=w`bzGNrICK}cyP?5$YU2E;Nj~Uyc=|n_7FoAE_*$Z^ zKPlB#JFXrR4Wohf+(W@Di`KGfyCsL!h{2QVTEAcPgE!Z)`jS{Sr}(8cp9g~XeT{D? z3%k>kNVxc&F0f}X(7uD2XB5Br<9WMdb+%%BF)!@Biv%MPE|fF#>4aS_CWXJl-kYwr zVNkKwBOcc;$7*EYYIHU zt)QI2ivj?z2FT9Lt(Yo(n2l70870mVP({|I0$fGwugN;Bj!h|$Phv&}b)r^Ia44pZ zT~}Zi!L*!)DH@5C3q8qBcdi>!!2_-W}W2De4+ z7KP@CsxYNURRnKgU==kp!IvW{Tvw!0f;TW26Ez2gwh~rhL6M3HUc&-tO_A*kk1v`S z6jA@uI4GMLq-p;P?%M^3y^@_@7{W;#bY_Yhc!OKI`;vt{Y3l|)57x5@y9EV5C4mzt zaMP8MpXUdPU++uec)|{9a;4x{z3t4j9N+}sQ6!v6z(yiGiUb^4`8iVP>`O|IGoTv* zZy{kesxDDXm!sp0)}e${!&8p?5_UKtY7c1aUKWG*Wp_GtiqGAWu2{riEw{w}1hg7g z=3#^0z^prDB`)wRJMap_b^%UPmem--N>mL|5+hhvDTl#&Qrw4%e}q-Jktb0VLqZ9I zE+RaMgr2A>+c@Fd2oj!VP)mfhx&mIl{iL8<6f&TvMyf@EC!+eO3}>(mQU!xnQrLt9 z4AMuf$k#1)GuTXoTqN{{RUcJ)0@Id;6LGriOV~TuE5JT}AU=eJb+|ISsj!E!3@LZI zm#+VLXWKG-kcjiw?p~+|1Vdjsro%T-a2l67`Y2G;Vl;RJxKg-PT zbHaN%q5fz(!xiLfI^nn=4{<`XfUjt%JN*?`$j3JLrBas9Z`5Cyq8-YF6@Ix5|KV14 oBg@axx3EpfmUzp%$i1sJZoDw>{JEmSiT`(orut@E@IdF_zw1sH0ssI2 literal 0 HcmV?d00001 diff --git a/replay.md b/replay.md new file mode 100644 index 00000000000000..d455ce31e33d6f --- /dev/null +++ b/replay.md @@ -0,0 +1,324 @@ +# `deno --record` / `deno replay` Design Document + +## Overview + +Deterministic record-and-replay as a runtime mode. Recording is a transparent flag on `deno run`. Replay is a dedicated subcommand that reconstructs past executions and integrates with Chrome DevTools for time-travel debugging. + +No APIs. No imports. No code changes. + +--- + +## Recording + +### Usage + +```bash +deno run --record=trace.bin server.ts +deno run --record=trace.bin --record-limit=500mb server.ts +``` + +### What Gets Recorded + +Every source of nondeterminism that flows through Deno's ops layer: + +| Category | Ops | Recorded Data | +|----------|-----|---------------| +| Time | `Date.now()`, `performance.now()` | Returned timestamp | +| Randomness | `Math.random()`, `crypto.getRandomValues()` | Returned bytes | +| Network | `fetch`, `Deno.connect`, `Deno.listen` | Request/response pairs, byte streams | +| Filesystem | `Deno.readFile`, `Deno.stat`, `Deno.readDir` | Return values, file contents | +| Subprocess | `Deno.Command` | stdout, stderr, exit code | +| Environment | `Deno.env.get`, `Deno.hostname` | Returned strings | +| Timers | `setTimeout`, `setInterval` | Scheduling order and fire sequence | +| Async scheduling | Event loop | Op completion order | + +### What Does NOT Get Recorded + +- CPU-bound computation (deterministic given same inputs) +- V8 internals (JIT, GC, IC — these are invisible to JS) +- Module source code (captured by reference via content hash) + +### Trace Format + +Binary format, streaming. The runtime appends entries as ops complete — no buffering entire responses in memory. + +``` +┌─────────────────────────────────────────┐ +│ Header │ +│ magic: "DENO_TRACE\0" │ +│ version: u32 │ +│ deno_version: string │ +│ v8_version: string │ +│ arch: string │ +│ os: string │ +│ module_graph_hash: [u8; 32] │ +│ permissions: PermissionSet │ +│ recorded_at: u64 (unix ms) │ +│ entry_point: string │ +├─────────────────────────────────────────┤ +│ Module Table │ +│ [specifier, content_hash, size]... │ +├─────────────────────────────────────────┤ +│ Op Events (streaming, append-only) │ +│ ┌───────────────────────────────┐ │ +│ │ sequence_id: u64 │ │ +│ │ timestamp_us: u64 │ │ +│ │ op_name: interned string │ │ +│ │ async_id: u64 │ │ +│ │ payload_len: u32 │ │ +│ │ payload: [u8] (V8 serialized)│ │ +│ └───────────────────────────────┘ │ +│ ...repeated... │ +├─────────────────────────────────────────┤ +│ Checkpoints (periodic, for fast seek) │ +│ [sequence_id, file_offset]... │ +└─────────────────────────────────────────┘ +``` + +### Checkpoints + +Every N ops (configurable, default ~10,000), the runtime writes a checkpoint: a V8 heap snapshot at that point plus the file offset. This enables fast seeking during replay — instead of replaying from the start, jump to the nearest checkpoint and replay forward. + +### Ring Buffer Mode + +```bash +deno run --record=trace.bin --record-limit=500mb server.ts +``` + +Keeps only the most recent N megabytes of trace data. When the limit is hit, old op events are discarded (but the header and module table are preserved). Useful for long-running servers where you only care about the last few minutes before a crash. + +### Recording Overhead + +Target: < 5% CPU overhead, bounded memory. + +This is achievable because: +- Ops already return structured data — we're copying it, not intercepting syscalls +- V8 serialization is fast for the small payloads most ops return +- File I/O for the trace is append-only and can be buffered +- No binary translation, no JIT interposition, no ptrace + +--- + +## Replay + +### Usage + +```bash +# Basic replay — re-executes the recorded program deterministically +deno replay trace.bin + +# Replay with DevTools debugging +deno replay trace.bin --inspect +deno replay trace.bin --inspect-brk # pause at first statement + +# Print a summary without replaying +deno replay trace.bin --info + +# Validate trace against current source (are modules unchanged?) +deno replay trace.bin --validate +``` + +### How Replay Works + +1. **Load header** — verify deno/v8 version compatibility, check module hashes +2. **Restore module graph** — load modules from disk (hashes must match, or error) +3. **Execute with ops intercepted** — instead of performing real I/O, every op returns the next recorded payload from the trace +4. **Enforce scheduling order** — async ops complete in the exact sequence they were recorded, regardless of actual timing + +The replayed program observes the exact same world as the original execution. `Date.now()` returns the recorded timestamp. `fetch()` returns the recorded response. `Math.random()` returns the recorded value. From JS's perspective, time has not passed and the network has not changed. + +### Divergence Detection + +If the replayed code takes a different path than the recording (e.g. modules changed, or a bug in the replay engine), the runtime detects the divergence: + +``` +error: Replay divergence at op #48,291 + Expected: op_fetch_send (async_id: 1042) + Got: op_read_file (async_id: 1043) + + This usually means source code has changed since the recording. + Run `deno replay trace.bin --validate` to check module hashes. +``` + +--- + +## DevTools Integration + +### Connecting + +```bash +deno replay trace.bin --inspect +# Debugger listening on ws://127.0.0.1:9229/... +# Open chrome://inspect in Chrome +``` + +The replay session speaks the standard Chrome DevTools Protocol (CDP). Any CDP client works — Chrome, VS Code, WebStorm. + +### Standard Debugging (works immediately) + +Everything you'd expect from `--inspect` works during replay: +- Breakpoints (line, conditional, logpoint) +- Step over / into / out +- Scope inspection, watch expressions +- Console evaluation +- Call stack navigation +- Source maps + +The difference: breakpoints are perfectly reproducible. Hit "restart" and you land in the exact same state with the exact same data. + +### Time-Travel Controls (new CDP extensions) + +Replay adds new capabilities to the debugger: + +#### Reverse Continue + +Run backwards from the current pause point to the previous breakpoint hit. Implemented by: find the nearest checkpoint before the target, replay forward to that point. + +``` +CDP: Debugger.reverseContinue +``` + +#### Step Back + +Step to the previous statement. Same mechanism — checkpoint + forward replay — but the UX is a single "step back" button. + +``` +CDP: Debugger.stepBack +``` + +#### Seek to Op + +Jump to any recorded op by sequence ID. The DevTools timeline shows all ops; clicking one seeks to that point in execution. + +``` +CDP: Runtime.seekToOp { sequenceId: 48291 } +``` + +#### Async Causal Chain + +Given any op, walk backwards through the chain of async operations that caused it. "This fetch was awaited by this function, which was called from this event handler, which was triggered by this timer, which was set during module init." + +``` +CDP: Runtime.getAsyncCausalChain { asyncId: 1042 } +→ [ + { asyncId: 1042, op: "op_fetch_send", location: "server.ts:42" }, + { asyncId: 891, op: "op_timer", location: "server.ts:38" }, + { asyncId: 0, op: "top_level", location: "server.ts:1" } + ] +``` + +### DevTools UI Extensions + +Chrome DevTools supports custom panels via extensions. Deno could ship a companion extension that adds: + +**Timeline Panel** — a horizontal timeline of all recorded ops, color-coded by category (net=blue, fs=green, timer=yellow). Click any op to seek. Zoom in/out. Shows wall-clock time from the recording. + +**Async Graph** — a visual DAG of async causality. Nodes are ops, edges are "caused by" relationships. Click a node to seek and inspect. + +**Op Inspector** — when paused at a breakpoint, shows the recorded ops that are "in flight" at that moment, with their full request/response payloads. + +--- + +## CLI Reference + +### `deno run --record` + +``` +FLAGS: + --record= + Enable recording. Writes trace to the specified file. + File is created or overwritten. + + --record-limit= + Maximum trace size. Ring-buffer mode — keeps the most + recent data within the budget. Accepts kb, mb, gb suffixes. + Default: unlimited. + + --record-filter= + Comma-separated list of op categories to record. + Categories: net, fs, time, random, env, subprocess, timer, all. + Default: all. + + --record-checkpoint-interval= + Write a heap checkpoint every N ops. + Lower = faster seeking, larger trace. + Default: 10000. +``` + +### `deno replay` + +``` +USAGE: + deno replay [FLAGS] + +FLAGS: + --inspect + Open a CDP debugging session. Connect Chrome DevTools + or any CDP client. + + --inspect-brk + Same as --inspect, but pause before the first statement. + + --inspect-addr= + Address for the CDP WebSocket. Default: 127.0.0.1:9229. + + --info + Print trace metadata and exit. Shows: entry point, + deno/v8 version, duration, op count, module list, + permissions, file size. + + --validate + Check module hashes against source on disk. Reports which + files have changed since the recording. Does not replay. + + --seek= + Start replay paused at the given op sequence ID. + Useful for jumping directly to a known point of interest. + + --speed= + Replay wall-clock speed relative to recorded time. + Default: max (as fast as possible). + Use --speed=1 for real-time, --speed=0.5 for half-speed. +``` + +--- + +## Interaction with Other Features + +### Permissions + +Replay does not perform real I/O, so no permissions are needed. The `deno replay` subcommand ignores `--allow-*` flags — it's replaying recorded data, not accessing the system. + +### Snapshots (`Deno.snapshot`) + +Recording and snapshots are complementary: +- A snapshot captures a *state* for fast restore +- A recording captures an *execution* for debugging + +A snapshot could be embedded as the first checkpoint in a trace, giving instant seek to "just after initialization." + +### `--watch` Mode + +`--record` and `--watch` are mutually exclusive. Restarting on file change would create a new execution — start a new recording instead. + +### Testing + +```bash +deno test --record=test-trace.bin +``` + +Records the entire test run. On failure, replay the trace to debug the exact conditions that caused the failure — including any randomness, timing, or network responses. + +--- + +## Open Questions + +1. **Trace portability** — can a trace recorded on Linux be replayed on macOS? The ops return the same types, but path separators, env vars, etc. differ. Probably: yes for most ops, no for fs paths. + +2. **Worker threads** — workers are separate isolates. Record each worker's ops independently, with a shared sequence counter for cross-worker ordering? + +3. **FFI** — `Deno.dlopen` calls bypass the ops layer entirely. Options: refuse to record programs using FFI, or record at the FFI boundary (return values only, not internal native state). + +4. **Trace sharing** — recordings contain full network responses, file contents, env vars. Sensitive data. Need a `deno replay trace.bin --redact` that strips payloads but preserves structure for sharing bug reports? + +5. **Partial replay** — can you replay just a single request to a server, rather than the entire execution from startup? Requires identifying the async tree rooted at that request and replaying only those ops. diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index d9e7db94937954..fe58b99789ef54 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -73,12 +73,11 @@ pub trait DesktopApi: Send + Sync + 'static { fn hide(&self); fn focus(&self); - fn bind( + fn bind<'a>( &self, name: &str, - scope: &mut v8::PinScope<'_, '_>, - this: v8::Global, - cb: v8::Local, + scope: &mut v8::PinScope<'a, '_>, + cb: v8::Local<'a, v8::Function>, ); fn unbind(&self, name: &str); @@ -133,14 +132,13 @@ impl BrowserWindow { } #[fast] - fn bind( + fn bind<'a>( &self, - scope: &mut v8::PinScope<'_, '_>, - #[this] this: v8::Global, + scope: &mut v8::PinScope<'a, '_>, #[string] name: &str, - cb: v8::Local, + cb: v8::Local<'a, v8::Function>, ) { - self.api.bind(name, scope, this, cb); + self.api.bind(name, scope, cb); } #[fast] @@ -231,8 +229,12 @@ impl BrowserWindow { todo!("implement") } - async fn execute_js(&self, scope: &mut v8::PinScope<'_, '_>, #[string] script: &str) -> Result, v8::Local> { - self.api.execute_js(scope, script).await + #[fast] + fn execute_js(&self, #[string] _script: &str) { + todo!("implement execute_js — async + v8 scope borrowing needs a different approach") + /* + self.api.execute_js(scope, script).await + */ } #[fast] diff --git a/test2.js b/test2.js new file mode 100644 index 00000000000000..180c27730063f4 --- /dev/null +++ b/test2.js @@ -0,0 +1,9 @@ +const inputBlob = new Blob([await Deno.readFile("./input.png")], { + type: "image/png", +}); +const bitmap = await createImageBitmap(inputBlob); +const canvas = new OffscreenCanvas(200, 200); +const bitmaprenderer = canvas.getContext("bitmaprenderer"); +bitmaprenderer.transferFromImageBitmap(bitmap); +const outputBlob = await canvas.convertToBlob(); +await Deno.writeFile("./output.png", outputBlob.stream()); diff --git a/test3.ts b/test3.ts new file mode 100644 index 00000000000000..17160974901a0a --- /dev/null +++ b/test3.ts @@ -0,0 +1,20 @@ +setInterval(async () => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); + Deno.hostname(); +}, 500); + +setInterval(async () => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); + Deno.env.get("foo"); +}, 1100); + +setInterval(async () => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); + Deno.readTextFile("test3.ts"); +}, 900); + +setInterval(async () => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); + fetch("https://example.com"); + console.log("test2"); +}, 1000); diff --git a/tests/util/std b/tests/util/std index 63b3348ab11941..9c5b7101545e53 160000 --- a/tests/util/std +++ b/tests/util/std @@ -1 +1 @@ -Subproject commit 63b3348ab11941b92e3ab4da8429b32e488f8df1 +Subproject commit 9c5b7101545e53f031b6e7757da655d5d8ee3fc2 From a5a79b474ca490772b87e18a03c7fe5260b4d19c Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 18 Mar 2026 15:32:22 +0100 Subject: [PATCH 009/137] remove unrelated files --- grafana.json | 300 -------------------------- grafana_README.md | 23 -- grafanav3.json | 508 -------------------------------------------- input.png | Bin 1174 -> 0 bytes offscreen_canvas.js | 73 ------- otel | 38 ---- output.png | Bin 1814 -> 0 bytes replay.md | 324 ---------------------------- test2.js | 9 - test3.ts | 20 -- 10 files changed, 1295 deletions(-) delete mode 100644 grafana.json delete mode 100644 grafana_README.md delete mode 100644 grafanav3.json delete mode 100644 input.png delete mode 100644 offscreen_canvas.js delete mode 100644 otel delete mode 100644 output.png delete mode 100644 replay.md delete mode 100644 test2.js delete mode 100644 test3.ts diff --git a/grafana.json b/grafana.json deleted file mode 100644 index 576f1ba9c063e5..00000000000000 --- a/grafana.json +++ /dev/null @@ -1,300 +0,0 @@ -{ - "__inputs": [ - { - "name": "DS_LOKI", - "label": "Loki", - "description": "Loki datasource for OTel logs", - "type": "datasource", - "pluginId": "loki", - "pluginName": "Loki" - } - ], - "annotations": { - "list": [] - }, - "description": "Visualizes Deno permission access audit events emitted via OpenTelemetry (DENO_AUDIT_PERMISSIONS)", - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "title": "Permission Accesses Over Time", - "type": "timeseries", - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 0 - }, - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "bars", - "fillOpacity": 50, - "stacking": { - "mode": "normal" - }, - "barWidthFactor": 0.9 - } - }, - "overrides": [] - }, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "interval": "$interval", - "targets": [ - { - "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$interval]))", - "legendFormat": "{{deno_permission_type}}", - "refId": "A" - } - ] - }, - { - "title": "Permission Access Count by Type", - "type": "piechart", - "gridPos": { - "h": 10, - "w": 8, - "x": 0, - "y": 8 - }, - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "fieldConfig": { - "defaults": {}, - "overrides": [] - }, - "options": { - "legend": { - "displayMode": "table", - "placement": "right", - "values": [ - "value", - "percent" - ] - }, - "pieType": "donut", - "reduceOptions": { - "calcs": [ - "sum" - ] - } - }, - "targets": [ - { - "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range]))", - "legendFormat": "{{deno_permission_type}}", - "refId": "A" - } - ] - }, - { - "title": "Top Accessed Resources", - "type": "table", - "gridPos": { - "h": 10, - "w": 16, - "x": 8, - "y": 8 - }, - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "fieldConfig": { - "defaults": {}, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Time" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "deno_permission_type" - }, - "properties": [ - { - "id": "displayName", - "value": "Permission" - }, - { - "id": "custom.width", - "value": 120 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "deno_permission_value" - }, - "properties": [ - { - "id": "displayName", - "value": "Resource" - }, - { - "id": "custom.width", - "value": 300 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Value #A" - }, - "properties": [ - { - "id": "displayName", - "value": "Count" - } - ] - } - ] - }, - "options": { - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Count" - } - ] - }, - "targets": [ - { - "expr": "topk(10, sum by (deno_permission_type, deno_permission_value) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range])))", - "legendFormat": "{{deno_permission_type}}: {{deno_permission_value}}", - "refId": "A", - "instant": true - } - ] - }, - { - "title": "Recent Permission Accesses", - "description": "Click a row to expand and see the full stack trace (requires DENO_TRACE_PERMISSIONS=1).", - "type": "logs", - "gridPos": { - "h": 12, - "w": 24, - "x": 0, - "y": 28 - }, - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "options": { - "showTime": true, - "showLabels": false, - "showCommonLabels": false, - "wrapLogMessage": false, - "prettifyLogMessage": false, - "enableLogDetails": true, - "sortOrder": "Descending", - "dedupStrategy": "none" - }, - "targets": [ - { - "expr": "{service_name=~\"$service_name\"} | scope_name = `deno.permission.access` | line_format `{{.deno_permission_outcome}} [{{.deno_permission_type}}] {{.deno_permission_value}}`", - "refId": "A" - } - ] - } - ], - "schemaVersion": 39, - "tags": [ - "deno", - "permissions", - "otel" - ], - "templating": { - "list": [ - { - "current": { - "selected": true, - "text": "1m", - "value": "1m" - }, - "label": "Interval", - "name": "interval", - "options": [ - { "selected": false, "text": "1s", "value": "1s" }, - { "selected": false, "text": "5s", "value": "5s" }, - { "selected": false, "text": "10s", "value": "10s" }, - { "selected": false, "text": "15s", "value": "15s" }, - { "selected": false, "text": "30s", "value": "30s" }, - { "selected": true, "text": "1m", "value": "1m" }, - { "selected": false, "text": "5m", "value": "5m" }, - { "selected": false, "text": "15m", "value": "15m" }, - { "selected": false, "text": "30m", "value": "30m" }, - { "selected": false, "text": "1h", "value": "1h" } - ], - "query": "1s,5s,10s,15s,30s,1m,5m,15m,30m,1h", - "auto": false, - "auto_count": 30, - "auto_min": "10s", - "type": "interval" - }, - { - "name": "service_name", - "label": "Service", - "type": "query", - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "query": { - "type": 1, - "label": "service_name", - "stream": "" - }, - "current": { - "selected": true, - "text": "All", - "value": "$__all" - }, - "includeAll": true, - "allValue": ".+", - "multi": false, - "refresh": 2, - "sort": 1 - } - ] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deno Permission Audit", - "uid": "", - "version": 1 -} diff --git a/grafana_README.md b/grafana_README.md deleted file mode 100644 index 893abd047a2017..00000000000000 --- a/grafana_README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Deno Permission Audit - Grafana Dashboard - -Visualizes Deno permission access audit events emitted via OpenTelemetry. - -## Setup - -Run Deno with OTel permission auditing enabled: - -```bash -OTEL_DENO=true DENO_AUDIT_PERMISSIONS=otel deno run main.ts -``` - -Set `DENO_TRACE_PERMISSIONS=1` to include stack traces. - -## Panels - -- **Permission Accesses Over Time** - Stacked bar chart of accesses bucketed by - the configurable **Interval** variable -- **Permission Access Count by Type** - Donut chart of total accesses by - permission type over the selected time range -- **Top Accessed Resources** - Table of the 10 most accessed resources -- **Recent Permission Accesses** - Log panel of individual events (expand rows - for stack traces) diff --git a/grafanav3.json b/grafanav3.json deleted file mode 100644 index cef0e27e99fa8e..00000000000000 --- a/grafanav3.json +++ /dev/null @@ -1,508 +0,0 @@ -{ - "__inputs": [ - { - "name": "DS_LOKI", - "label": "Loki", - "description": "", - "type": "datasource", - "pluginId": "loki", - "pluginName": "Loki" - } - ], - "__elements": {}, - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "11.4.0" - }, - { - "type": "panel", - "id": "logs", - "name": "Logs", - "version": "" - }, - { - "type": "datasource", - "id": "loki", - "name": "Loki", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "piechart", - "name": "Pie chart", - "version": "" - }, - { - "type": "panel", - "id": "table", - "name": "Table", - "version": "" - }, - { - "type": "panel", - "id": "timeseries", - "name": "Time series", - "version": "" - } - ], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "description": "Visualizes Deno permission access audit events emitted via OpenTelemetry (DENO_AUDIT_PERMISSIONS)", - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.9, - "drawStyle": "bars", - "fillOpacity": 50, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 1, - "interval": "$interval", - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "11.4.0", - "targets": [ - { - "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$interval]))", - "legendFormat": "{{deno_permission_type}}", - "refId": "A", - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - } - } - ], - "title": "Permission Accesses Over Time", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "mappings": [] - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 9 - }, - "id": 2, - "options": { - "legend": { - "displayMode": "table", - "placement": "right", - "showLegend": true, - "values": [ - "value", - "percent" - ] - }, - "pieType": "donut", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "11.4.0", - "targets": [ - { - "expr": "sum by (deno_permission_type) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range]))", - "legendFormat": "{{deno_permission_type}}", - "refId": "A", - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - } - } - ], - "title": "Permission Access Count by Type", - "type": "piechart" - }, - { - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Time" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "deno_permission_type" - }, - "properties": [ - { - "id": "displayName", - "value": "Permission" - }, - { - "id": "custom.width", - "value": 120 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "deno_permission_value" - }, - "properties": [ - { - "id": "displayName", - "value": "Resource" - }, - { - "id": "custom.width", - "value": 300 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Value #A" - }, - "properties": [ - { - "id": "displayName", - "value": "Count" - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 16, - "x": 8, - "y": 9 - }, - "id": 3, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Count" - } - ] - }, - "pluginVersion": "11.4.0", - "targets": [ - { - "expr": "topk(10, sum by (deno_permission_type, deno_permission_value) (count_over_time({service_name=~\"$service_name\"} | scope_name = `deno.permission.access` [$__range])))", - "instant": true, - "legendFormat": "{{deno_permission_type}}: {{deno_permission_value}}", - "refId": "A", - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - } - } - ], - "title": "Top Accessed Resources", - "type": "table" - }, - { - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "description": "Click a row to expand and see the full stack trace (requires DENO_TRACE_PERMISSIONS=1).", - "gridPos": { - "h": 11, - "w": 24, - "x": 0, - "y": 17 - }, - "id": 4, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": false - }, - "pluginVersion": "11.4.0", - "targets": [ - { - "expr": "{service_name=~\"$service_name\"} | scope_name = `deno.permission.access` | line_format `{{.deno_permission_outcome}} [{{.deno_permission_type}}] {{.deno_permission_value}}`", - "refId": "A", - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - } - } - ], - "title": "Recent Permission Accesses", - "type": "logs" - } - ], - "refresh": "", - "schemaVersion": 40, - "tags": [ - "deno", - "permissions", - "otel" - ], - "templating": { - "list": [ - { - "auto": false, - "auto_count": 30, - "auto_min": "10s", - "current": { - "text": "10s", - "value": "10s" - }, - "label": "Interval", - "name": "interval", - "options": [ - { - "selected": false, - "text": "1s", - "value": "1s" - }, - { - "selected": false, - "text": "5s", - "value": "5s" - }, - { - "selected": true, - "text": "10s", - "value": "10s" - }, - { - "selected": false, - "text": "15s", - "value": "15s" - }, - { - "selected": false, - "text": "30s", - "value": "30s" - }, - { - "selected": false, - "text": "1m", - "value": "1m" - }, - { - "selected": false, - "text": "5m", - "value": "5m" - }, - { - "selected": false, - "text": "15m", - "value": "15m" - }, - { - "selected": false, - "text": "30m", - "value": "30m" - }, - { - "selected": false, - "text": "1h", - "value": "1h" - } - ], - "query": "1s,5s,10s,15s,30s,1m,5m,15m,30m,1h", - "type": "interval" - }, - { - "allValue": ".+", - "current": {}, - "datasource": { - "type": "loki", - "uid": "${DS_LOKI}" - }, - "includeAll": true, - "label": "Service", - "name": "service_name", - "options": [], - "query": { - "label": "service_name", - "stream": "", - "type": 1 - }, - "refresh": 2, - "sort": 1, - "type": "query" - } - ] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deno Permission Audit", - "uid": "eff70btahqn0ga", - "version": 2, - "weekStart": "" -} diff --git a/input.png b/input.png deleted file mode 100644 index d2c597bc418ee00253016b50fc66dfb9298aba04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1174 zcmWkuaZD3e93}^cz*(4J&(Q@i;I^dFXc&cv5;MkuKs7Lpcpo%_MjkRi2*kd7sZYQwz%gX@4YX--6WNp`4XP%J1gqL_yx{?y&UW?{EDqsvN56jWS>?( z=-)5IZj*ix>+jX0)&}R_Eqt*}vKWxwLXc!()uDXp+A+j?PiVq*E^+hOIi&ZB^T#l< zz*W~{81IP}ZecD9+bHFtMA>X(ux?#6@yg>wqM)g)V~ozWcSdk&@=_!dcd+tFZ(D^2 zms&aX38Sm+nj4o!Idx$pO`V#ksGCmbCI|mf#7@mEohQB@0)Z&$bt9fYxGQi%F*2UIge_ zk|_+9&MXzm0doDBQx|kQ+j5cXrl{y1q$z(D*BLJir8=PVf=U<)()~UG!2ZoAoGBbrAg5(=2p(9x81VApc5d2LP!40U#bJ*Tm z)?XbAe)}3xW10WDvO%&#B?O1?vD4R-ieE_PM36K(-K$jGC7JOcsc4#4DsGcZ0+Lf zXg1J&`@U5PEfKt1>vlJLMEC|X&}}O9RdHaCV0Ww>=|$*WAvW9l`~|vDnq9~Ot`3Cs zY-2Wo+8lDZjXj1jf@>Re~}#ArWp`J5@qk>aIjsU)QP< zR#FL6n^`28V{{w|3cWs(8K$F2Fe5!6nJ5rNg6NeBf**y!Hh9R7rn7oE3V70HG4q5{ zbSRBRG|2sl778qD&@Iy4BhUH_K5d)3r@x0G6F_l znO0qGv2Z;DFMf2GUMl9Wyv6cn3bgBeSl-waZiBh%LEK(_7qh|om=W9_YfVptuf&Hs;Ms%23>gKV$Jbw%2J+wBA+e;oI<6-wWA{Iw5^t4ZBU@O5B1$t~pQ yeoB(S1ZJ_k_N^AGbMBuqRQo$h8XJ3C{cPWII31_?F8}q<6Ocn`GW#>w&;AENbs4At diff --git a/offscreen_canvas.js b/offscreen_canvas.js deleted file mode 100644 index b0300bae5f5f3b..00000000000000 --- a/offscreen_canvas.js +++ /dev/null @@ -1,73 +0,0 @@ -const offscreenCanvas = new OffscreenCanvas(200, 200); -const webgpuContext = offscreenCanvas.getContext("webgpu"); -const adapter = await navigator.gpu.requestAdapter(); -const device = await adapter?.requestDevice(); -device.onuncapturederror = (e) => console.error(e.error.message); - -webgpuContext.configure({ - device, - format: "rgba8unorm", -}); - -const shaderCode = ` -@vertex -fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4 { - let x = f32(i32(in_vertex_index) - 1); - let y = f32(i32(in_vertex_index & 1u) * 2 - 1); - return vec4(x, y, 0.0, 1.0); -} - -@fragment -fn fs_main() -> @location(0) vec4 { - return vec4(1.0, 0.0, 0.0, 1.0); -} -`; - -const shaderModule = device.createShaderModule({ - code: shaderCode, -}); - -const pipelineLayout = device.createPipelineLayout({ - bindGroupLayouts: [], -}); - -const renderPipeline = device.createRenderPipeline({ - layout: pipelineLayout, - vertex: { - module: shaderModule, - entryPoint: "vs_main", - }, - fragment: { - module: shaderModule, - entryPoint: "fs_main", - targets: [ - { - format: "rgba8unorm", - }, - ], - }, -}); - -const view = webgpuContext.getCurrentTexture().createView(); - -const encoder = device.createCommandEncoder(); -const renderPass = encoder.beginRenderPass({ - colorAttachments: [ - { - view, - storeOp: "store", - loadOp: "clear", - clearValue: [0, 1, 0, 1], - }, - ], -}); -renderPass.setPipeline(renderPipeline); -renderPass.draw(3, 1); -renderPass.end(); -device.queue.submit([encoder.finish()]); - -const outputBlob = await offscreenCanvas.convertToBlob({ - type: "image/png", -}); - -await Deno.writeFile("./output.png", outputBlob.stream()); diff --git a/otel b/otel deleted file mode 100644 index c6fbe830acd089..00000000000000 --- a/otel +++ /dev/null @@ -1,38 +0,0 @@ -{"v":1,"datetime":"2026-03-05T00:23:19Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:20Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:21Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:22Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:23Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:24Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:25Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:26Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:27Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:28Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:29Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:30Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:31Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:32Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:33Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:34Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:35Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:36Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:37Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:38Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:39Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:40Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:41Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:42Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:43Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:44Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:45Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:46Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:47Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:48Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:49Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:50Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:51Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:52Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:53Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:54Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:55Z","permission":"sys","value":"hostname"} -{"v":1,"datetime":"2026-03-05T00:23:56Z","permission":"sys","value":"hostname"} diff --git a/output.png b/output.png deleted file mode 100644 index 2453ae60b617d09ed08007eff64d9253d34e97af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1814 zcmcIl{ZCs}7;Xs&D>B^TZWmZ!8z>80*F{Mt`>LA-L?@tG*EV6m6v`5#h>o-yiC1BB z!DCY0R3tOQxgRub($61;gQei;Kt{ZoNwb*6bi2lwkSJ>m;kuRk`d)k9{Rf_;ZBCx& zJkR@ePKR5Y8;eXOCWFCH)bxVwRs5aPKLt7Xy>(aJj|Rgdzc$(SAMzUiykq>NvgvGP z(|YC~ooKY#+-~<_J1&pDRu=w`bzGNrICK}cyP?5$YU2E;Nj~Uyc=|n_7FoAE_*$Z^ zKPlB#JFXrR4Wohf+(W@Di`KGfyCsL!h{2QVTEAcPgE!Z)`jS{Sr}(8cp9g~XeT{D? z3%k>kNVxc&F0f}X(7uD2XB5Br<9WMdb+%%BF)!@Biv%MPE|fF#>4aS_CWXJl-kYwr zVNkKwBOcc;$7*EYYIHU zt)QI2ivj?z2FT9Lt(Yo(n2l70870mVP({|I0$fGwugN;Bj!h|$Phv&}b)r^Ia44pZ zT~}Zi!L*!)DH@5C3q8qBcdi>!!2_-W}W2De4+ z7KP@CsxYNURRnKgU==kp!IvW{Tvw!0f;TW26Ez2gwh~rhL6M3HUc&-tO_A*kk1v`S z6jA@uI4GMLq-p;P?%M^3y^@_@7{W;#bY_Yhc!OKI`;vt{Y3l|)57x5@y9EV5C4mzt zaMP8MpXUdPU++uec)|{9a;4x{z3t4j9N+}sQ6!v6z(yiGiUb^4`8iVP>`O|IGoTv* zZy{kesxDDXm!sp0)}e${!&8p?5_UKtY7c1aUKWG*Wp_GtiqGAWu2{riEw{w}1hg7g z=3#^0z^prDB`)wRJMap_b^%UPmem--N>mL|5+hhvDTl#&Qrw4%e}q-Jktb0VLqZ9I zE+RaMgr2A>+c@Fd2oj!VP)mfhx&mIl{iL8<6f&TvMyf@EC!+eO3}>(mQU!xnQrLt9 z4AMuf$k#1)GuTXoTqN{{RUcJ)0@Id;6LGriOV~TuE5JT}AU=eJb+|ISsj!E!3@LZI zm#+VLXWKG-kcjiw?p~+|1Vdjsro%T-a2l67`Y2G;Vl;RJxKg-PT zbHaN%q5fz(!xiLfI^nn=4{<`XfUjt%JN*?`$j3JLrBas9Z`5Cyq8-YF6@Ix5|KV14 oBg@axx3EpfmUzp%$i1sJZoDw>{JEmSiT`(orut@E@IdF_zw1sH0ssI2 diff --git a/replay.md b/replay.md deleted file mode 100644 index d455ce31e33d6f..00000000000000 --- a/replay.md +++ /dev/null @@ -1,324 +0,0 @@ -# `deno --record` / `deno replay` Design Document - -## Overview - -Deterministic record-and-replay as a runtime mode. Recording is a transparent flag on `deno run`. Replay is a dedicated subcommand that reconstructs past executions and integrates with Chrome DevTools for time-travel debugging. - -No APIs. No imports. No code changes. - ---- - -## Recording - -### Usage - -```bash -deno run --record=trace.bin server.ts -deno run --record=trace.bin --record-limit=500mb server.ts -``` - -### What Gets Recorded - -Every source of nondeterminism that flows through Deno's ops layer: - -| Category | Ops | Recorded Data | -|----------|-----|---------------| -| Time | `Date.now()`, `performance.now()` | Returned timestamp | -| Randomness | `Math.random()`, `crypto.getRandomValues()` | Returned bytes | -| Network | `fetch`, `Deno.connect`, `Deno.listen` | Request/response pairs, byte streams | -| Filesystem | `Deno.readFile`, `Deno.stat`, `Deno.readDir` | Return values, file contents | -| Subprocess | `Deno.Command` | stdout, stderr, exit code | -| Environment | `Deno.env.get`, `Deno.hostname` | Returned strings | -| Timers | `setTimeout`, `setInterval` | Scheduling order and fire sequence | -| Async scheduling | Event loop | Op completion order | - -### What Does NOT Get Recorded - -- CPU-bound computation (deterministic given same inputs) -- V8 internals (JIT, GC, IC — these are invisible to JS) -- Module source code (captured by reference via content hash) - -### Trace Format - -Binary format, streaming. The runtime appends entries as ops complete — no buffering entire responses in memory. - -``` -┌─────────────────────────────────────────┐ -│ Header │ -│ magic: "DENO_TRACE\0" │ -│ version: u32 │ -│ deno_version: string │ -│ v8_version: string │ -│ arch: string │ -│ os: string │ -│ module_graph_hash: [u8; 32] │ -│ permissions: PermissionSet │ -│ recorded_at: u64 (unix ms) │ -│ entry_point: string │ -├─────────────────────────────────────────┤ -│ Module Table │ -│ [specifier, content_hash, size]... │ -├─────────────────────────────────────────┤ -│ Op Events (streaming, append-only) │ -│ ┌───────────────────────────────┐ │ -│ │ sequence_id: u64 │ │ -│ │ timestamp_us: u64 │ │ -│ │ op_name: interned string │ │ -│ │ async_id: u64 │ │ -│ │ payload_len: u32 │ │ -│ │ payload: [u8] (V8 serialized)│ │ -│ └───────────────────────────────┘ │ -│ ...repeated... │ -├─────────────────────────────────────────┤ -│ Checkpoints (periodic, for fast seek) │ -│ [sequence_id, file_offset]... │ -└─────────────────────────────────────────┘ -``` - -### Checkpoints - -Every N ops (configurable, default ~10,000), the runtime writes a checkpoint: a V8 heap snapshot at that point plus the file offset. This enables fast seeking during replay — instead of replaying from the start, jump to the nearest checkpoint and replay forward. - -### Ring Buffer Mode - -```bash -deno run --record=trace.bin --record-limit=500mb server.ts -``` - -Keeps only the most recent N megabytes of trace data. When the limit is hit, old op events are discarded (but the header and module table are preserved). Useful for long-running servers where you only care about the last few minutes before a crash. - -### Recording Overhead - -Target: < 5% CPU overhead, bounded memory. - -This is achievable because: -- Ops already return structured data — we're copying it, not intercepting syscalls -- V8 serialization is fast for the small payloads most ops return -- File I/O for the trace is append-only and can be buffered -- No binary translation, no JIT interposition, no ptrace - ---- - -## Replay - -### Usage - -```bash -# Basic replay — re-executes the recorded program deterministically -deno replay trace.bin - -# Replay with DevTools debugging -deno replay trace.bin --inspect -deno replay trace.bin --inspect-brk # pause at first statement - -# Print a summary without replaying -deno replay trace.bin --info - -# Validate trace against current source (are modules unchanged?) -deno replay trace.bin --validate -``` - -### How Replay Works - -1. **Load header** — verify deno/v8 version compatibility, check module hashes -2. **Restore module graph** — load modules from disk (hashes must match, or error) -3. **Execute with ops intercepted** — instead of performing real I/O, every op returns the next recorded payload from the trace -4. **Enforce scheduling order** — async ops complete in the exact sequence they were recorded, regardless of actual timing - -The replayed program observes the exact same world as the original execution. `Date.now()` returns the recorded timestamp. `fetch()` returns the recorded response. `Math.random()` returns the recorded value. From JS's perspective, time has not passed and the network has not changed. - -### Divergence Detection - -If the replayed code takes a different path than the recording (e.g. modules changed, or a bug in the replay engine), the runtime detects the divergence: - -``` -error: Replay divergence at op #48,291 - Expected: op_fetch_send (async_id: 1042) - Got: op_read_file (async_id: 1043) - - This usually means source code has changed since the recording. - Run `deno replay trace.bin --validate` to check module hashes. -``` - ---- - -## DevTools Integration - -### Connecting - -```bash -deno replay trace.bin --inspect -# Debugger listening on ws://127.0.0.1:9229/... -# Open chrome://inspect in Chrome -``` - -The replay session speaks the standard Chrome DevTools Protocol (CDP). Any CDP client works — Chrome, VS Code, WebStorm. - -### Standard Debugging (works immediately) - -Everything you'd expect from `--inspect` works during replay: -- Breakpoints (line, conditional, logpoint) -- Step over / into / out -- Scope inspection, watch expressions -- Console evaluation -- Call stack navigation -- Source maps - -The difference: breakpoints are perfectly reproducible. Hit "restart" and you land in the exact same state with the exact same data. - -### Time-Travel Controls (new CDP extensions) - -Replay adds new capabilities to the debugger: - -#### Reverse Continue - -Run backwards from the current pause point to the previous breakpoint hit. Implemented by: find the nearest checkpoint before the target, replay forward to that point. - -``` -CDP: Debugger.reverseContinue -``` - -#### Step Back - -Step to the previous statement. Same mechanism — checkpoint + forward replay — but the UX is a single "step back" button. - -``` -CDP: Debugger.stepBack -``` - -#### Seek to Op - -Jump to any recorded op by sequence ID. The DevTools timeline shows all ops; clicking one seeks to that point in execution. - -``` -CDP: Runtime.seekToOp { sequenceId: 48291 } -``` - -#### Async Causal Chain - -Given any op, walk backwards through the chain of async operations that caused it. "This fetch was awaited by this function, which was called from this event handler, which was triggered by this timer, which was set during module init." - -``` -CDP: Runtime.getAsyncCausalChain { asyncId: 1042 } -→ [ - { asyncId: 1042, op: "op_fetch_send", location: "server.ts:42" }, - { asyncId: 891, op: "op_timer", location: "server.ts:38" }, - { asyncId: 0, op: "top_level", location: "server.ts:1" } - ] -``` - -### DevTools UI Extensions - -Chrome DevTools supports custom panels via extensions. Deno could ship a companion extension that adds: - -**Timeline Panel** — a horizontal timeline of all recorded ops, color-coded by category (net=blue, fs=green, timer=yellow). Click any op to seek. Zoom in/out. Shows wall-clock time from the recording. - -**Async Graph** — a visual DAG of async causality. Nodes are ops, edges are "caused by" relationships. Click a node to seek and inspect. - -**Op Inspector** — when paused at a breakpoint, shows the recorded ops that are "in flight" at that moment, with their full request/response payloads. - ---- - -## CLI Reference - -### `deno run --record` - -``` -FLAGS: - --record= - Enable recording. Writes trace to the specified file. - File is created or overwritten. - - --record-limit= - Maximum trace size. Ring-buffer mode — keeps the most - recent data within the budget. Accepts kb, mb, gb suffixes. - Default: unlimited. - - --record-filter= - Comma-separated list of op categories to record. - Categories: net, fs, time, random, env, subprocess, timer, all. - Default: all. - - --record-checkpoint-interval= - Write a heap checkpoint every N ops. - Lower = faster seeking, larger trace. - Default: 10000. -``` - -### `deno replay` - -``` -USAGE: - deno replay [FLAGS] - -FLAGS: - --inspect - Open a CDP debugging session. Connect Chrome DevTools - or any CDP client. - - --inspect-brk - Same as --inspect, but pause before the first statement. - - --inspect-addr= - Address for the CDP WebSocket. Default: 127.0.0.1:9229. - - --info - Print trace metadata and exit. Shows: entry point, - deno/v8 version, duration, op count, module list, - permissions, file size. - - --validate - Check module hashes against source on disk. Reports which - files have changed since the recording. Does not replay. - - --seek= - Start replay paused at the given op sequence ID. - Useful for jumping directly to a known point of interest. - - --speed= - Replay wall-clock speed relative to recorded time. - Default: max (as fast as possible). - Use --speed=1 for real-time, --speed=0.5 for half-speed. -``` - ---- - -## Interaction with Other Features - -### Permissions - -Replay does not perform real I/O, so no permissions are needed. The `deno replay` subcommand ignores `--allow-*` flags — it's replaying recorded data, not accessing the system. - -### Snapshots (`Deno.snapshot`) - -Recording and snapshots are complementary: -- A snapshot captures a *state* for fast restore -- A recording captures an *execution* for debugging - -A snapshot could be embedded as the first checkpoint in a trace, giving instant seek to "just after initialization." - -### `--watch` Mode - -`--record` and `--watch` are mutually exclusive. Restarting on file change would create a new execution — start a new recording instead. - -### Testing - -```bash -deno test --record=test-trace.bin -``` - -Records the entire test run. On failure, replay the trace to debug the exact conditions that caused the failure — including any randomness, timing, or network responses. - ---- - -## Open Questions - -1. **Trace portability** — can a trace recorded on Linux be replayed on macOS? The ops return the same types, but path separators, env vars, etc. differ. Probably: yes for most ops, no for fs paths. - -2. **Worker threads** — workers are separate isolates. Record each worker's ops independently, with a shared sequence counter for cross-worker ordering? - -3. **FFI** — `Deno.dlopen` calls bypass the ops layer entirely. Options: refuse to record programs using FFI, or record at the FFI boundary (return values only, not internal native state). - -4. **Trace sharing** — recordings contain full network responses, file contents, env vars. Sensitive data. Need a `deno replay trace.bin --redact` that strips payloads but preserves structure for sharing bug reports? - -5. **Partial replay** — can you replay just a single request to a server, rather than the entire execution from startup? Requires identifying the async tree rooted at that request and replaying only those ops. diff --git a/test2.js b/test2.js deleted file mode 100644 index 180c27730063f4..00000000000000 --- a/test2.js +++ /dev/null @@ -1,9 +0,0 @@ -const inputBlob = new Blob([await Deno.readFile("./input.png")], { - type: "image/png", -}); -const bitmap = await createImageBitmap(inputBlob); -const canvas = new OffscreenCanvas(200, 200); -const bitmaprenderer = canvas.getContext("bitmaprenderer"); -bitmaprenderer.transferFromImageBitmap(bitmap); -const outputBlob = await canvas.convertToBlob(); -await Deno.writeFile("./output.png", outputBlob.stream()); diff --git a/test3.ts b/test3.ts deleted file mode 100644 index 17160974901a0a..00000000000000 --- a/test3.ts +++ /dev/null @@ -1,20 +0,0 @@ -setInterval(async () => { - await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); - Deno.hostname(); -}, 500); - -setInterval(async () => { - await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); - Deno.env.get("foo"); -}, 1100); - -setInterval(async () => { - await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); - Deno.readTextFile("test3.ts"); -}, 900); - -setInterval(async () => { - await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); - fetch("https://example.com"); - console.log("test2"); -}, 1000); From a01c7d2f9ec679083de52d8ca3272b765536a529 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 18 Mar 2026 19:08:40 +0100 Subject: [PATCH 010/137] fixes --- Cargo.lock | 1 + cli/args/flags.rs | 2 +- cli/rt/desktop.rs | 69 ++++++++++++++++-- cli/rt_desktop/lib.rs | 158 +++++++++++++++++++---------------------- cli/tools/compile.rs | 85 ++++++++++++---------- ext/web/02_event.js | 4 +- main.ts | 0 runtime/js/99_main.js | 11 ++- runtime/ops/desktop.rs | 136 ++++++++++++++++++++++++++++++----- 9 files changed, 314 insertions(+), 152 deletions(-) delete mode 100644 main.ts diff --git a/Cargo.lock b/Cargo.lock index 6dde882c3566a3..40e9163aba564a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11354,6 +11354,7 @@ name = "wef" version = "0.1.0" dependencies = [ "bindgen 0.71.1", + "tokio", ] [[package]] diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 5ec5f6270e20e3..450884f414363c 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -2960,7 +2960,7 @@ framework (Next.js, Astro, etc.). .long("backend") .help("WEF backend to use for the desktop app") .value_parser(["webview", "cef", "servo"]) - .default_value("webview") + .default_value("cef") .help_heading(DESKTOP_HEADING), ) .arg( diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index a2cdf20d7dc6ef..065fb4247a7a3b 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -16,13 +16,44 @@ pub use deno_runtime::ops::desktop::DesktopApi; /// JS code that exposes desktop APIs via `Deno.BrowserWindow` and `Deno.desktop`. pub const DESKTOP_JS: &str = r#" (() => { - const { BrowserWindow } = Deno[Deno.internal].core.ops; + const internals = Deno[Deno.internal]; + const { + BrowserWindow, + op_desktop_recv_menu_click, + op_desktop_recv_keyboard_event, + op_desktop_recv_bind_call, + op_desktop_resolve_bind_call, + op_desktop_reject_bind_call, + } = internals.core.ops; const BrowserWindowPrototype = BrowserWindow.prototype; - Deno.BrowserWindow = BrowserWindow; - - ObjectSetPrototypeOf(BrowserWindowPrototype, EventTargetPrototype); - defineEventHandler(BrowserWindowPrototype, "keydown"); - defineEventHandler(BrowserWindowPrototype, "keyup"); + Object.setPrototypeOf(BrowserWindowPrototype, EventTarget.prototype); + + const NativeBrowserWindow = BrowserWindow; + const WrappedBrowserWindow = function BrowserWindow(options) { + const instance = new NativeBrowserWindow(options); + EventTarget.call(instance); + return instance; + }; + WrappedBrowserWindow.prototype = BrowserWindowPrototype; + BrowserWindowPrototype.constructor = WrappedBrowserWindow; + Deno.BrowserWindow = WrappedBrowserWindow; + internals.defineEventHandler(BrowserWindowPrototype, "keydown"); + internals.defineEventHandler(BrowserWindowPrototype, "keyup"); + + // Bind callback registry: name -> bound function + const bindCallbacks = new Map(); + + const nativeBind = BrowserWindowPrototype.bind; + BrowserWindowPrototype.bind = function(name, fn) { + bindCallbacks.set(name, fn.bind(this)); + nativeBind.call(this, name); + }; + + const nativeUnbind = BrowserWindowPrototype.unbind; + BrowserWindowPrototype.unbind = function(name) { + bindCallbacks.delete(name); + nativeUnbind.call(this, name); + }; // Defer start so the pending op doesn't block the pre-module event loop tick used by HMR. addEventListener("load", () => { @@ -52,6 +83,25 @@ pub const DESKTOP_JS: &str = r#" })); } })(); + // Poll for bind calls from the webview and dispatch to JS callbacks. + (async () => { + while (true) { + const call = await op_desktop_recv_bind_call(); + if (call == null) break; + const fn_ = bindCallbacks.get(call.name); + if (!fn_) { + op_desktop_reject_bind_call(call.callId, "No callback bound for: " + call.name); + continue; + } + try { + const args = Array.isArray(call.args) ? call.args : []; + const result = await fn_(...args); + op_desktop_resolve_bind_call(call.callId, result ?? null); + } catch (e) { + op_desktop_reject_bind_call(call.callId, String(e)); + } + } + })(); }, { once: true }); })(); "#; @@ -147,8 +197,13 @@ pub fn desktop_auto_update_js( ) } +pub use deno_runtime::ops::desktop::BindCallReceiver; +pub use deno_runtime::ops::desktop::BindCallSender; pub use deno_runtime::ops::desktop::KeyboardEventSender; pub use deno_runtime::ops::desktop::MenuClickSender; +pub use deno_runtime::ops::desktop::PendingBindCall; +pub use deno_runtime::ops::desktop::PendingBindResponses; +pub use deno_runtime::ops::desktop::create_bind_call_channel; pub use deno_runtime::ops::desktop::create_keyboard_event_channel; pub use deno_runtime::ops::desktop::create_menu_click_channel; @@ -173,4 +228,6 @@ pub fn init_desktop_state( let (kb_tx, kb_rx) = create_keyboard_event_channel(); state.put(kb_rx); state.put(kb_tx); + // Initialize pending bind responses map + state.put(PendingBindResponses::new()); } diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 147ff8be2d8feb..7bfa27713eec63 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -26,15 +26,16 @@ use deno_lib::util::result::js_error_downcast_ref; use deno_lib::version::otel_runtime_config; use deno_runtime::fmt_errors::format_js_error; use deno_terminal::colors; -use tokio::sync::oneshot::error::RecvError; -use wef::Value; use denort::run::RunOptions; /// Port used for the embedded HTTP server. const DESKTOP_SERVE_PORT: u16 = 41520; /// WEF-backed implementation of [`denort::desktop::DesktopApi`]. -struct WefDesktopApi; +struct WefDesktopApi { + bind_call_tx: + tokio::sync::mpsc::UnboundedSender, +} impl denort::desktop::DesktopApi for WefDesktopApi { fn set_title(&self, title: &str) { @@ -89,84 +90,39 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::focus(); } - fn bind<'a>( - &self, - name: &str, - scope: &mut v8::PinScope<'a, '_>, - cb: v8::Local<'a, v8::Function>, - ) { - wef::bind(name, |mut js_call| { - let this = v8::null(scope); - let args = std::mem::take(&mut js_call.args) - .into_iter() - .map(|v| wef_value_to_v8(scope, v)) - .collect::>(); - - let tc = std::pin::pin!(v8::TryCatch::new(scope)); - let mut tc = &tc.init(); - - if let Some(ret) = cb.call(tc, this.into(), &args) { - // Attach .then()/.catch() to resolve/reject the JsCall - // when the promise settles. - if ret.is_promise() { - let promise: v8::Local = ret.try_into().unwrap(); - - // Box the JsCall into an Option so either handler can take - // ownership exactly once. Store as v8::External data. - let js_call_ptr = - Box::into_raw(Box::new(Some(js_call))) as *mut std::ffi::c_void; - let external = v8::External::new(tc, js_call_ptr); - - let on_fulfilled = v8::Function::builder( - |scope: &mut v8::PinScope, - args: v8::FunctionCallbackArguments, - _rv: v8::ReturnValue| { - let data = - v8::Local::::try_from(args.data()).unwrap(); - let js_call = unsafe { - Box::>::from_raw( - data.value() as *mut Option - ) - }; - if let Some(call) = *js_call { - call.resolve(v8_to_wef_value(scope, args.get(0))); - } - }, - ) - .data(external.into()) - .build(tc) - .unwrap(); - - let on_rejected = v8::Function::builder( - |scope: &mut v8::PinScope, - args: v8::FunctionCallbackArguments, - _rv: v8::ReturnValue| { - let data = - v8::Local::::try_from(args.data()).unwrap(); - let js_call = unsafe { - Box::>::from_raw( - data.value() as *mut Option - ) - }; - if let Some(call) = *js_call { - call.reject(v8_to_wef_value(scope, args.get(0))); - } - }, - ) - .data(external.into()) - .build(tc) - .unwrap(); - - promise.then2(tc, on_fulfilled, on_rejected); - } else { - js_call.resolve(v8_to_wef_value(tc, ret)); + fn bind(&self, name: &str) { + let tx = self.bind_call_tx.clone(); + let name_owned = name.to_string(); + wef::bind_async(name, move |js_call| { + let tx = tx.clone(); + let name = name_owned.clone(); + async move { + let args: Vec = + js_call.args.iter().map(wef_value_to_json).collect(); + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); + let pending = denort::desktop::PendingBindCall { + name, + args: serde_json::Value::Array(args), + response: resp_tx, + }; + if tx.send(pending).is_err() { + js_call + .reject(wef::Value::String("bind channel closed".to_string())); + return; + } + match resp_rx.await { + Ok(Ok(result)) => { + js_call.resolve(json_to_wef_value(&result)); + } + Ok(Err(error)) => { + js_call.reject(wef::Value::String(error)); + } + Err(_) => { + js_call.reject(wef::Value::String( + "bind response channel dropped".to_string(), + )); + } } - } else if let Some(err) = tc.exception() { - js_call.reject(v8_to_wef_value(tc, err)); - } else { - let message = v8::String::new(tc, "unknown error").unwrap(); - let err = v8::Exception::error(tc, message.into()); - js_call.reject(v8_to_wef_value(tc, err.into())); } }); } @@ -179,8 +135,9 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::navigate(url); } - fn execute_js<'a>(&self, scope: &'a mut v8::PinScope<'a, '_>, script: &str) -> std::pin::Pin, v8::Local<'a, v8::Value>>> + 'a>> { - let (tx, rx) = tokio::sync::oneshot::channel(); + fn execute_js<'a>(&self, _scope: &'a mut v8::PinScope<'a, '_>, _script: &str) -> std::pin::Pin, v8::Local<'a, v8::Value>>> + 'a>> { + todo!() + /*let (tx, rx) = tokio::sync::oneshot::channel(); wef::execute_js(script, Some(|res| { let _ = tx.send(res); })); @@ -190,7 +147,7 @@ impl denort::desktop::DesktopApi for WefDesktopApi { Ok(val) => Ok(wef_value_to_v8(scope, val)), Err(err) => Err(wef_value_to_v8(scope, err)), } - }) + })*/ } fn quit(&self) { @@ -210,6 +167,7 @@ impl denort::desktop::DesktopApi for WefDesktopApi { } } +#[allow(dead_code)] fn wef_value_to_v8<'a>( scope: &v8::PinScope<'a, '_>, val: wef::Value, @@ -253,6 +211,7 @@ fn wef_value_to_v8<'a>( } } +#[allow(dead_code)] fn v8_to_wef_value<'a>( scope: &v8::PinScope<'a, '_>, val: v8::Local<'a, v8::Value>, @@ -639,6 +598,28 @@ fn extract_fork_script_path( None } +/// Convert a wef::Value to a serde_json::Value for channel transport. +fn wef_value_to_json(v: &wef::Value) -> serde_json::Value { + match v { + wef::Value::Null => serde_json::Value::Null, + wef::Value::Bool(b) => serde_json::Value::Bool(*b), + wef::Value::Int(i) => serde_json::json!(*i), + wef::Value::Double(d) => serde_json::json!(*d), + wef::Value::String(s) => serde_json::Value::String(s.clone()), + wef::Value::List(l) => { + serde_json::Value::Array(l.iter().map(wef_value_to_json).collect()) + } + wef::Value::Dict(d) => { + let mut map = serde_json::Map::new(); + for (k, v) in d { + map.insert(k.clone(), wef_value_to_json(v)); + } + serde_json::Value::Object(map) + } + wef::Value::Binary(b) => serde_json::json!(b), + } +} + /// Convert a serde_json::Value to a wef::Value for the menu template. fn json_to_wef_value(v: &serde_json::Value) -> wef::Value { match v { @@ -734,7 +715,10 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let hmr_on_reload: Option = if hmr_watch_dir.is_some() && !is_framework_dev { Some(Box::new(|| { - wef::execute_js("location.reload()", None); + wef::execute_js::)>( + "location.reload()", + None, + ); })) } else { None @@ -769,11 +753,15 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { }, hmr_on_reload, op_state_init: Some(Box::new(move |state| { + let (bind_tx, bind_rx) = denort::desktop::create_bind_call_channel(); denort::desktop::init_desktop_state( state, - Box::new(WefDesktopApi), + Box::new(WefDesktopApi { + bind_call_tx: bind_tx.0, + }), auto_update_state, ); + state.put(bind_rx); // Wire WEF menu click callback to the Deno runtime channel let menu_tx = state.borrow::().0.clone(); diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index 285e523cf6eed9..09609d617fef56 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -41,32 +41,31 @@ const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; /// Dev builds live at `/target/{debug,release}/deno`. /// Resolve WEF backend binary search paths based on the chosen backend. fn wef_backend_search_paths(backend: &str) -> Vec { - match backend { - "cef" => vec![ - PathBuf::from( - "/Users/divy/gh/wef/result-cef/Applications/wef.app/Contents/MacOS/wef", - ), - PathBuf::from( - "/Users/divy/gh/wef/result/Applications/wef.app/Contents/MacOS/wef", - ), - PathBuf::from("/Users/divy/gh/wef/cef/build/wef.app/Contents/MacOS/wef"), - ], - "servo" => vec![ - PathBuf::from("/Users/divy/gh/wef/servo/target/release/wef_servo"), - PathBuf::from("/Users/divy/gh/wef/servo/target/debug/wef_servo"), - ], - _ => vec![ - PathBuf::from( - "/Users/divy/gh/wef/result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview", - ), - PathBuf::from( - "/Users/divy/gh/wef/result/Applications/wef_webview.app/Contents/MacOS/wef_webview", - ), - PathBuf::from( - "/Users/divy/gh/wef/webview/build/wef_webview.app/Contents/MacOS/wef_webview", - ), - ], + let wef_base = option_env!("CARGO_MANIFEST_DIR") + .map(|d| std::path::Path::new(d).join("../../wef")) + .filter(|p| p.exists()); + + let mut paths = Vec::new(); + if let Some(ref wef) = wef_base { + match backend { + "cef" => { + paths.push(wef.join("result-cef/Applications/wef.app/Contents/MacOS/wef")); + paths.push(wef.join("result/Applications/wef.app/Contents/MacOS/wef")); + paths.push(wef.join("cef/build/Release/wef.app/Contents/MacOS/wef")); + paths.push(wef.join("cef/build/wef.app/Contents/MacOS/wef")); + } + "servo" => { + paths.push(wef.join("servo/target/release/wef_servo")); + paths.push(wef.join("servo/target/debug/wef_servo")); + } + _ => { + paths.push(wef.join("result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview")); + paths.push(wef.join("result/Applications/wef_webview.app/Contents/MacOS/wef_webview")); + paths.push(wef.join("webview/build/wef_webview.app/Contents/MacOS/wef_webview")); + } + } } + paths } pub async fn compile( @@ -373,19 +372,31 @@ const WEF_BACKEND_APP_ENV: &str = "WEF_BACKEND_APP"; /// Resolve WEF backend .app search paths based on the chosen backend. fn wef_backend_app_search_paths(backend: &str) -> Vec { - match backend { - "cef" => vec![ - "/Users/divy/gh/wef/result-cef/Applications/wef.app".to_string(), - "/Users/divy/gh/wef/result/Applications/wef.app".to_string(), - "/Users/divy/gh/wef/cef/build/wef.app".to_string(), - ], - "servo" => vec![], // Servo is not an .app bundle - _ => vec![ - "/Users/divy/gh/wef/result-1/Applications/wef_webview.app".to_string(), - "/Users/divy/gh/wef/result/Applications/wef_webview.app".to_string(), - "/Users/divy/gh/wef/webview/build/wef_webview.app".to_string(), - ], + // Derive the wef repo path from this crate's location. + // CARGO_MANIFEST_DIR is cli/, the wef repo is at ../../wef relative to that + // (i.e. a sibling of the deno repo in the parent directory). + let wef_base = option_env!("CARGO_MANIFEST_DIR") + .map(|d| std::path::Path::new(d).join("../../wef")) + .filter(|p| p.exists()); + + let mut paths = Vec::new(); + if let Some(ref wef) = wef_base { + match backend { + "cef" => { + paths.push(wef.join("result-cef/Applications/wef.app").to_string_lossy().to_string()); + paths.push(wef.join("result/Applications/wef.app").to_string_lossy().to_string()); + paths.push(wef.join("cef/build/Release/wef.app").to_string_lossy().to_string()); + paths.push(wef.join("cef/build/wef.app").to_string_lossy().to_string()); + } + "winit" | "servo" => {} // Not .app bundles + _ => { + paths.push(wef.join("result-1/Applications/wef_webview.app").to_string_lossy().to_string()); + paths.push(wef.join("result/Applications/wef_webview.app").to_string_lossy().to_string()); + paths.push(wef.join("webview/build/wef_webview.app").to_string_lossy().to_string()); + } + } } + paths } /// Find the WEF backend .app bundle directory. diff --git a/ext/web/02_event.js b/ext/web/02_event.js index 62897722f69826..83d4bc0b9558c6 100644 --- a/ext/web/02_event.js +++ b/ext/web/02_event.js @@ -5,7 +5,7 @@ // parts still exists. This means you will observe a lot of strange structures // and impossible logic branches based on what Deno currently supports. -import { core, primordials } from "ext:core/mod.js"; +import { core, internals, primordials } from "ext:core/mod.js"; const { ArrayPrototypeIncludes, ArrayPrototypeIndexOf, @@ -1542,6 +1542,8 @@ function reportError(error) { reportException(error); } +internals.defineEventHandler = defineEventHandler; + export { CloseEvent, CustomEvent, diff --git a/main.ts b/main.ts deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 421d0ed1e4e35a..6616efab2b03bb 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -540,15 +540,14 @@ const NOT_IMPORTED_OPS = [ "op_napi_open", // Related to `Deno.desktop` API (deno compile --desktop) - "op_desktop_set_title", - "op_desktop_set_size", - "op_desktop_navigate", - "op_desktop_execute_js", - "op_desktop_close", - "op_desktop_set_application_menu", + "BrowserWindow", "op_desktop_apply_patch", "op_desktop_confirm_update", "op_desktop_recv_menu_click", + "op_desktop_recv_keyboard_event", + "op_desktop_recv_bind_call", + "op_desktop_resolve_bind_call", + "op_desktop_reject_bind_call", // deno deploy subcommand "op_deploy_token_get", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index fe58b99789ef54..d263cc0d8e10d8 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -7,14 +7,18 @@ //! builds), the ops silently no-op. use std::borrow::Cow; +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering; -use deno_core::{FromV8}; -use deno_core::{ToV8}; +use deno_core::FromV8; use deno_core::OpState; +use deno_core::ToV8; use deno_core::op2; +use deno_core::serde_json; use deno_core::v8; /// Channel for receiving menu click events in the Deno runtime. @@ -52,6 +56,41 @@ pub fn create_keyboard_event_channel( (KeyboardEventSender(tx), KeyboardEventReceiver(rx)) } +/// A pending call from the webview to a bound Deno function. +pub struct PendingBindCall { + pub name: String, + pub args: serde_json::Value, + pub response: + tokio::sync::oneshot::Sender>, +} + +pub struct BindCallReceiver( + pub tokio::sync::mpsc::UnboundedReceiver, +); +pub struct BindCallSender( + pub tokio::sync::mpsc::UnboundedSender, +); + +pub fn create_bind_call_channel() -> (BindCallSender, BindCallReceiver) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + (BindCallSender(tx), BindCallReceiver(rx)) +} + +pub struct PendingBindResponses( + pub HashMap< + u32, + tokio::sync::oneshot::Sender>, + >, +); + +impl PendingBindResponses { + pub fn new() -> Self { + Self(HashMap::new()) + } +} + +static BIND_CALL_COUNTER: AtomicU32 = AtomicU32::new(1); + /// Trait for desktop window operations. Implemented by the desktop /// runtime (denort_desktop) to bridge to the WEF backend. pub trait DesktopApi: Send + Sync + 'static { @@ -73,16 +112,11 @@ pub trait DesktopApi: Send + Sync + 'static { fn hide(&self); fn focus(&self); - fn bind<'a>( - &self, - name: &str, - scope: &mut v8::PinScope<'a, '_>, - cb: v8::Local<'a, v8::Function>, - ); + fn bind(&self, name: &str); fn unbind(&self, name: &str); fn navigate(&self, url: &str); - fn execute_js<'a>(&self, scope: &mut v8::PinScope<'a, '_>, script: &str) -> Pin, v8::Local<'a, v8::Value>>> + 'a>>; + fn execute_js<'a>(&self, scope: &'a mut v8::PinScope<'a, '_>, script: &str) -> Pin, v8::Local<'a, v8::Value>>> + 'a>>; fn quit(&self); fn set_application_menu(&self, template_json: &str); } @@ -126,19 +160,20 @@ impl BrowserWindow { options.width.unwrap_or(800), options.height.unwrap_or(600), ); + if let Some(resizable) = options.resizable { + api.set_resizeable(resizable); + } + if let Some(always_on_top) = options.always_on_top { + api.set_always_on_top(always_on_top); + } } BrowserWindow { api } } #[fast] - fn bind<'a>( - &self, - scope: &mut v8::PinScope<'a, '_>, - #[string] name: &str, - cb: v8::Local<'a, v8::Function>, - ) { - self.api.bind(name, scope, cb); + fn bind(&self, #[string] name: &str) { + self.api.bind(name); } #[fast] @@ -364,6 +399,72 @@ pub fn op_desktop_confirm_update(state: &mut OpState) { } } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct BindCallInfo { + name: String, + args: serde_json::Value, + call_id: u32, +} + +#[op2] +#[serde] +async fn op_desktop_recv_bind_call( + state: std::rc::Rc>, +) -> Option { + let rx = { + let mut s = state.borrow_mut(); + s.try_take::() + }; + if let Some(mut rx) = rx { + let result = rx.0.recv().await; + state.borrow_mut().put(rx); + if let Some(call) = result { + let call_id = BIND_CALL_COUNTER.fetch_add(1, Ordering::Relaxed); + { + let mut s = state.borrow_mut(); + let responses = s.borrow_mut::(); + responses.0.insert(call_id, call.response); + } + Some(BindCallInfo { + name: call.name, + args: call.args, + call_id, + }) + } else { + None + } + } else { + std::future::pending().await + } +} + +#[op2] +fn op_desktop_resolve_bind_call( + state: &mut OpState, + #[smi] call_id: u32, + #[serde] result: serde_json::Value, +) { + if let Some(responses) = state.try_borrow_mut::() { + if let Some(tx) = responses.0.remove(&call_id) { + let _ = tx.send(Ok(result)); + } + } +} + +#[op2(fast)] +fn op_desktop_reject_bind_call( + state: &mut OpState, + #[smi] call_id: u32, + #[string] error: String, +) { + if let Some(responses) = state.try_borrow_mut::() { + if let Some(tx) = responses.0.remove(&call_id) { + let _ = tx.send(Err(error)); + } + } +} + deno_core::extension!( deno_desktop, ops = [ @@ -371,6 +472,9 @@ deno_core::extension!( op_desktop_confirm_update, op_desktop_recv_menu_click, op_desktop_recv_keyboard_event, + op_desktop_recv_bind_call, + op_desktop_resolve_bind_call, + op_desktop_reject_bind_call, ], objects = [BrowserWindow,], ); From 3d54803e6886ef23c8caa2383f63cf86cb1445c5 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 18 Mar 2026 21:38:24 +0100 Subject: [PATCH 011/137] fixes --- cli/rt/desktop.rs | 75 ++++++++++++++++++++++++++++++++++++----- cli/rt_desktop/lib.rs | 6 +++- ext/web/02_event.js | 1 + ext/webidl/00_webidl.js | 4 ++- runtime/js/99_main.js | 1 + runtime/ops/desktop.rs | 40 ++++++++++++++++++++-- 6 files changed, 113 insertions(+), 14 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 065fb4247a7a3b..f04482563ab662 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -19,6 +19,7 @@ pub const DESKTOP_JS: &str = r#" const internals = Deno[Deno.internal]; const { BrowserWindow, + op_desktop_init, op_desktop_recv_menu_click, op_desktop_recv_keyboard_event, op_desktop_recv_bind_call, @@ -28,15 +29,69 @@ pub const DESKTOP_JS: &str = r#" const BrowserWindowPrototype = BrowserWindow.prototype; Object.setPrototypeOf(BrowserWindowPrototype, EventTarget.prototype); - const NativeBrowserWindow = BrowserWindow; - const WrappedBrowserWindow = function BrowserWindow(options) { - const instance = new NativeBrowserWindow(options); - EventTarget.call(instance); + class KeyboardEvent extends Event { + #key = ""; + #code = ""; + #location = 0; + #ctrlKey = false; + #shiftKey = false; + #altKey = false; + #metaKey = false; + #repeat = false; + #isComposing = false; + + get key() { return this.#key; } + get code() { return this.#code; } + get location() { return this.#location; } + get ctrlKey() { return this.#ctrlKey; } + get shiftKey() { return this.#shiftKey; } + get altKey() { return this.#altKey; } + get metaKey() { return this.#metaKey; } + get repeat() { return this.#repeat; } + get isComposing() { return this.#isComposing; } + + constructor(type, init = {}) { + super(type, init); + this.#key = init.key ?? ""; + this.#code = init.code ?? ""; + this.#location = init.location ?? 0; + this.#ctrlKey = init.ctrlKey ?? false; + this.#shiftKey = init.shiftKey ?? false; + this.#altKey = init.altKey ?? false; + this.#metaKey = init.metaKey ?? false; + this.#repeat = init.repeat ?? false; + this.#isComposing = init.isComposing ?? false; + } + + getModifierState(key) { + switch (key) { + case "Alt": return this.#altKey; + case "Control": return this.#ctrlKey; + case "Meta": return this.#metaKey; + case "Shift": return this.#shiftKey; + default: return false; + } + } + } + globalThis.KeyboardEvent = internals.core.propNonEnumerable(KeyboardEvent); + + op_desktop_init( + internals.webidlBrand, + internals.setEventTargetData, + ); + + // Track the active BrowserWindow instance for event dispatch. + let activeWindow = null; + const nativeConstructor = BrowserWindow; + const OrigBW = function(...args) { + const instance = new nativeConstructor(...args); + activeWindow = instance; return instance; }; - WrappedBrowserWindow.prototype = BrowserWindowPrototype; - BrowserWindowPrototype.constructor = WrappedBrowserWindow; - Deno.BrowserWindow = WrappedBrowserWindow; + Object.setPrototypeOf(OrigBW, nativeConstructor); + Object.setPrototypeOf(OrigBW.prototype, nativeConstructor.prototype); + Deno.BrowserWindow = OrigBW; + internals.defineEventHandler(BrowserWindowPrototype, "keydown"); internals.defineEventHandler(BrowserWindowPrototype, "keyup"); @@ -67,12 +122,14 @@ pub const DESKTOP_JS: &str = r#" } })(); // Poll for keyboard events from the native side and dispatch - // them as KeyboardEvent on globalThis. + // them on the active BrowserWindow instance. (async () => { while (true) { const ev = await op_desktop_recv_keyboard_event(); if (ev == null) break; - dispatchEvent(new KeyboardEvent(ev.type, { + const target = activeWindow; + if (!target) continue; + target.dispatchEvent(new KeyboardEvent(ev.type, { key: ev.key, code: ev.code, shiftKey: ev.shift, diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 7bfa27713eec63..8720bd30964799 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -774,7 +774,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { .0 .clone(); wef::on_keyboard_event(move |ev| { - let _ = + eprintln!("[desktop] on_keyboard_event: sending {:?}/{:?}", ev.key, ev.state); + let result = kb_tx.send(deno_runtime::ops::desktop::KeyboardEventData { r#type: match ev.state { wef::KeyState::Pressed => "keydown", @@ -788,6 +789,9 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { meta: ev.modifiers.meta, repeat: ev.repeat, }); + if let Err(e) = result { + eprintln!("[desktop] keyboard send failed: {}", e); + } }); })), override_main_module: None, diff --git a/ext/web/02_event.js b/ext/web/02_event.js index 83d4bc0b9558c6..b70c43d9983384 100644 --- a/ext/web/02_event.js +++ b/ext/web/02_event.js @@ -1543,6 +1543,7 @@ function reportError(error) { } internals.defineEventHandler = defineEventHandler; +internals.setEventTargetData = setEventTargetData; export { CloseEvent, diff --git a/ext/webidl/00_webidl.js b/ext/webidl/00_webidl.js index e0dbd880cdd963..87000dfc592409 100644 --- a/ext/webidl/00_webidl.js +++ b/ext/webidl/00_webidl.js @@ -6,7 +6,7 @@ /// -import { core, primordials } from "ext:core/mod.js"; +import { core, internals, primordials } from "ext:core/mod.js"; const { isArrayBuffer, isDataView, @@ -1424,6 +1424,8 @@ function setlikeObjectWrap(objPrototype, readonly) { } } +internals.webidlBrand = brand; + export { assertBranded, AsyncIterable, diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 6616efab2b03bb..8c2e9173f1bc14 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -543,6 +543,7 @@ const NOT_IMPORTED_OPS = [ "BrowserWindow", "op_desktop_apply_patch", "op_desktop_confirm_update", + "op_desktop_init", "op_desktop_recv_menu_click", "op_desktop_recv_keyboard_event", "op_desktop_recv_bind_call", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index d263cc0d8e10d8..19dc0956ba3854 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -140,14 +140,19 @@ impl deno_core::Resource for BrowserWindow { } } +struct EventTargetSetup { + brand: v8::Global, + set_event_target_data: v8::Global, +} + #[op2] impl BrowserWindow { #[constructor] - #[cppgc] fn new( state: &OpState, + scope: &mut v8::PinScope<'_, '_>, #[scoped] options: Option, - ) -> BrowserWindow { + ) -> v8::Global { let api = state .try_borrow::>() .expect("desktop mode enabled") @@ -168,7 +173,19 @@ impl BrowserWindow { } } - BrowserWindow { api } + let window = BrowserWindow { api }; + let window = deno_core::cppgc::make_cppgc_object(scope, window); + let event_target_setup = state.borrow::(); + let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); + window.set(scope, webidl_brand, webidl_brand); + let set_event_target_data = + v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) + .cast::(); + let null = v8::null(scope); + set_event_target_data.call(scope, null.into(), &[window.into()]); + let window = window.cast::(); + + v8::Global::new(scope, window) } #[fast] @@ -377,8 +394,10 @@ async fn op_desktop_recv_keyboard_event( let mut s = state.borrow_mut(); s.try_take::() }; + dbg!(rx.is_some()); if let Some(mut rx) = rx { let result = rx.0.recv().await; + dbg!(&result); state.borrow_mut().put(rx); result } else { @@ -465,11 +484,26 @@ fn op_desktop_reject_bind_call( } } +#[op2(fast)] +pub fn op_desktop_init( + state: &mut OpState, + scope: &mut v8::PinScope<'_, '_>, + webidl_brand: v8::Local, + set_event_target_data: v8::Local, +) { + state.put(EventTargetSetup { + brand: v8::Global::new(scope, webidl_brand), + set_event_target_data: v8::Global::new(scope, set_event_target_data), + }); +} + + deno_core::extension!( deno_desktop, ops = [ op_desktop_apply_patch, op_desktop_confirm_update, + op_desktop_init, op_desktop_recv_menu_click, op_desktop_recv_keyboard_event, op_desktop_recv_bind_call, From 5dbba23d5717dd5ec5cddf552a94f185980c5c19 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 19 Mar 2026 00:10:46 +0100 Subject: [PATCH 012/137] fix event system to always work in hmr --- cli/rt/desktop.rs | 104 +++++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 48 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index f04482563ab662..1c7054cefb839c 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -110,56 +110,64 @@ pub const DESKTOP_JS: &str = r#" nativeUnbind.call(this, name); }; - // Defer start so the pending op doesn't block the pre-module event loop tick used by HMR. - addEventListener("load", () => { - // Poll for menu click events from the native side and dispatch - // them as CustomEvents on globalThis. - (async () => { - while (true) { - const id = await op_desktop_recv_menu_click(); - if (id == null) break; - dispatchEvent(new CustomEvent("menuclick", { detail: { id } })); - } - })(); - // Poll for keyboard events from the native side and dispatch - // them on the active BrowserWindow instance. - (async () => { - while (true) { - const ev = await op_desktop_recv_keyboard_event(); - if (ev == null) break; - const target = activeWindow; - if (!target) continue; - target.dispatchEvent(new KeyboardEvent(ev.type, { - key: ev.key, - code: ev.code, - shiftKey: ev.shift, - ctrlKey: ev.control, - altKey: ev.alt, - metaKey: ev.meta, - repeat: ev.repeat, - })); + // Start polling loops immediately. Use core.unrefOpPromise so these + // pending ops don't block event loop completion (e.g. the pre-module + // tick used by HMR, or module evaluation with top-level await). + const { unrefOpPromise } = internals.core; + + // Poll for menu click events from the native side and dispatch + // them as CustomEvents on globalThis. + (async () => { + while (true) { + const p = op_desktop_recv_menu_click(); + unrefOpPromise(p); + const id = await p; + if (id == null) break; + dispatchEvent(new CustomEvent("menuclick", { detail: { id } })); + } + })(); + // Poll for keyboard events from the native side and dispatch + // them on the active BrowserWindow instance. + (async () => { + while (true) { + const p = op_desktop_recv_keyboard_event(); + unrefOpPromise(p); + const ev = await p; + if (ev == null) break; + const target = activeWindow; + if (!target) continue; + target.dispatchEvent(new KeyboardEvent(ev.type, { + key: ev.key, + code: ev.code, + shiftKey: ev.shift, + ctrlKey: ev.control, + altKey: ev.alt, + metaKey: ev.meta, + repeat: ev.repeat, + })); + } + })(); + // Poll for bind calls from the webview and dispatch to JS callbacks. + (async () => { + while (true) { + const p = op_desktop_recv_bind_call(); + unrefOpPromise(p); + const call = await p; + if (call == null) break; + const fn_ = bindCallbacks.get(call.name); + if (!fn_) { + op_desktop_reject_bind_call(call.callId, "No callback bound for: " + call.name); + continue; } - })(); - // Poll for bind calls from the webview and dispatch to JS callbacks. - (async () => { - while (true) { - const call = await op_desktop_recv_bind_call(); - if (call == null) break; - const fn_ = bindCallbacks.get(call.name); - if (!fn_) { - op_desktop_reject_bind_call(call.callId, "No callback bound for: " + call.name); - continue; - } - try { - const args = Array.isArray(call.args) ? call.args : []; - const result = await fn_(...args); - op_desktop_resolve_bind_call(call.callId, result ?? null); - } catch (e) { - op_desktop_reject_bind_call(call.callId, String(e)); - } + try { + const args = Array.isArray(call.args) ? call.args : []; + const result = await fn_(...args); + op_desktop_resolve_bind_call(call.callId, result ?? null); + } catch (e) { + op_desktop_reject_bind_call(call.callId, String(e)); } - })(); - }, { once: true }); + } + })(); })(); "#; From 5f175bc438845cdcefc036cd76d2a82a004dd82d Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 19 Mar 2026 00:43:48 +0100 Subject: [PATCH 013/137] rework event system --- cli/rt/desktop.rs | 106 ++++++++++--------------- cli/rt_desktop/lib.rs | 62 ++++++++------- runtime/js/99_main.js | 4 +- runtime/ops/desktop.rs | 173 ++++++++++++++--------------------------- 4 files changed, 135 insertions(+), 210 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 1c7054cefb839c..1f9f0b1b5909cb 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -20,9 +20,7 @@ pub const DESKTOP_JS: &str = r#" const { BrowserWindow, op_desktop_init, - op_desktop_recv_menu_click, - op_desktop_recv_keyboard_event, - op_desktop_recv_bind_call, + op_desktop_recv_event, op_desktop_resolve_bind_call, op_desktop_reject_bind_call, } = internals.core.ops; @@ -115,56 +113,46 @@ pub const DESKTOP_JS: &str = r#" // tick used by HMR, or module evaluation with top-level await). const { unrefOpPromise } = internals.core; - // Poll for menu click events from the native side and dispatch - // them as CustomEvents on globalThis. + // Single polling loop for all native desktop events. (async () => { while (true) { - const p = op_desktop_recv_menu_click(); - unrefOpPromise(p); - const id = await p; - if (id == null) break; - dispatchEvent(new CustomEvent("menuclick", { detail: { id } })); - } - })(); - // Poll for keyboard events from the native side and dispatch - // them on the active BrowserWindow instance. - (async () => { - while (true) { - const p = op_desktop_recv_keyboard_event(); + const p = op_desktop_recv_event(); unrefOpPromise(p); const ev = await p; if (ev == null) break; - const target = activeWindow; - if (!target) continue; - target.dispatchEvent(new KeyboardEvent(ev.type, { - key: ev.key, - code: ev.code, - shiftKey: ev.shift, - ctrlKey: ev.control, - altKey: ev.alt, - metaKey: ev.meta, - repeat: ev.repeat, - })); - } - })(); - // Poll for bind calls from the webview and dispatch to JS callbacks. - (async () => { - while (true) { - const p = op_desktop_recv_bind_call(); - unrefOpPromise(p); - const call = await p; - if (call == null) break; - const fn_ = bindCallbacks.get(call.name); - if (!fn_) { - op_desktop_reject_bind_call(call.callId, "No callback bound for: " + call.name); - continue; - } - try { - const args = Array.isArray(call.args) ? call.args : []; - const result = await fn_(...args); - op_desktop_resolve_bind_call(call.callId, result ?? null); - } catch (e) { - op_desktop_reject_bind_call(call.callId, String(e)); + switch (ev.kind) { + case "menuClick": + dispatchEvent(new CustomEvent("menuclick", { detail: { id: ev.id } })); + break; + case "keyboardEvent": { + const target = activeWindow; + if (!target) break; + target.dispatchEvent(new KeyboardEvent(ev.type, { + key: ev.key, + code: ev.code, + shiftKey: ev.shift, + ctrlKey: ev.control, + altKey: ev.alt, + metaKey: ev.meta, + repeat: ev.repeat, + })); + break; + } + case "bindCall": { + const fn_ = bindCallbacks.get(ev.name); + if (!fn_) { + op_desktop_reject_bind_call(ev.callId, "No callback bound for: " + ev.name); + break; + } + try { + const args = Array.isArray(ev.args) ? ev.args : []; + const result = await fn_(...args); + op_desktop_resolve_bind_call(ev.callId, result ?? null); + } catch (e) { + op_desktop_reject_bind_call(ev.callId, String(e)); + } + break; + } } } })(); @@ -262,15 +250,13 @@ pub fn desktop_auto_update_js( ) } -pub use deno_runtime::ops::desktop::BindCallReceiver; -pub use deno_runtime::ops::desktop::BindCallSender; -pub use deno_runtime::ops::desktop::KeyboardEventSender; -pub use deno_runtime::ops::desktop::MenuClickSender; +pub use deno_runtime::ops::desktop::DesktopEvent; +pub use deno_runtime::ops::desktop::DesktopEventReceiver; +pub use deno_runtime::ops::desktop::DesktopEventSender; pub use deno_runtime::ops::desktop::PendingBindCall; pub use deno_runtime::ops::desktop::PendingBindResponses; -pub use deno_runtime::ops::desktop::create_bind_call_channel; -pub use deno_runtime::ops::desktop::create_keyboard_event_channel; -pub use deno_runtime::ops::desktop::create_menu_click_channel; +pub use deno_runtime::ops::desktop::create_desktop_event_channel; +pub use deno_runtime::ops::desktop::register_bind_call; /// Place the DesktopApi and optional AutoUpdateState into OpState. /// The ops are already registered in the snapshot; this just provides @@ -285,14 +271,4 @@ pub fn init_desktop_state( if let Some(au) = auto_update { state.put::(au); } - // Create menu click channel so op_desktop_recv_menu_click can work - let (tx, rx) = create_menu_click_channel(); - state.put(rx); - state.put(tx); - // Create keyboard event channel so op_desktop_recv_keyboard_event can work - let (kb_tx, kb_rx) = create_keyboard_event_channel(); - state.put(kb_rx); - state.put(kb_tx); - // Initialize pending bind responses map - state.put(PendingBindResponses::new()); } diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 8720bd30964799..a20b6c93d0f95e 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -33,8 +33,8 @@ const DESKTOP_SERVE_PORT: u16 = 41520; /// WEF-backed implementation of [`denort::desktop::DesktopApi`]. struct WefDesktopApi { - bind_call_tx: - tokio::sync::mpsc::UnboundedSender, + event_tx: tokio::sync::mpsc::UnboundedSender, + pending_responses: deno_runtime::ops::desktop::PendingBindResponses, } impl denort::desktop::DesktopApi for WefDesktopApi { @@ -91,23 +91,27 @@ impl denort::desktop::DesktopApi for WefDesktopApi { } fn bind(&self, name: &str) { - let tx = self.bind_call_tx.clone(); + let tx = self.event_tx.clone(); + let responses = self.pending_responses.clone(); let name_owned = name.to_string(); wef::bind_async(name, move |js_call| { let tx = tx.clone(); + let responses = responses.clone(); let name = name_owned.clone(); async move { let args: Vec = js_call.args.iter().map(wef_value_to_json).collect(); let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); - let pending = denort::desktop::PendingBindCall { + let call_id = + deno_runtime::ops::desktop::register_bind_call(&responses, resp_tx); + let event = deno_runtime::ops::desktop::DesktopEvent::BindCall { name, args: serde_json::Value::Array(args), - response: resp_tx, + call_id, }; - if tx.send(pending).is_err() { + if tx.send(event).is_err() { js_call - .reject(wef::Value::String("bind channel closed".to_string())); + .reject(wef::Value::String("event channel closed".to_string())); return; } match resp_rx.await { @@ -753,33 +757,37 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { }, hmr_on_reload, op_state_init: Some(Box::new(move |state| { - let (bind_tx, bind_rx) = denort::desktop::create_bind_call_channel(); + let (event_tx, event_rx) = + denort::desktop::create_desktop_event_channel(); + let pending_responses = denort::desktop::PendingBindResponses::new(); + let menu_tx = event_tx.0.clone(); + let kb_tx = event_tx.0.clone(); denort::desktop::init_desktop_state( state, Box::new(WefDesktopApi { - bind_call_tx: bind_tx.0, + event_tx: event_tx.0.clone(), + pending_responses: pending_responses.clone(), }), auto_update_state, ); - state.put(bind_rx); - // Wire WEF menu click callback to the Deno runtime channel - let menu_tx = - state.borrow::().0.clone(); + state.put(event_rx); + state.put(event_tx); + state.put(pending_responses); + // Wire WEF menu click callback to the unified event channel wef::set_menu_click_handler(move |id| { - let _ = menu_tx.send(id.to_string()); + let _ = menu_tx.send( + deno_runtime::ops::desktop::DesktopEvent::MenuClick { + id: id.to_string(), + }, + ); }); - // Wire WEF keyboard events to the Deno runtime channel - let kb_tx = state - .borrow::() - .0 - .clone(); + // Wire WEF keyboard events to the unified event channel wef::on_keyboard_event(move |ev| { - eprintln!("[desktop] on_keyboard_event: sending {:?}/{:?}", ev.key, ev.state); - let result = - kb_tx.send(deno_runtime::ops::desktop::KeyboardEventData { + let _ = kb_tx.send( + deno_runtime::ops::desktop::DesktopEvent::KeyboardEvent { r#type: match ev.state { - wef::KeyState::Pressed => "keydown", - wef::KeyState::Released => "keyup", + wef::KeyState::Pressed => "keydown".to_string(), + wef::KeyState::Released => "keyup".to_string(), }, key: ev.key, code: ev.code, @@ -788,10 +796,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { alt: ev.modifiers.alt, meta: ev.modifiers.meta, repeat: ev.repeat, - }); - if let Err(e) = result { - eprintln!("[desktop] keyboard send failed: {}", e); - } + }, + ); }); })), override_main_module: None, diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 8c2e9173f1bc14..0ba75403a43112 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -544,9 +544,7 @@ const NOT_IMPORTED_OPS = [ "op_desktop_apply_patch", "op_desktop_confirm_update", "op_desktop_init", - "op_desktop_recv_menu_click", - "op_desktop_recv_keyboard_event", - "op_desktop_recv_bind_call", + "op_desktop_recv_event", "op_desktop_resolve_bind_call", "op_desktop_reject_bind_call", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 19dc0956ba3854..ded67eb0e9b3db 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -16,44 +16,46 @@ use std::sync::atomic::Ordering; use deno_core::FromV8; use deno_core::OpState; -use deno_core::ToV8; use deno_core::op2; use deno_core::serde_json; use deno_core::v8; -/// Channel for receiving menu click events in the Deno runtime. -pub struct MenuClickReceiver(pub tokio::sync::mpsc::UnboundedReceiver); -pub struct MenuClickSender(pub tokio::sync::mpsc::UnboundedSender); - -pub fn create_menu_click_channel() -> (MenuClickSender, MenuClickReceiver) { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - (MenuClickSender(tx), MenuClickReceiver(rx)) -} - -#[derive(Debug, Clone, ToV8)] -pub struct KeyboardEventData { - /// "keydown" or "keyup" - pub r#type: &'static str, - pub key: String, - pub code: String, - pub shift: bool, - pub control: bool, - pub alt: bool, - pub meta: bool, - pub repeat: bool, +/// A single event type that flows from the WEF backend to the Deno runtime. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum DesktopEvent { + MenuClick { + id: String, + }, + #[serde(rename_all = "camelCase")] + KeyboardEvent { + r#type: String, + key: String, + code: String, + shift: bool, + control: bool, + alt: bool, + meta: bool, + repeat: bool, + }, + #[serde(rename_all = "camelCase")] + BindCall { + name: String, + args: serde_json::Value, + call_id: u32, + }, } -pub struct KeyboardEventReceiver( - pub tokio::sync::mpsc::UnboundedReceiver, +pub struct DesktopEventReceiver( + pub tokio::sync::mpsc::UnboundedReceiver, ); -pub struct KeyboardEventSender( - pub tokio::sync::mpsc::UnboundedSender, +pub struct DesktopEventSender( + pub tokio::sync::mpsc::UnboundedSender, ); -pub fn create_keyboard_event_channel( -) -> (KeyboardEventSender, KeyboardEventReceiver) { +pub fn create_desktop_event_channel() -> (DesktopEventSender, DesktopEventReceiver) { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - (KeyboardEventSender(tx), KeyboardEventReceiver(rx)) + (DesktopEventSender(tx), DesktopEventReceiver(rx)) } /// A pending call from the webview to a bound Deno function. @@ -64,33 +66,37 @@ pub struct PendingBindCall { tokio::sync::oneshot::Sender>, } -pub struct BindCallReceiver( - pub tokio::sync::mpsc::UnboundedReceiver, -); -pub struct BindCallSender( - pub tokio::sync::mpsc::UnboundedSender, -); - -pub fn create_bind_call_channel() -> (BindCallSender, BindCallReceiver) { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - (BindCallSender(tx), BindCallReceiver(rx)) -} - +#[derive(Clone)] pub struct PendingBindResponses( - pub HashMap< - u32, - tokio::sync::oneshot::Sender>, + pub Arc< + std::sync::Mutex< + HashMap< + u32, + tokio::sync::oneshot::Sender>, + >, + >, >, ); impl PendingBindResponses { pub fn new() -> Self { - Self(HashMap::new()) + Self(Arc::new(std::sync::Mutex::new(HashMap::new()))) } } static BIND_CALL_COUNTER: AtomicU32 = AtomicU32::new(1); +/// Assign a call_id for a bind call and register its response sender. +/// Returns the call_id to embed in the `DesktopEvent::BindCall`. +pub fn register_bind_call( + responses: &PendingBindResponses, + response: tokio::sync::oneshot::Sender>, +) -> u32 { + let call_id = BIND_CALL_COUNTER.fetch_add(1, Ordering::Relaxed); + responses.0.lock().unwrap().insert(call_id, response); + call_id +} + /// Trait for desktop window operations. Implemented by the desktop /// runtime (denort_desktop) to bridge to the WEF backend. pub trait DesktopApi: Send + Sync + 'static { @@ -368,36 +374,16 @@ pub fn op_desktop_apply_patch( } #[op2] -#[string] -async fn op_desktop_recv_menu_click( - state: std::rc::Rc>, -) -> Option { - let rx = { - let mut s = state.borrow_mut(); - s.try_take::() - }; - if let Some(mut rx) = rx { - let result = rx.0.recv().await; - state.borrow_mut().put(rx); - result - } else { - // No receiver — desktop not initialized, pend forever - std::future::pending().await - } -} - -#[op2] -async fn op_desktop_recv_keyboard_event( +#[serde] +async fn op_desktop_recv_event( state: std::rc::Rc>, -) -> Option { +) -> Option { let rx = { let mut s = state.borrow_mut(); - s.try_take::() + s.try_take::() }; - dbg!(rx.is_some()); if let Some(mut rx) = rx { let result = rx.0.recv().await; - dbg!(&result); state.borrow_mut().put(rx); result } else { @@ -418,45 +404,6 @@ pub fn op_desktop_confirm_update(state: &mut OpState) { } } -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct BindCallInfo { - name: String, - args: serde_json::Value, - call_id: u32, -} - -#[op2] -#[serde] -async fn op_desktop_recv_bind_call( - state: std::rc::Rc>, -) -> Option { - let rx = { - let mut s = state.borrow_mut(); - s.try_take::() - }; - if let Some(mut rx) = rx { - let result = rx.0.recv().await; - state.borrow_mut().put(rx); - if let Some(call) = result { - let call_id = BIND_CALL_COUNTER.fetch_add(1, Ordering::Relaxed); - { - let mut s = state.borrow_mut(); - let responses = s.borrow_mut::(); - responses.0.insert(call_id, call.response); - } - Some(BindCallInfo { - name: call.name, - args: call.args, - call_id, - }) - } else { - None - } - } else { - std::future::pending().await - } -} #[op2] fn op_desktop_resolve_bind_call( @@ -464,8 +411,8 @@ fn op_desktop_resolve_bind_call( #[smi] call_id: u32, #[serde] result: serde_json::Value, ) { - if let Some(responses) = state.try_borrow_mut::() { - if let Some(tx) = responses.0.remove(&call_id) { + if let Some(responses) = state.try_borrow::() { + if let Some(tx) = responses.0.lock().unwrap().remove(&call_id) { let _ = tx.send(Ok(result)); } } @@ -477,8 +424,8 @@ fn op_desktop_reject_bind_call( #[smi] call_id: u32, #[string] error: String, ) { - if let Some(responses) = state.try_borrow_mut::() { - if let Some(tx) = responses.0.remove(&call_id) { + if let Some(responses) = state.try_borrow::() { + if let Some(tx) = responses.0.lock().unwrap().remove(&call_id) { let _ = tx.send(Err(error)); } } @@ -504,9 +451,7 @@ deno_core::extension!( op_desktop_apply_patch, op_desktop_confirm_update, op_desktop_init, - op_desktop_recv_menu_click, - op_desktop_recv_keyboard_event, - op_desktop_recv_bind_call, + op_desktop_recv_event, op_desktop_resolve_bind_call, op_desktop_reject_bind_call, ], From d3a86b0945e98679392d5032b6de2ea1f54251d9 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 19 Mar 2026 01:06:50 +0100 Subject: [PATCH 014/137] mouse events --- cli/args/flags.rs | 2 +- cli/rt/desktop.rs | 128 ++++++++++++++++++++++++++++++ cli/rt_desktop/lib.rs | 65 ++++++++++++++- cli/tools/compile.rs | 12 ++- cli/tsc/dts/lib.deno.desktop.d.ts | 6 ++ runtime/ops/desktop.rs | 33 ++++++++ 6 files changed, 239 insertions(+), 7 deletions(-) diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 450884f414363c..02ca1a46531a70 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -2865,7 +2865,7 @@ On the first invocation of `deno compile`, Deno will download the relevant binar Arg::new("backend") .long("backend") .help("WEF backend to use for the desktop app") - .value_parser(["webview", "cef", "servo"]) + .value_parser(["webview", "cef", "servo", "raw"]) .default_value("webview") .requires("desktop") .help_heading(COMPILE_HEADING), diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 1f9f0b1b5909cb..f8560700db1686 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -71,7 +71,74 @@ pub const DESKTOP_JS: &str = r#" } } } + + class MouseEvent extends Event { + #button = 0; + #clientX = 0; + #clientY = 0; + #ctrlKey = false; + #shiftKey = false; + #altKey = false; + #metaKey = false; + #detail = 0; + + get button() { return this.#button; } + get clientX() { return this.#clientX; } + get clientY() { return this.#clientY; } + get screenX() { return this.#clientX; } + get screenY() { return this.#clientY; } + get ctrlKey() { return this.#ctrlKey; } + get shiftKey() { return this.#shiftKey; } + get altKey() { return this.#altKey; } + get metaKey() { return this.#metaKey; } + get detail() { return this.#detail; } + + constructor(type, init = {}) { + super(type, init); + this.#button = init.button ?? 0; + this.#clientX = init.clientX ?? 0; + this.#clientY = init.clientY ?? 0; + this.#ctrlKey = init.ctrlKey ?? false; + this.#shiftKey = init.shiftKey ?? false; + this.#altKey = init.altKey ?? false; + this.#metaKey = init.metaKey ?? false; + this.#detail = init.detail ?? 0; + } + + getModifierState(key) { + switch (key) { + case "Alt": return this.#altKey; + case "Control": return this.#ctrlKey; + case "Meta": return this.#metaKey; + case "Shift": return this.#shiftKey; + default: return false; + } + } + } + + class WheelEvent extends MouseEvent { + #deltaX = 0; + #deltaY = 0; + #deltaZ = 0; + #deltaMode = 0; + + get deltaX() { return this.#deltaX; } + get deltaY() { return this.#deltaY; } + get deltaZ() { return this.#deltaZ; } + get deltaMode() { return this.#deltaMode; } + + constructor(type, init = {}) { + super(type, init); + this.#deltaX = init.deltaX ?? 0; + this.#deltaY = init.deltaY ?? 0; + this.#deltaZ = init.deltaZ ?? 0; + this.#deltaMode = init.deltaMode ?? 0; + } + } + globalThis.KeyboardEvent = internals.core.propNonEnumerable(KeyboardEvent); + globalThis.MouseEvent = internals.core.propNonEnumerable(MouseEvent); + globalThis.WheelEvent = internals.core.propNonEnumerable(WheelEvent); op_desktop_init( internals.webidlBrand, @@ -92,6 +159,12 @@ pub const DESKTOP_JS: &str = r#" internals.defineEventHandler(BrowserWindowPrototype, "keydown"); internals.defineEventHandler(BrowserWindowPrototype, "keyup"); + internals.defineEventHandler(BrowserWindowPrototype, "mousedown"); + internals.defineEventHandler(BrowserWindowPrototype, "mouseup"); + internals.defineEventHandler(BrowserWindowPrototype, "click"); + internals.defineEventHandler(BrowserWindowPrototype, "dblclick"); + internals.defineEventHandler(BrowserWindowPrototype, "mousemove"); + internals.defineEventHandler(BrowserWindowPrototype, "wheel"); // Bind callback registry: name -> bound function const bindCallbacks = new Map(); @@ -153,6 +226,61 @@ pub const DESKTOP_JS: &str = r#" } break; } + case "mouseClick": { + const target = activeWindow; + if (!target) break; + const init = { + button: ev.button, + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + detail: ev.clickCount, + }; + if (ev.state === "pressed") { + target.dispatchEvent(new MouseEvent("mousedown", init)); + } else { + target.dispatchEvent(new MouseEvent("mouseup", init)); + if (ev.button === 0) { + target.dispatchEvent(new MouseEvent("click", init)); + if (ev.clickCount >= 2) { + target.dispatchEvent(new MouseEvent("dblclick", init)); + } + } + } + break; + } + case "mouseMove": { + const target = activeWindow; + if (!target) break; + target.dispatchEvent(new MouseEvent("mousemove", { + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + })); + break; + } + case "wheel": { + const target = activeWindow; + if (!target) break; + target.dispatchEvent(new WheelEvent("wheel", { + deltaX: ev.deltaX, + deltaY: ev.deltaY, + deltaMode: ev.deltaMode, + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + })); + break; + } } } })(); diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index a20b6c93d0f95e..2fffca4696e71e 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -762,6 +762,9 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let pending_responses = denort::desktop::PendingBindResponses::new(); let menu_tx = event_tx.0.clone(); let kb_tx = event_tx.0.clone(); + let mouse_click_tx = event_tx.0.clone(); + let mouse_move_tx = event_tx.0.clone(); + let wheel_tx = event_tx.0.clone(); denort::desktop::init_desktop_state( state, Box::new(WefDesktopApi { @@ -773,7 +776,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { state.put(event_rx); state.put(event_tx); state.put(pending_responses); - // Wire WEF menu click callback to the unified event channel + wef::set_menu_click_handler(move |id| { let _ = menu_tx.send( deno_runtime::ops::desktop::DesktopEvent::MenuClick { @@ -781,7 +784,6 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { }, ); }); - // Wire WEF keyboard events to the unified event channel wef::on_keyboard_event(move |ev| { let _ = kb_tx.send( deno_runtime::ops::desktop::DesktopEvent::KeyboardEvent { @@ -799,6 +801,65 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { }, ); }); + // Wire WEF mouse click events to the unified event channel + wef::on_mouse_click(move |ev| { + let _ = mouse_click_tx.send( + deno_runtime::ops::desktop::DesktopEvent::MouseClick { + state: match ev.state { + wef::MouseButtonState::Pressed => "pressed".to_string(), + wef::MouseButtonState::Released => "released".to_string(), + }, + button: match ev.button { + wef::MouseButton::Left => 0, + wef::MouseButton::Middle => 1, + wef::MouseButton::Right => 2, + wef::MouseButton::Back => 3, + wef::MouseButton::Forward => 4, + wef::MouseButton::Other(n) => n, + }, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + click_count: ev.click_count, + }, + ); + }); + // Wire WEF mouse move events to the unified event channel + wef::on_mouse_move(move |ev| { + let _ = mouse_move_tx.send( + deno_runtime::ops::desktop::DesktopEvent::MouseMove { + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }, + ); + }); + // Wire WEF wheel events to the unified event channel + wef::on_wheel(move |ev| { + let _ = wheel_tx.send( + deno_runtime::ops::desktop::DesktopEvent::Wheel { + delta_x: ev.delta_x, + delta_y: ev.delta_y, + delta_mode: match ev.delta_mode { + wef::WheelDeltaMode::Pixel => 0, + wef::WheelDeltaMode::Line => 1, + wef::WheelDeltaMode::Page => 2, + }, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }, + ); + }); })), override_main_module: None, auto_update_version, diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index 09609d617fef56..c1547335d9f7ee 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -55,8 +55,12 @@ fn wef_backend_search_paths(backend: &str) -> Vec { paths.push(wef.join("cef/build/wef.app/Contents/MacOS/wef")); } "servo" => { - paths.push(wef.join("servo/target/release/wef_servo")); - paths.push(wef.join("servo/target/debug/wef_servo")); + paths.push(wef.join("target/release/wef_servo")); + paths.push(wef.join("target/debug/wef_servo")); + } + "raw" => { + paths.push(wef.join("target/release/wef_winit")); + paths.push(wef.join("target/debug/wef_winit")); } _ => { paths.push(wef.join("result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview")); @@ -388,7 +392,7 @@ fn wef_backend_app_search_paths(backend: &str) -> Vec { paths.push(wef.join("cef/build/Release/wef.app").to_string_lossy().to_string()); paths.push(wef.join("cef/build/wef.app").to_string_lossy().to_string()); } - "winit" | "servo" => {} // Not .app bundles + "raw" | "servo" => {} // Not .app bundles _ => { paths.push(wef.join("result-1/Applications/wef_webview.app").to_string_lossy().to_string()); paths.push(wef.join("result/Applications/wef_webview.app").to_string_lossy().to_string()); @@ -489,7 +493,7 @@ fn package_macos_app_bundle( .join(format!("{}.app", app_name)); // Find the WEF backend .app and its main executable. - let backend = compile_flags.backend.as_deref().unwrap_or("webview"); + let backend = compile_flags.backend.as_deref().unwrap_or("cef"); let wef_app = find_wef_backend_app_bundle(backend)?; let wef_plist_path = wef_app.join("Contents/Info.plist"); let wef_executable_name = if wef_plist_path.exists() { diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index a2cdded5a427e1..31b5fa4b512957 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -56,6 +56,12 @@ declare namespace Deno { interface BrowserWindowEventMap { keydown: KeyboardEvent; keyup: KeyboardEvent; + mousedown: MouseEvent; + mouseup: MouseEvent; + click: MouseEvent; + dblclick: MouseEvent; + mousemove: MouseEvent; + wheel: WheelEvent; } type BrowserWindowEventHandlers = { diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index ded67eb0e9b3db..65220eb9bb7626 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -44,6 +44,39 @@ pub enum DesktopEvent { args: serde_json::Value, call_id: u32, }, + #[serde(rename_all = "camelCase")] + MouseClick { + state: String, + button: i32, + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + click_count: i32, + }, + #[serde(rename_all = "camelCase")] + MouseMove { + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + }, + #[serde(rename_all = "camelCase")] + Wheel { + delta_x: f64, + delta_y: f64, + delta_mode: i32, + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + }, } pub struct DesktopEventReceiver( From 81b38b41e0e8ee9257ac32314e1a0f75a4160c56 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 24 Mar 2026 19:35:45 +0100 Subject: [PATCH 015/137] multiple windows --- cli/rt/desktop.rs | 83 ++++++- cli/rt_desktop/lib.rs | 359 ++++++++++++++++++------------ cli/tsc/dts/lib.deno.desktop.d.ts | 19 ++ runtime/ops/desktop.rs | 151 +++++++++---- 4 files changed, 417 insertions(+), 195 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index f8560700db1686..78edeaaf581526 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -145,12 +145,13 @@ pub const DESKTOP_JS: &str = r#" internals.setEventTargetData, ); - // Track the active BrowserWindow instance for event dispatch. - let activeWindow = null; + // Window registry: windowId -> BrowserWindow instance. + const windows = new Map(); const nativeConstructor = BrowserWindow; const OrigBW = function(...args) { const instance = new nativeConstructor(...args); - activeWindow = instance; + const windowId = instance.getWindowId(); + windows.set(windowId, instance); return instance; }; Object.setPrototypeOf(OrigBW, nativeConstructor); @@ -165,19 +166,32 @@ pub const DESKTOP_JS: &str = r#" internals.defineEventHandler(BrowserWindowPrototype, "dblclick"); internals.defineEventHandler(BrowserWindowPrototype, "mousemove"); internals.defineEventHandler(BrowserWindowPrototype, "wheel"); + internals.defineEventHandler(BrowserWindowPrototype, "mouseenter"); + internals.defineEventHandler(BrowserWindowPrototype, "mouseleave"); + internals.defineEventHandler(BrowserWindowPrototype, "focus"); + internals.defineEventHandler(BrowserWindowPrototype, "blur"); + internals.defineEventHandler(BrowserWindowPrototype, "resize"); + internals.defineEventHandler(BrowserWindowPrototype, "move"); + internals.defineEventHandler(BrowserWindowPrototype, "close"); - // Bind callback registry: name -> bound function - const bindCallbacks = new Map(); + // Per-window bind callback registry: windowId -> Map + const windowBindCallbacks = new Map(); const nativeBind = BrowserWindowPrototype.bind; BrowserWindowPrototype.bind = function(name, fn) { - bindCallbacks.set(name, fn.bind(this)); + const windowId = this.getWindowId(); + if (!windowBindCallbacks.has(windowId)) { + windowBindCallbacks.set(windowId, new Map()); + } + windowBindCallbacks.get(windowId).set(name, fn.bind(this)); nativeBind.call(this, name); }; const nativeUnbind = BrowserWindowPrototype.unbind; BrowserWindowPrototype.unbind = function(name) { - bindCallbacks.delete(name); + const windowId = this.getWindowId(); + const callbacks = windowBindCallbacks.get(windowId); + if (callbacks) callbacks.delete(name); nativeUnbind.call(this, name); }; @@ -198,7 +212,7 @@ pub const DESKTOP_JS: &str = r#" dispatchEvent(new CustomEvent("menuclick", { detail: { id: ev.id } })); break; case "keyboardEvent": { - const target = activeWindow; + const target = windows.get(ev.windowId); if (!target) break; target.dispatchEvent(new KeyboardEvent(ev.type, { key: ev.key, @@ -212,7 +226,8 @@ pub const DESKTOP_JS: &str = r#" break; } case "bindCall": { - const fn_ = bindCallbacks.get(ev.name); + const callbacks = windowBindCallbacks.get(ev.windowId); + const fn_ = callbacks?.get(ev.name); if (!fn_) { op_desktop_reject_bind_call(ev.callId, "No callback bound for: " + ev.name); break; @@ -227,7 +242,7 @@ pub const DESKTOP_JS: &str = r#" break; } case "mouseClick": { - const target = activeWindow; + const target = windows.get(ev.windowId); if (!target) break; const init = { button: ev.button, @@ -253,7 +268,7 @@ pub const DESKTOP_JS: &str = r#" break; } case "mouseMove": { - const target = activeWindow; + const target = windows.get(ev.windowId); if (!target) break; target.dispatchEvent(new MouseEvent("mousemove", { clientX: ev.clientX, @@ -266,7 +281,7 @@ pub const DESKTOP_JS: &str = r#" break; } case "wheel": { - const target = activeWindow; + const target = windows.get(ev.windowId); if (!target) break; target.dispatchEvent(new WheelEvent("wheel", { deltaX: ev.deltaX, @@ -281,6 +296,49 @@ pub const DESKTOP_JS: &str = r#" })); break; } + case "cursorEnterLeave": { + const target = windows.get(ev.windowId); + if (!target) break; + const init = { + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + }; + target.dispatchEvent(new MouseEvent( + ev.entered ? "mouseenter" : "mouseleave", init)); + break; + } + case "focusChanged": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new FocusEvent(ev.focused ? "focus" : "blur")); + break; + } + case "windowResize": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("resize", { + detail: { width: ev.width, height: ev.height }, + })); + break; + } + case "windowMove": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("move", { + detail: { x: ev.x, y: ev.y }, + })); + break; + } + case "closeRequested": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new Event("close")); + break; + } } } })(); @@ -381,6 +439,7 @@ pub fn desktop_auto_update_js( pub use deno_runtime::ops::desktop::DesktopEvent; pub use deno_runtime::ops::desktop::DesktopEventReceiver; pub use deno_runtime::ops::desktop::DesktopEventSender; +pub use deno_runtime::ops::desktop::InitialWindowId; pub use deno_runtime::ops::desktop::PendingBindCall; pub use deno_runtime::ops::desktop::PendingBindResponses; pub use deno_runtime::ops::desktop::create_desktop_event_channel; diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 2fffca4696e71e..6368651478735c 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -16,6 +16,8 @@ use std::env; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering; use deno_core::anyhow::Context; use deno_core::anyhow::bail; @@ -26,6 +28,7 @@ use deno_lib::util::result::js_error_downcast_ref; use deno_lib::version::otel_runtime_config; use deno_runtime::fmt_errors::format_js_error; use deno_terminal::colors; +use denort::desktop::DesktopApi; use denort::run::RunOptions; /// Port used for the embedded HTTP server. @@ -37,64 +40,216 @@ struct WefDesktopApi { pending_responses: deno_runtime::ops::desktop::PendingBindResponses, } +impl WefDesktopApi { + /// Set up all event handlers on a newly created window, wiring events + /// into the shared event channel. + fn setup_window_events(&self, window: wef::Window) -> wef::Window { + let kb_tx = self.event_tx.clone(); + let mouse_click_tx = self.event_tx.clone(); + let mouse_move_tx = self.event_tx.clone(); + let wheel_tx = self.event_tx.clone(); + let cursor_tx = self.event_tx.clone(); + let focus_tx = self.event_tx.clone(); + let resize_tx = self.event_tx.clone(); + let move_tx = self.event_tx.clone(); + let close_tx = self.event_tx.clone(); + + window + .on_keyboard_event(move |ev| { + let _ = kb_tx.send( + deno_runtime::ops::desktop::DesktopEvent::KeyboardEvent { + window_id: ev.window_id, + r#type: match ev.state { + wef::KeyState::Pressed => "keydown".to_string(), + wef::KeyState::Released => "keyup".to_string(), + }, + key: ev.key, + code: ev.code, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + repeat: ev.repeat, + }, + ); + }) + .on_mouse_click(move |ev| { + let _ = mouse_click_tx.send( + deno_runtime::ops::desktop::DesktopEvent::MouseClick { + window_id: ev.window_id, + state: match ev.state { + wef::MouseButtonState::Pressed => "pressed".to_string(), + wef::MouseButtonState::Released => "released".to_string(), + }, + button: match ev.button { + wef::MouseButton::Left => 0, + wef::MouseButton::Middle => 1, + wef::MouseButton::Right => 2, + wef::MouseButton::Back => 3, + wef::MouseButton::Forward => 4, + wef::MouseButton::Other(n) => n, + }, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + click_count: ev.click_count, + }, + ); + }) + .on_mouse_move(move |ev| { + let _ = mouse_move_tx.send( + deno_runtime::ops::desktop::DesktopEvent::MouseMove { + window_id: ev.window_id, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }, + ); + }) + .on_wheel(move |ev| { + let _ = wheel_tx.send( + deno_runtime::ops::desktop::DesktopEvent::Wheel { + window_id: ev.window_id, + delta_x: ev.delta_x, + delta_y: ev.delta_y, + delta_mode: match ev.delta_mode { + wef::WheelDeltaMode::Pixel => 0, + wef::WheelDeltaMode::Line => 1, + wef::WheelDeltaMode::Page => 2, + }, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }, + ); + }) + .on_cursor_enter_leave(move |ev| { + let _ = cursor_tx.send( + deno_runtime::ops::desktop::DesktopEvent::CursorEnterLeave { + window_id: ev.window_id, + entered: ev.entered, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }, + ); + }) + .on_focused(move |ev| { + let _ = focus_tx.send( + deno_runtime::ops::desktop::DesktopEvent::FocusChanged { + window_id: ev.window_id, + focused: ev.focused, + }, + ); + }) + .on_resize(move |ev| { + let _ = resize_tx.send( + deno_runtime::ops::desktop::DesktopEvent::WindowResize { + window_id: ev.window_id, + width: ev.width, + height: ev.height, + }, + ); + }) + .on_move(move |ev| { + let _ = move_tx.send( + deno_runtime::ops::desktop::DesktopEvent::WindowMove { + window_id: ev.window_id, + x: ev.x, + y: ev.y, + }, + ); + }) + .on_close_requested(move |ev| { + let _ = close_tx.send( + deno_runtime::ops::desktop::DesktopEvent::CloseRequested { + window_id: ev.window_id, + }, + ); + }) + } +} + impl denort::desktop::DesktopApi for WefDesktopApi { - fn set_title(&self, title: &str) { - wef::set_title(title); + fn create_window(&self, width: i32, height: i32) -> u32 { + let window = wef::Window::new(width, height); + let window = self.setup_window_events(window); + window.id() } - fn get_window_size(&self) -> (i32, i32) { - wef::get_window_size() + fn close_window(&self, window_id: u32) { + wef::Window::from_id(window_id).close(); } - fn set_window_size(&self, width: i32, height: i32) { - wef::set_window_size(width, height); + fn set_title(&self, window_id: u32, title: &str) { + wef::Window::from_id(window_id).set_title(title); } - fn get_window_position(&self) -> (i32, i32) { - wef::get_window_position() + fn get_window_size(&self, window_id: u32) -> (i32, i32) { + wef::Window::from_id(window_id).get_size() } - fn set_window_position(&self, width: i32, height: i32) { - wef::set_window_position(width, height); + fn set_window_size(&self, window_id: u32, width: i32, height: i32) { + wef::Window::from_id(window_id).set_size(width, height); } - fn is_resizeable(&self) -> bool { - wef::is_resizable() + fn get_window_position(&self, window_id: u32) -> (i32, i32) { + wef::Window::from_id(window_id).get_position() } - fn set_resizeable(&self, resizeable: bool) { - wef::set_resizable(resizeable); + fn set_window_position(&self, window_id: u32, x: i32, y: i32) { + wef::Window::from_id(window_id).set_position(x, y); } - fn is_always_on_top(&self) -> bool { - wef::is_always_on_top() + fn is_resizable(&self, window_id: u32) -> bool { + wef::Window::from_id(window_id).get_resizable() } - fn set_always_on_top(&self, always_on_top: bool) { - wef::set_always_on_top(always_on_top); + fn set_resizable(&self, window_id: u32, resizable: bool) { + wef::Window::from_id(window_id).set_resizable(resizable); } - fn is_visible(&self) -> bool { - wef::is_visible() + fn is_always_on_top(&self, window_id: u32) -> bool { + wef::Window::from_id(window_id).get_always_on_top() } - fn show(&self) { - wef::show(); + fn set_always_on_top(&self, window_id: u32, always_on_top: bool) { + wef::Window::from_id(window_id).set_always_on_top(always_on_top); } - fn hide(&self) { - wef::hide(); + fn is_visible(&self, window_id: u32) -> bool { + wef::Window::from_id(window_id).get_visible() } - fn focus(&self) { - wef::focus(); + fn show(&self, window_id: u32) { + wef::Window::from_id(window_id).show(); } - fn bind(&self, name: &str) { + fn hide(&self, window_id: u32) { + wef::Window::from_id(window_id).hide(); + } + + fn focus(&self, window_id: u32) { + wef::Window::from_id(window_id).focus(); + } + + fn bind(&self, window_id: u32, name: &str) { let tx = self.event_tx.clone(); let responses = self.pending_responses.clone(); let name_owned = name.to_string(); - wef::bind_async(name, move |js_call| { + wef::Window::from_id(window_id).add_binding_async(name, move |js_call| { let tx = tx.clone(); let responses = responses.clone(); let name = name_owned.clone(); @@ -105,6 +260,7 @@ impl denort::desktop::DesktopApi for WefDesktopApi { let call_id = deno_runtime::ops::desktop::register_bind_call(&responses, resp_tx); let event = deno_runtime::ops::desktop::DesktopEvent::BindCall { + window_id: js_call.window_id, name, args: serde_json::Value::Array(args), call_id, @@ -131,27 +287,12 @@ impl denort::desktop::DesktopApi for WefDesktopApi { }); } - fn unbind(&self, name: &str) { - wef::unbind(name); + fn unbind(&self, window_id: u32, name: &str) { + wef::Window::from_id(window_id).unbind(name); } - fn navigate(&self, url: &str) { - wef::navigate(url); - } - - fn execute_js<'a>(&self, _scope: &'a mut v8::PinScope<'a, '_>, _script: &str) -> std::pin::Pin, v8::Local<'a, v8::Value>>> + 'a>> { - todo!() - /*let (tx, rx) = tokio::sync::oneshot::channel(); - wef::execute_js(script, Some(|res| { - let _ = tx.send(res); - })); - - Box::pin(async move { - match rx.await.expect("execute_js channel failed") { - Ok(val) => Ok(wef_value_to_v8(scope, val)), - Err(err) => Err(wef_value_to_v8(scope, err)), - } - })*/ + fn navigate(&self, window_id: u32, url: &str) { + wef::Window::from_id(window_id).navigate(url); } fn quit(&self) { @@ -716,13 +857,21 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { } } + // Shared initial window ID for navigate_fut and HMR reload. + let initial_window_id = Arc::new(AtomicU32::new(0)); + let initial_window_id_for_hmr = initial_window_id.clone(); + let initial_window_id_for_navigate = initial_window_id.clone(); + let hmr_on_reload: Option = if hmr_watch_dir.is_some() && !is_framework_dev { - Some(Box::new(|| { - wef::execute_js::)>( - "location.reload()", - None, - ); + Some(Box::new(move || { + let id = initial_window_id_for_hmr.load(Ordering::Acquire); + if id != 0 { + wef::Window::from_id(id).execute_js::)>( + "location.reload()", + None, + ); + } })) } else { None @@ -760,22 +909,28 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let (event_tx, event_rx) = denort::desktop::create_desktop_event_channel(); let pending_responses = denort::desktop::PendingBindResponses::new(); + + let api = WefDesktopApi { + event_tx: event_tx.0.clone(), + pending_responses: pending_responses.clone(), + }; + + // Create the initial window and wire up event handlers. + let window_id = api.create_window(800, 600); + initial_window_id.store(window_id, Ordering::Release); + let menu_tx = event_tx.0.clone(); - let kb_tx = event_tx.0.clone(); - let mouse_click_tx = event_tx.0.clone(); - let mouse_move_tx = event_tx.0.clone(); - let wheel_tx = event_tx.0.clone(); denort::desktop::init_desktop_state( state, - Box::new(WefDesktopApi { - event_tx: event_tx.0.clone(), - pending_responses: pending_responses.clone(), - }), + Box::new(api), auto_update_state, ); state.put(event_rx); state.put(event_tx); state.put(pending_responses); + state.put(denort::desktop::InitialWindowId( + std::sync::Mutex::new(Some(window_id)), + )); wef::set_menu_click_handler(move |id| { let _ = menu_tx.send( @@ -784,82 +939,6 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { }, ); }); - wef::on_keyboard_event(move |ev| { - let _ = kb_tx.send( - deno_runtime::ops::desktop::DesktopEvent::KeyboardEvent { - r#type: match ev.state { - wef::KeyState::Pressed => "keydown".to_string(), - wef::KeyState::Released => "keyup".to_string(), - }, - key: ev.key, - code: ev.code, - shift: ev.modifiers.shift, - control: ev.modifiers.control, - alt: ev.modifiers.alt, - meta: ev.modifiers.meta, - repeat: ev.repeat, - }, - ); - }); - // Wire WEF mouse click events to the unified event channel - wef::on_mouse_click(move |ev| { - let _ = mouse_click_tx.send( - deno_runtime::ops::desktop::DesktopEvent::MouseClick { - state: match ev.state { - wef::MouseButtonState::Pressed => "pressed".to_string(), - wef::MouseButtonState::Released => "released".to_string(), - }, - button: match ev.button { - wef::MouseButton::Left => 0, - wef::MouseButton::Middle => 1, - wef::MouseButton::Right => 2, - wef::MouseButton::Back => 3, - wef::MouseButton::Forward => 4, - wef::MouseButton::Other(n) => n, - }, - client_x: ev.x, - client_y: ev.y, - shift: ev.modifiers.shift, - control: ev.modifiers.control, - alt: ev.modifiers.alt, - meta: ev.modifiers.meta, - click_count: ev.click_count, - }, - ); - }); - // Wire WEF mouse move events to the unified event channel - wef::on_mouse_move(move |ev| { - let _ = mouse_move_tx.send( - deno_runtime::ops::desktop::DesktopEvent::MouseMove { - client_x: ev.x, - client_y: ev.y, - shift: ev.modifiers.shift, - control: ev.modifiers.control, - alt: ev.modifiers.alt, - meta: ev.modifiers.meta, - }, - ); - }); - // Wire WEF wheel events to the unified event channel - wef::on_wheel(move |ev| { - let _ = wheel_tx.send( - deno_runtime::ops::desktop::DesktopEvent::Wheel { - delta_x: ev.delta_x, - delta_y: ev.delta_y, - delta_mode: match ev.delta_mode { - wef::WheelDeltaMode::Pixel => 0, - wef::WheelDeltaMode::Line => 1, - wef::WheelDeltaMode::Page => 2, - }, - client_x: ev.x, - client_y: ev.y, - shift: ev.modifiers.shift, - control: ev.modifiers.control, - alt: ev.modifiers.alt, - meta: ev.modifiers.meta, - }, - ); - }); })), override_main_module: None, auto_update_version, @@ -875,7 +954,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { denort::run::run_with_options(Arc::new(sys.clone()), sys, data, run_opts); let wef_fut = wef::run(); - // Wait for the server to be ready, then navigate the webview. + // Wait for the server to be ready, then navigate the initial window. // Do a full HTTP request instead of just a TCP connect — frameworks // like Vite accept connections before they're ready to serve. let navigate_fut = async { @@ -903,7 +982,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { i + 1, &url ); - wef::navigate(&url); + let id = initial_window_id_for_navigate.load(Ordering::Acquire); + wef::Window::from_id(id).navigate(&url); return; } } @@ -912,7 +992,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { tokio::time::sleep(std::time::Duration::from_millis(250)).await; } log::warn!("Server not ready after 15s, navigating anyway"); - wef::navigate(&url); + let id = initial_window_id_for_navigate.load(Ordering::Acquire); + wef::Window::from_id(id).navigate(&url); }; tokio::select! { diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 31b5fa4b512957..794d4105db7444 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -53,6 +53,16 @@ declare namespace Deno { ) => Promise; }; + interface BrowserWindowResizeDetail { + width: number; + height: number; + } + + interface BrowserWindowMoveDetail { + x: number; + y: number; + } + interface BrowserWindowEventMap { keydown: KeyboardEvent; keyup: KeyboardEvent; @@ -61,7 +71,16 @@ declare namespace Deno { click: MouseEvent; dblclick: MouseEvent; mousemove: MouseEvent; + mouseenter: MouseEvent; + mouseleave: MouseEvent; wheel: WheelEvent; + focus: FocusEvent; + blur: FocusEvent; + + // non-standard events + resize: CustomEvent; + move: CustomEvent; + close: Event; } type BrowserWindowEventHandlers = { diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 65220eb9bb7626..57a1d3b98f201d 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -8,8 +8,6 @@ use std::borrow::Cow; use std::collections::HashMap; -use std::future::Future; -use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; @@ -29,6 +27,7 @@ pub enum DesktopEvent { }, #[serde(rename_all = "camelCase")] KeyboardEvent { + window_id: u32, r#type: String, key: String, code: String, @@ -40,12 +39,14 @@ pub enum DesktopEvent { }, #[serde(rename_all = "camelCase")] BindCall { + window_id: u32, name: String, args: serde_json::Value, call_id: u32, }, #[serde(rename_all = "camelCase")] MouseClick { + window_id: u32, state: String, button: i32, client_x: f64, @@ -58,6 +59,7 @@ pub enum DesktopEvent { }, #[serde(rename_all = "camelCase")] MouseMove { + window_id: u32, client_x: f64, client_y: f64, shift: bool, @@ -67,6 +69,7 @@ pub enum DesktopEvent { }, #[serde(rename_all = "camelCase")] Wheel { + window_id: u32, delta_x: f64, delta_y: f64, delta_mode: i32, @@ -77,6 +80,38 @@ pub enum DesktopEvent { alt: bool, meta: bool, }, + #[serde(rename_all = "camelCase")] + CursorEnterLeave { + window_id: u32, + entered: bool, + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + }, + #[serde(rename_all = "camelCase")] + FocusChanged { + window_id: u32, + focused: bool, + }, + #[serde(rename_all = "camelCase")] + WindowResize { + window_id: u32, + width: i32, + height: i32, + }, + #[serde(rename_all = "camelCase")] + WindowMove { + window_id: u32, + x: i32, + y: i32, + }, + #[serde(rename_all = "camelCase")] + CloseRequested { + window_id: u32, + }, } pub struct DesktopEventReceiver( @@ -132,36 +167,48 @@ pub fn register_bind_call( /// Trait for desktop window operations. Implemented by the desktop /// runtime (denort_desktop) to bridge to the WEF backend. +/// +/// All per-window methods take a `window_id` identifying the target window. pub trait DesktopApi: Send + Sync + 'static { - fn set_title(&self, title: &str); + /// Create a new window with the given dimensions and return its ID. + fn create_window(&self, width: i32, height: i32) -> u32; + /// Close a specific window. + fn close_window(&self, window_id: u32); + + fn set_title(&self, window_id: u32, title: &str); - fn get_window_size(&self) -> (i32, i32); - fn set_window_size(&self, width: i32, height: i32); + fn get_window_size(&self, window_id: u32) -> (i32, i32); + fn set_window_size(&self, window_id: u32, width: i32, height: i32); - fn get_window_position(&self) -> (i32, i32); - fn set_window_position(&self, width: i32, height: i32); + fn get_window_position(&self, window_id: u32) -> (i32, i32); + fn set_window_position(&self, window_id: u32, x: i32, y: i32); - fn is_resizeable(&self) -> bool; - fn set_resizeable(&self, resizeable: bool); + fn is_resizable(&self, window_id: u32) -> bool; + fn set_resizable(&self, window_id: u32, resizable: bool); - fn is_always_on_top(&self) -> bool; - fn set_always_on_top(&self, always_on_top: bool); - fn is_visible(&self) -> bool; - fn show(&self); - fn hide(&self); - fn focus(&self); + fn is_always_on_top(&self, window_id: u32) -> bool; + fn set_always_on_top(&self, window_id: u32, always_on_top: bool); + fn is_visible(&self, window_id: u32) -> bool; + fn show(&self, window_id: u32); + fn hide(&self, window_id: u32); + fn focus(&self, window_id: u32); - fn bind(&self, name: &str); - fn unbind(&self, name: &str); + fn bind(&self, window_id: u32, name: &str); + fn unbind(&self, window_id: u32, name: &str); - fn navigate(&self, url: &str); - fn execute_js<'a>(&self, scope: &'a mut v8::PinScope<'a, '_>, script: &str) -> Pin, v8::Local<'a, v8::Value>>> + 'a>>; + fn navigate(&self, window_id: u32, url: &str); fn quit(&self); fn set_application_menu(&self, template_json: &str); } +/// Stores the window ID of the initial window created during runtime init. +/// The first `BrowserWindow` constructor takes this ID to wrap the existing +/// window; subsequent constructors create new windows. +pub struct InitialWindowId(pub std::sync::Mutex>); + struct BrowserWindow { api: Arc, + window_id: u32, } // SAFETY: we're sure this can be GCed @@ -196,23 +243,39 @@ impl BrowserWindow { .try_borrow::>() .expect("desktop mode enabled") .clone(); - if let Some(options) = options { - if let Some(title) = options.title { - api.set_title(&title); + + // Use the initial window if this is the first BrowserWindow, + // otherwise create a new one. + let window_id = state + .try_borrow::() + .and_then(|iw| iw.0.lock().unwrap().take()) + .unwrap_or_else(|| { + let width = options.as_ref().and_then(|o| o.width).unwrap_or(800); + let height = options.as_ref().and_then(|o| o.height).unwrap_or(600); + api.create_window(width, height) + }); + + if let Some(options) = &options { + if let Some(title) = &options.title { + api.set_title(window_id, title); } api.set_window_size( + window_id, options.width.unwrap_or(800), options.height.unwrap_or(600), ); + if let (Some(x), Some(y)) = (options.x, options.y) { + api.set_window_position(window_id, x, y); + } if let Some(resizable) = options.resizable { - api.set_resizeable(resizable); + api.set_resizable(window_id, resizable); } if let Some(always_on_top) = options.always_on_top { - api.set_always_on_top(always_on_top); + api.set_always_on_top(window_id, always_on_top); } } - let window = BrowserWindow { api }; + let window = BrowserWindow { api, window_id }; let window = deno_core::cppgc::make_cppgc_object(scope, window); let event_target_setup = state.borrow::(); let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); @@ -229,55 +292,55 @@ impl BrowserWindow { #[fast] fn bind(&self, #[string] name: &str) { - self.api.bind(name); + self.api.bind(self.window_id, name); } #[fast] fn unbind(&self, #[string] name: &str) { - self.api.unbind(name); + self.api.unbind(self.window_id, name); } #[fast] fn set_title(&self, #[string] title: &str) { - self.api.set_title(title); + self.api.set_title(self.window_id, title); } fn get_size(&self) -> (i32, i32) { - self.api.get_window_size() + self.api.get_window_size(self.window_id) } #[fast] fn set_size(&self, #[smi] width: i32, #[smi] height: i32) { - self.api.set_window_size(width, height); + self.api.set_window_size(self.window_id, width, height); } fn get_position(&self) -> (i32, i32) { - self.api.get_window_position() + self.api.get_window_position(self.window_id) } #[fast] fn set_position(&self, #[smi] x: i32, #[smi] y: i32) { - self.api.set_window_position(x, y); + self.api.set_window_position(self.window_id, x, y); } #[fast] fn is_resizeable(&self) -> bool { - self.api.is_resizeable() + self.api.is_resizable(self.window_id) } #[fast] fn set_resizeable(&self, resizeable: bool) { - self.api.set_resizeable(resizeable); + self.api.set_resizable(self.window_id, resizeable); } #[fast] fn is_always_on_top(&self) -> bool { - self.api.is_always_on_top() + self.api.is_always_on_top(self.window_id) } #[fast] fn set_always_on_top(&self, always_on_top: bool) { - self.api.set_always_on_top(always_on_top); + self.api.set_always_on_top(self.window_id, always_on_top); } #[fast] @@ -287,32 +350,32 @@ impl BrowserWindow { #[fast] fn close(&self) { - self.api.quit(); + self.api.close_window(self.window_id); } #[fast] fn is_visible(&self) -> bool { - self.api.is_visible() + self.api.is_visible(self.window_id) } #[fast] fn show(&self) { - self.api.show(); + self.api.show(self.window_id); } #[fast] fn hide(&self) { - self.api.hide(); + self.api.hide(self.window_id); } #[fast] fn focus(&self) { - self.api.focus(); + self.api.focus(self.window_id); } #[fast] fn navigate(&self, #[string] url: &str) { - self.api.navigate(url); + self.api.navigate(self.window_id, url); } #[fast] @@ -340,8 +403,8 @@ struct BrowserWindowOptions { title: Option, width: Option, height: Option, - x: Option, - y: Option, + x: Option, + y: Option, resizable: Option, always_on_top: Option, } From 988ba1e9db06bf35f5116aa4baff5bcf97fa88d9 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 25 Mar 2026 16:28:33 +0100 Subject: [PATCH 016/137] cleanup subcommand handling --- cli/args/flags.rs | 15 - cli/factory.rs | 2 + cli/rt_desktop/lib.rs | 47 ++- cli/standalone/binary.rs | 7 +- cli/tools/compile.rs | 606 +----------------------------- cli/tools/desktop.rs | 735 +++++++++++++++++++++++++++++++++---- cli/tools/installer/mod.rs | 3 - runtime/ops/desktop.rs | 18 +- 8 files changed, 724 insertions(+), 709 deletions(-) diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 02ca1a46531a70..ab297e4ac72b7b 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -175,9 +175,6 @@ pub struct CompileFlags { pub exclude: Vec, pub eszip: bool, pub self_extracting: bool, - pub desktop: bool, - pub hmr: bool, - pub backend: Option, } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -6290,9 +6287,6 @@ fn compile_parse( let no_terminal = matches.get_flag("no-terminal"); let eszip = matches.get_flag("eszip-internal-do-not-use"); let self_extracting = matches.get_flag("self-extracting"); - let desktop = matches.get_flag("desktop"); - let hmr = matches.get_flag("hmr"); - let backend = matches.remove_one::("backend"); let include = matches .remove_many::("include") .map(|f| f.collect::>()) @@ -6316,9 +6310,6 @@ fn compile_parse( exclude, eszip, self_extracting, - desktop, - hmr, - backend, }); Ok(()) @@ -12708,8 +12699,6 @@ mod tests { exclude: Default::default(), eszip: false, self_extracting: false, - desktop: false, - hmr: false, }), type_check_mode: TypeCheckMode::Local, code_cache_enabled: true, @@ -12737,8 +12726,6 @@ mod tests { exclude: vec!["exclude.txt".to_string()], eszip: false, self_extracting: false, - desktop: false, - hmr: false, }), import_map_path: Some("import_map.json".to_string()), no_remote: true, @@ -15054,8 +15041,6 @@ Usage: deno repl [OPTIONS] [-- [ARGS]...]\n" exclude: Default::default(), eszip: false, self_extracting: false, - desktop: false, - hmr: false, }), type_check_mode: TypeCheckMode::Local, preload: svec!["p1.js", "./p2.js"], diff --git a/cli/factory.rs b/cli/factory.rs index 548ba6d963dce8..a17c21d3ea72d8 100644 --- a/cli/factory.rs +++ b/cli/factory.rs @@ -997,6 +997,7 @@ impl CliFactory { pub async fn create_compile_binary_writer( &self, + is_desktop: bool, ) -> Result, AnyError> { let cli_options = self.cli_options()?; Ok(DenoCompileBinaryWriter::new( @@ -1010,6 +1011,7 @@ impl CliFactory { self.npm_resolver().await?, self.workspace_resolver().await?.as_ref(), cli_options.npm_system_info(), + is_desktop, )) } diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 6368651478735c..447a03ab98beb8 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -36,7 +36,9 @@ const DESKTOP_SERVE_PORT: u16 = 41520; /// WEF-backed implementation of [`denort::desktop::DesktopApi`]. struct WefDesktopApi { - event_tx: tokio::sync::mpsc::UnboundedSender, + event_tx: tokio::sync::mpsc::UnboundedSender< + deno_runtime::ops::desktop::DesktopEvent, + >, pending_responses: deno_runtime::ops::desktop::PendingBindResponses, } @@ -56,8 +58,8 @@ impl WefDesktopApi { window .on_keyboard_event(move |ev| { - let _ = kb_tx.send( - deno_runtime::ops::desktop::DesktopEvent::KeyboardEvent { + let _ = + kb_tx.send(deno_runtime::ops::desktop::DesktopEvent::KeyboardEvent { window_id: ev.window_id, r#type: match ev.state { wef::KeyState::Pressed => "keydown".to_string(), @@ -70,8 +72,7 @@ impl WefDesktopApi { alt: ev.modifiers.alt, meta: ev.modifiers.meta, repeat: ev.repeat, - }, - ); + }); }) .on_mouse_click(move |ev| { let _ = mouse_click_tx.send( @@ -113,8 +114,8 @@ impl WefDesktopApi { ); }) .on_wheel(move |ev| { - let _ = wheel_tx.send( - deno_runtime::ops::desktop::DesktopEvent::Wheel { + let _ = + wheel_tx.send(deno_runtime::ops::desktop::DesktopEvent::Wheel { window_id: ev.window_id, delta_x: ev.delta_x, delta_y: ev.delta_y, @@ -129,8 +130,7 @@ impl WefDesktopApi { control: ev.modifiers.control, alt: ev.modifiers.alt, meta: ev.modifiers.meta, - }, - ); + }); }) .on_cursor_enter_leave(move |ev| { let _ = cursor_tx.send( @@ -164,13 +164,12 @@ impl WefDesktopApi { ); }) .on_move(move |ev| { - let _ = move_tx.send( - deno_runtime::ops::desktop::DesktopEvent::WindowMove { + let _ = + move_tx.send(deno_runtime::ops::desktop::DesktopEvent::WindowMove { window_id: ev.window_id, x: ev.x, y: ev.y, - }, - ); + }); }) .on_close_requested(move |ev| { let _ = close_tx.send( @@ -867,10 +866,11 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { Some(Box::new(move || { let id = initial_window_id_for_hmr.load(Ordering::Acquire); if id != 0 { - wef::Window::from_id(id).execute_js::)>( - "location.reload()", - None, - ); + wef::Window::from_id(id) + .execute_js::)>( + "location.reload()", + None, + ); } })) } else { @@ -928,16 +928,15 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { state.put(event_rx); state.put(event_tx); state.put(pending_responses); - state.put(denort::desktop::InitialWindowId( - std::sync::Mutex::new(Some(window_id)), - )); + state.put(denort::desktop::InitialWindowId(std::sync::Mutex::new( + Some(window_id), + ))); wef::set_menu_click_handler(move |id| { - let _ = menu_tx.send( - deno_runtime::ops::desktop::DesktopEvent::MenuClick { + let _ = + menu_tx.send(deno_runtime::ops::desktop::DesktopEvent::MenuClick { id: id.to_string(), - }, - ); + }); }); })), override_main_module: None, diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index 728011243d3a40..7ddc3ff0587d8d 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -208,6 +208,7 @@ pub struct DenoCompileBinaryWriter<'a> { npm_resolver: &'a CliNpmResolver, workspace_resolver: &'a WorkspaceResolver, npm_system_info: NpmSystemInfo, + is_desktop: bool, } impl<'a> DenoCompileBinaryWriter<'a> { @@ -223,6 +224,7 @@ impl<'a> DenoCompileBinaryWriter<'a> { npm_resolver: &'a CliNpmResolver, workspace_resolver: &'a WorkspaceResolver, npm_system_info: NpmSystemInfo, + is_desktop: bool, ) -> Self { Self { cjs_module_export_analyzer, @@ -235,6 +237,7 @@ impl<'a> DenoCompileBinaryWriter<'a> { npm_resolver, workspace_resolver, npm_system_info, + is_desktop, } } @@ -260,7 +263,7 @@ impl<'a> DenoCompileBinaryWriter<'a> { if options.compile_flags.icon.is_some() { let target = options.compile_flags.resolve_target(); // Desktop builds handle icons during app bundle packaging. - if !target.contains("windows") && !options.compile_flags.desktop { + if !target.contains("windows") && !self.is_desktop { bail!( "The `--icon` flag is only available when targeting Windows (current: {})", target, @@ -274,7 +277,7 @@ impl<'a> DenoCompileBinaryWriter<'a> { &self, compile_flags: &CompileFlags, ) -> Result, AnyError> { - if compile_flags.desktop { + if self.is_desktop { return self.get_desktop_base_binary(compile_flags).await; } diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index c1547335d9f7ee..b6e0448471b16e 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -28,135 +28,15 @@ use crate::args::CliOptions; use crate::args::CompileFlags; use crate::args::ConfigFlag; use crate::args::Flags; -use crate::args::TypeCheckMode; use crate::factory::CliFactory; use crate::standalone::binary::WriteBinOptions; use crate::standalone::binary::is_standalone_binary; use crate::util::temp::create_temp_node_modules_dir; -/// Environment variable for the WEF backend executable path. -const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; - -/// Find the deno repo root from the current exe path. -/// Dev builds live at `/target/{debug,release}/deno`. -/// Resolve WEF backend binary search paths based on the chosen backend. -fn wef_backend_search_paths(backend: &str) -> Vec { - let wef_base = option_env!("CARGO_MANIFEST_DIR") - .map(|d| std::path::Path::new(d).join("../../wef")) - .filter(|p| p.exists()); - - let mut paths = Vec::new(); - if let Some(ref wef) = wef_base { - match backend { - "cef" => { - paths.push(wef.join("result-cef/Applications/wef.app/Contents/MacOS/wef")); - paths.push(wef.join("result/Applications/wef.app/Contents/MacOS/wef")); - paths.push(wef.join("cef/build/Release/wef.app/Contents/MacOS/wef")); - paths.push(wef.join("cef/build/wef.app/Contents/MacOS/wef")); - } - "servo" => { - paths.push(wef.join("target/release/wef_servo")); - paths.push(wef.join("target/debug/wef_servo")); - } - "raw" => { - paths.push(wef.join("target/release/wef_winit")); - paths.push(wef.join("target/debug/wef_winit")); - } - _ => { - paths.push(wef.join("result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview")); - paths.push(wef.join("result/Applications/wef_webview.app/Contents/MacOS/wef_webview")); - paths.push(wef.join("webview/build/wef_webview.app/Contents/MacOS/wef_webview")); - } - } - } - paths -} - pub async fn compile( mut flags: Flags, - mut compile_flags: CompileFlags, + compile_flags: CompileFlags, ) -> Result<(), AnyError> { - // Desktop framework detection: when --desktop is used and the source is - // "." (a directory), detect the framework and generate the entrypoint. - let _desktop_entrypoint_file = if compile_flags.desktop - && compile_flags.source_file == "." - { - let cwd = flags - .initial_cwd - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap()); - if let Some(detection) = super::framework::detect_framework(&cwd)? { - let entrypoint_code = detection.entrypoint_code; - let includes = detection.include_paths; - log::info!("Detected {} framework", detection.name); - // Enable CJS detection for Node-based frameworks. - flags.unstable_config.detect_cjs = true; - if detection.name == "Next.js" - && !matches!(flags.type_check_mode, TypeCheckMode::None) - { - log::info!( - "Disabling Deno type checking for Next.js desktop compile; Next handles app compilation itself" - ); - flags.type_check_mode = TypeCheckMode::None; - } - // Write a temporary entrypoint file. - let entrypoint_path = cwd.join(".deno_desktop_entry.ts"); - std::fs::write(&entrypoint_path, entrypoint_code)?; - let entrypoint_str = entrypoint_path.display().to_string(); - compile_flags.source_file = entrypoint_str.clone(); - // Also update the source in flags.subcommand so resolve_main_module sees it. - if let crate::args::DenoSubcommand::Compile(ref mut cf) = flags.subcommand - { - cf.source_file = entrypoint_str; - cf.self_extracting = true; - // Use the project directory name as the output binary name. - if cf.output.is_none() { - if let Some(dir_name) = cwd.file_name() { - cf.output = Some(dir_name.to_string_lossy().into_owned()); - } - } - } - if compile_flags.output.is_none() { - if let Some(dir_name) = cwd.file_name() { - compile_flags.output = Some(dir_name.to_string_lossy().into_owned()); - } - } - // Auto-enable self-extracting for framework apps. - compile_flags.self_extracting = true; - // Add framework build output to includes. - for inc in includes { - if !compile_flags.include.contains(&inc) { - compile_flags.include.push(inc.clone()); - } - if let crate::args::DenoSubcommand::Compile(ref mut cf) = - flags.subcommand - { - if !cf.include.contains(&inc) { - cf.include.push(inc); - } - } - } - Some(entrypoint_path) - } else { - bail!( - "Could not detect a supported framework in the current directory.\nSupported frameworks: Next.js, Astro\nProvide an explicit entrypoint instead of \".\"." - ); - } - } else { - None - }; - - // Clean up temp entrypoint on exit. - struct CleanupGuard(Option); - impl Drop for CleanupGuard { - fn drop(&mut self) { - if let Some(ref path) = self.0 { - let _ = std::fs::remove_file(path); - } - } - } - let _cleanup = CleanupGuard(_desktop_entrypoint_file); - // use a temporary directory with a node_modules folder when the user // specifies an npm package for better compatibility let _temp_dir = @@ -177,26 +57,30 @@ pub async fn compile( let flags = Arc::new(flags); // boxed_local() is to avoid large futures if compile_flags.eszip { - compile_eszip(flags, compile_flags).boxed_local().await + compile_eszip(flags, compile_flags).boxed_local().await?; } else { - compile_binary(flags, compile_flags).boxed_local().await + compile_binary(flags, compile_flags, false).boxed_local().await?; } + + Ok(()) } -async fn compile_binary( +pub async fn compile_binary( flags: Arc, compile_flags: CompileFlags, -) -> Result<(), AnyError> { + is_desktop: bool, +) -> Result { let factory = CliFactory::from_flags(flags); let cli_options = factory.cli_options()?; let module_graph_creator = factory.module_graph_creator().await?; - let binary_writer = factory.create_compile_binary_writer().await?; + let binary_writer = factory.create_compile_binary_writer(is_desktop).await?; let entrypoint = cli_options.resolve_main_module()?; let bin_name_resolver = factory.bin_name_resolver()?; let output_path = resolve_compile_executable_output_path( &bin_name_resolver, &compile_flags, cli_options.initial_cwd(), + is_desktop, ) .await?; let (module_roots, include_paths) = get_module_roots_and_include_paths( @@ -323,313 +207,11 @@ async fn compile_binary( return Err(err); } - // When --desktop --hmr, compile and immediately run with HMR enabled. - if compile_flags.desktop && compile_flags.hmr { - let cwd = cli_options.initial_cwd(); - let framework = super::framework::detect_framework(cwd)?; - let backend = compile_flags.backend.as_deref().unwrap_or("webview"); - run_desktop_hmr(&output_path, cwd, framework.as_ref(), backend).await?; - } else if compile_flags.desktop { - // Package the dylib into a platform-specific app bundle. - let bundle_path = - package_desktop_app(&output_path, &compile_flags, cli_options)?; - let initial_cwd = - deno_path_util::url_from_directory_path(cli_options.initial_cwd())?; - log::info!( - "{} {}", - colors::green("Bundle"), - if let Ok(bundle_url) = deno_path_util::url_from_file_path(&bundle_path) { - crate::util::path::relative_specifier_path_for_display( - &initial_cwd, - &bundle_url, - ) - } else { - bundle_path.display().to_string() - } - ); - } - - Ok(()) -} - -/// Package a compiled desktop dylib into a platform-specific app bundle. -fn package_desktop_app( - dylib_path: &Path, - compile_flags: &CompileFlags, - cli_options: &CliOptions, -) -> Result { - let is_darwin = match &compile_flags.target { - Some(target) => target.contains("darwin"), - None => cfg!(target_os = "macos"), - }; - - if is_darwin { - package_macos_app_bundle(dylib_path, compile_flags, cli_options) - } else { - // TODO(divy): Windows and Linux packaging - Ok(dylib_path.to_path_buf()) - } -} - -/// Environment variable pointing to a WEF backend .app bundle. -const WEF_BACKEND_APP_ENV: &str = "WEF_BACKEND_APP"; - -/// Resolve WEF backend .app search paths based on the chosen backend. -fn wef_backend_app_search_paths(backend: &str) -> Vec { - // Derive the wef repo path from this crate's location. - // CARGO_MANIFEST_DIR is cli/, the wef repo is at ../../wef relative to that - // (i.e. a sibling of the deno repo in the parent directory). - let wef_base = option_env!("CARGO_MANIFEST_DIR") - .map(|d| std::path::Path::new(d).join("../../wef")) - .filter(|p| p.exists()); - - let mut paths = Vec::new(); - if let Some(ref wef) = wef_base { - match backend { - "cef" => { - paths.push(wef.join("result-cef/Applications/wef.app").to_string_lossy().to_string()); - paths.push(wef.join("result/Applications/wef.app").to_string_lossy().to_string()); - paths.push(wef.join("cef/build/Release/wef.app").to_string_lossy().to_string()); - paths.push(wef.join("cef/build/wef.app").to_string_lossy().to_string()); - } - "raw" | "servo" => {} // Not .app bundles - _ => { - paths.push(wef.join("result-1/Applications/wef_webview.app").to_string_lossy().to_string()); - paths.push(wef.join("result/Applications/wef_webview.app").to_string_lossy().to_string()); - paths.push(wef.join("webview/build/wef_webview.app").to_string_lossy().to_string()); - } - } - } - paths -} - -/// Find the WEF backend .app bundle directory. -fn find_wef_backend_app_bundle(backend: &str) -> Result { - // Check explicit env var first. - if let Ok(path) = std::env::var(WEF_BACKEND_APP_ENV) { - let p = PathBuf::from(&path); - if p.exists() && p.extension().map_or(false, |e| e == "app") { - return Ok(p); - } - bail!( - "WEF backend .app not found at {} (set via {})", - path, - WEF_BACKEND_APP_ENV - ); - } - - // Search well-known paths. - let exe_dir = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())); - for search_path in wef_backend_app_search_paths(backend) { - let p = PathBuf::from(&search_path); - if p.exists() { - return Ok(p); - } - if let Some(exe_dir) = &exe_dir { - let p = exe_dir.join(&search_path); - if p.exists() { - return Ok(p); - } - } - } - - // Derive from the WEF backend binary path (walk up to .app). - if let Ok(backend_binary) = find_wef_backend(backend) { - let mut current = backend_binary.as_path(); - while let Some(parent) = current.parent() { - if parent.extension().map_or(false, |e| e == "app") { - return Ok(parent.to_path_buf()); - } - current = parent; - } - } - - bail!( - "WEF backend '{}' .app bundle not found. Set {} to the path of the WEF .app bundle.", - backend, - WEF_BACKEND_APP_ENV - ) -} - -/// Extract a string value from a plist XML by key. -fn extract_plist_string(plist_xml: &str, key: &str) -> Option { - let key_tag = format!("{}", key); - let pos = plist_xml.find(&key_tag)?; - let after_key = &plist_xml[pos + key_tag.len()..]; - let start = after_key.find("")? + "".len(); - let end = after_key.find("")?; - Some(after_key[start..end].to_string()) -} - -/// Create a macOS .app bundle from the compiled desktop dylib. -/// -/// Bundle structure: -/// ```text -/// AppName.app/ -/// Contents/ -/// Info.plist -/// MacOS/ -/// AppName (launcher script) -/// wef_webview (WEF backend binary) -/// libapp.dylib (compiled Deno runtime + user code) -/// Resources/ -/// AppIcon.icns (optional) -/// ``` -fn package_macos_app_bundle( - dylib_path: &Path, - compile_flags: &CompileFlags, - cli_options: &CliOptions, -) -> Result { - let app_name = dylib_path - .file_stem() - .unwrap() - .to_string_lossy() - .to_string(); - let app_bundle = dylib_path - .parent() - .unwrap() - .join(format!("{}.app", app_name)); - - // Find the WEF backend .app and its main executable. - let backend = compile_flags.backend.as_deref().unwrap_or("cef"); - let wef_app = find_wef_backend_app_bundle(backend)?; - let wef_plist_path = wef_app.join("Contents/Info.plist"); - let wef_executable_name = if wef_plist_path.exists() { - let plist_content = std::fs::read_to_string(&wef_plist_path)?; - extract_plist_string(&plist_content, "CFBundleExecutable") - .unwrap_or_else(|| "wef_webview".to_string()) - } else { - "wef_webview".to_string() - }; - let wef_binary = wef_app.join("Contents/MacOS").join(&wef_executable_name); - if !wef_binary.exists() { - bail!( - "WEF backend executable not found at '{}'", - wef_binary.display() - ); - } - - // Remove existing bundle. - if app_bundle.exists() { - std::fs::remove_dir_all(&app_bundle)?; - } - - // Copy the entire WEF .app as the shell (CEF needs Frameworks/, Resources/, etc.). - copy_dir_all(&wef_app, &app_bundle)?; - - let contents_dir = app_bundle.join("Contents"); - let macos_dir = contents_dir.join("MacOS"); - let resources_dir = contents_dir.join("Resources"); - std::fs::create_dir_all(&resources_dir)?; - - // Strip unnecessary bulk from the CEF framework. - strip_cef_bloat(&contents_dir); - - // Copy the compiled dylib. - let dylib_filename = dylib_path.file_name().unwrap(); - std::fs::copy(dylib_path, macos_dir.join(dylib_filename))?; - - // Create launcher script as the main executable. - let launcher_path = macos_dir.join(&app_name); - std::fs::write( - &launcher_path, - format!( - "#!/bin/bash\n\ - DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ - exec \"$DIR/{wef_binary}\" --runtime \"$DIR/{dylib}\" \"$@\"\n", - wef_binary = wef_executable_name, - dylib = dylib_filename.to_string_lossy(), - ), - )?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions( - &launcher_path, - std::fs::Permissions::from_mode(0o755), - )?; - } - - // Generate Info.plist. - let has_icon = compile_flags.icon.is_some(); - let bundle_id = app_name.to_lowercase().replace(' ', "-"); - let info_plist = format!( - r#" - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - {app_name} - CFBundleIconFile - {icon_file} - CFBundleIdentifier - com.deno.desktop.{bundle_id} - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - {app_name} - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1.0.0 - LSMinimumSystemVersion - 10.15 - NSHighResolutionCapable - - NSSupportsAutomaticGraphicsSwitching - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - - -"#, - app_name = app_name, - bundle_id = bundle_id, - icon_file = if has_icon { "AppIcon" } else { "" }, - ); - std::fs::write(contents_dir.join("Info.plist"), info_plist)?; - - // Handle icon. - if let Some(ref icon) = compile_flags.icon { - let icon_path = cli_options.initial_cwd().join(icon); - if icon_path.exists() { - let dest = resources_dir.join("AppIcon.icns"); - match icon_path.extension().and_then(|e| e.to_str()) { - Some("icns") => { - std::fs::copy(&icon_path, &dest)?; - } - Some("png") => { - convert_png_to_icns(&icon_path, &dest)?; - } - _ => { - log::warn!( - "Icon '{}' is not .icns or .png, skipping", - icon_path.display() - ); - } - } - } else { - log::warn!("Icon '{}' not found, skipping", icon_path.display()); - } - } - - // Remove the standalone dylib (it's now inside the .app). - let _ = std::fs::remove_file(dylib_path); - - Ok(app_bundle) + Ok(output_path) } /// Convert a PNG image to macOS .icns format using `sips` and `iconutil`. -fn convert_png_to_icns( +pub fn convert_png_to_icns( png_path: &Path, icns_path: &Path, ) -> Result<(), AnyError> { @@ -691,46 +273,7 @@ fn convert_png_to_icns( Ok(()) } -/// Recursively copy a directory tree, ensuring writable permissions on the -/// destination. This is needed because source files from the Nix store are -/// read-only. -/// Strip unnecessary files from a CEF-based app bundle to reduce size. -/// -/// Removes: -/// - Non-English locale packs (~47MB) -/// - SwiftShader software Vulkan renderer (~16MB, not needed on macOS with Metal) -/// - OpenGL ES emulation library (~6.5MB, not needed on macOS with Metal) -fn strip_cef_bloat(contents_dir: &Path) { - let frameworks_dir = contents_dir.join("Frameworks"); - let cef_framework = frameworks_dir - .join("Chromium Embedded Framework.framework") - .join("Versions") - .join("A"); - - if !cef_framework.exists() { - return; - } - - let cef_resources = cef_framework.join("Resources"); - let cef_libraries = cef_framework.join("Libraries"); - - // Remove non-English locale packs. - if let Ok(entries) = std::fs::read_dir(&cef_resources) { - for entry in entries.flatten() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.ends_with(".lproj") && name != "en.lproj" { - let _ = std::fs::remove_dir_all(entry.path()); - } - } - } - - // Remove SwiftShader (software Vulkan fallback, not needed on macOS with Metal). - let _ = std::fs::remove_file(cef_libraries.join("libvk_swiftshader.dylib")); - let _ = std::fs::remove_file(cef_libraries.join("vk_swiftshader_icd.json")); -} - -fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), AnyError> { +pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), AnyError> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src) .with_context(|| format!("Reading directory '{}'", src.display()))? @@ -783,6 +326,7 @@ async fn compile_eszip( &bin_name_resolver, &compile_flags, cli_options.initial_cwd(), + false, ) .await?; output_path.set_extension("eszip"); @@ -885,117 +429,6 @@ async fn compile_eszip( Ok(()) } -/// Find the WEF backend executable. -fn find_wef_backend(backend: &str) -> Result { - if let Ok(path) = std::env::var(WEF_BACKEND_ENV) { - let p = PathBuf::from(&path); - if p.exists() { - return Ok(p); - } - bail!( - "WEF backend not found at {} (set via {})", - path, - WEF_BACKEND_ENV - ); - } - - // Search relative to the deno executable for development. - let exe_dir = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())); - for search_path in wef_backend_search_paths(backend) { - let p = PathBuf::from(&search_path); - if p.exists() { - return Ok(p); - } - if let Some(exe_dir) = &exe_dir { - let p = exe_dir.join(&search_path); - if p.exists() { - return Ok(p); - } - } - } - - bail!( - "WEF backend '{}' not found. Set {} to the path of the WEF backend executable.", - backend, - WEF_BACKEND_ENV - ) -} - -/// Launch the desktop app with HMR enabled after compilation. -/// -/// The compiled dylib contains the entrypoint with both production and dev -/// code paths. The `dev_entrypoint_code` is parsed as KEY=VALUE env vars -/// that switch the entrypoint to dev mode (e.g. DENO_DESKTOP_DEV=1). -/// -/// Framework dev servers provide HMR via websocket. Since they run inside -/// the Deno desktop runtime, `Deno.desktop` APIs remain available. -/// `child_process.fork()` works because forked workers use -/// `override_main_module` to run the target script instead of the -/// embedded entrypoint. -async fn run_desktop_hmr( - dylib_path: &Path, - source_dir: &Path, - framework: Option<&super::framework::FrameworkDetection>, - backend: &str, -) -> Result<(), AnyError> { - let wef_backend = find_wef_backend(backend)?; - let dylib_abs = dylib_path - .canonicalize() - .unwrap_or(dylib_path.to_path_buf()); - let source_abs = source_dir - .canonicalize() - .unwrap_or(source_dir.to_path_buf()); - - if let Some(fw) = framework { - if fw.dev_entrypoint_code.is_some() { - log::info!( - "{} {} dev server with HMR in desktop mode", - colors::green("Running"), - fw.name, - ); - } - } - - log::info!( - "{} desktop app with HMR (watching {})", - colors::green("Running"), - source_abs.display(), - ); - - let mut cmd = std::process::Command::new(&wef_backend); - cmd - .arg("--runtime") - .arg(&dylib_abs) - .env("WEF_RUNTIME_PATH", &dylib_abs) - .env("DENO_DESKTOP_HMR", &source_abs) - .current_dir(&source_abs); - - // Set framework dev env vars (e.g. DENO_DESKTOP_DEV=1) so the - // embedded entrypoint branches into dev mode. - if let Some(fw) = framework { - if let Some(ref dev_env) = fw.dev_entrypoint_code { - for line in dev_env.lines() { - if let Some((key, value)) = line.split_once('=') { - cmd.env(key.trim(), value.trim()); - } - } - } - } - - let mut child = cmd.spawn().with_context(|| { - format!("Failed to launch WEF backend: {}", wef_backend.display()) - })?; - - let status = child.wait().context("Failed waiting for WEF backend")?; - - if !status.success() { - bail!("WEF backend exited with status: {}", status); - } - Ok(()) -} - /// This function writes out a final binary to specified path. If output path /// is not already standalone binary it will return error instead. fn validate_output_path(output_path: &Path) -> Result<(), AnyError> { @@ -1162,6 +595,7 @@ async fn resolve_compile_executable_output_path( bin_name_resolver: &BinNameResolver<'_>, compile_flags: &CompileFlags, current_dir: &Path, + is_desktop: bool, ) -> Result { let module_specifier = resolve_url_or_path(&compile_flags.source_file, current_dir)?; @@ -1195,7 +629,7 @@ async fn resolve_compile_executable_output_path( output_path.ok_or_else(|| anyhow!( "An executable name was not provided. One could not be inferred from the URL. Aborting.", )).map(|output_path| { - if compile_flags.desktop { + if is_desktop { get_desktop_specific_filepath(output_path, &compile_flags.target) } else { get_os_specific_filepath(output_path, &compile_flags.target) @@ -1273,11 +707,9 @@ mod test { exclude: Default::default(), eszip: true, self_extracting: false, - desktop: false, - hmr: false, - backend: None, }, &resolve_cwd(None).unwrap(), + false, ) .await .unwrap(); @@ -1308,11 +740,9 @@ mod test { no_terminal: false, eszip: true, self_extracting: false, - desktop: false, - hmr: false, - backend: None, }, &resolve_cwd(None).unwrap(), + false, ) .await .unwrap(); diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 3d885b3d5a2e21..7d17d17be2e3f4 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -1,85 +1,70 @@ // Copyright 2018-2026 the Deno authors. MIT license. +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; +use deno_core::anyhow::Context; +use deno_core::anyhow::bail; use deno_core::error::AnyError; +use deno_terminal::colors; +use crate::args::CliOptions; use crate::args::CompileFlags; use crate::args::DenoSubcommand; use crate::args::DesktopFlags; use crate::args::Flags; +use crate::args::TypeCheckMode; use crate::factory::CliFactory; pub async fn desktop( - mut flags: Flags, - desktop_flags: DesktopFlags, + flags: Flags, + mut desktop_flags: DesktopFlags, ) -> Result<(), AnyError> { let all_targets = desktop_flags.all_targets; - // Read desktop config from deno.json to use as defaults. - // CLI flags take precedence over config file values. - let desktop_config = { - let config_flags = flags.clone(); - let factory = CliFactory::from_flags(Arc::new(config_flags)); - let cli_options = factory.cli_options()?; - cli_options.start_dir.to_desktop_config()?.clone() - }; + let config_flags = flags.clone(); + let factory = CliFactory::from_flags(Arc::new(config_flags)); + let cli_options = factory.cli_options()?; + let desktop_config = cli_options.start_dir.to_desktop_config()?.clone(); - // Resolve icon: CLI --icon > config icons (platform-specific) - let icon = desktop_flags.icon.or_else(|| { - desktop_config.app.as_ref().and_then(|app| { - app.icons.as_ref().and_then(|icons| { - if cfg!(target_os = "macos") { - icons.macos.clone() - } else if cfg!(target_os = "windows") { - icons.windows.clone() - } else { - icons.linux.clone() - } - }) - }) - }); - - // Resolve backend: CLI --backend > config backend - let backend = desktop_flags - .backend - .or_else(|| desktop_config.backend.clone()); - - // Resolve output: CLI --output > config output (platform-specific) - let output = desktop_flags.output.or_else(|| { - desktop_config.output.as_ref().and_then(|out| { - if cfg!(target_os = "macos") { - out.macos.clone() + if let Some(output) = desktop_config.output + && desktop_flags.output.is_none() + { + desktop_flags.output = if cfg!(target_os = "macos") { + output.macos + } else if cfg!(target_os = "windows") { + output.windows + } else { + output.linux + }; + } + + if let Some(app_config) = desktop_config.app { + if let Some(icons) = app_config.icons + && desktop_flags.icon.is_none() + { + desktop_flags.icon = if cfg!(target_os = "macos") { + icons.macos } else if cfg!(target_os = "windows") { - out.windows.clone() + icons.windows } else { - out.linux.clone() - } - }) - }); - - // Use app name from config if no output specified - let output = output - .or_else(|| desktop_config.app.as_ref().and_then(|app| app.name.clone())); + icons.linux + }; + } - let compile_flags = CompileFlags { - source_file: desktop_flags.source_file, - output, - args: desktop_flags.args, - target: desktop_flags.target, - no_terminal: false, - icon, - include: desktop_flags.include, - exclude: desktop_flags.exclude, - eszip: false, - self_extracting: false, - desktop: true, - hmr: desktop_flags.hmr, - backend, - }; + if let Some(name) = app_config.name + && desktop_flags.output.is_none() + { + desktop_flags.output = Some(name); + } + } - // Update the subcommand in flags so compile internals see it correctly. - flags.subcommand = DenoSubcommand::Compile(compile_flags.clone()); + if let Some(backend) = desktop_config.backend + && desktop_flags.backend.is_none() + { + desktop_flags.backend = Some(backend); + } if all_targets { let targets = [ @@ -91,15 +76,629 @@ pub async fn desktop( ]; for target in targets { log::info!("Building for target: {}", target); - let mut target_flags = compile_flags.clone(); - target_flags.target = Some(target.to_string()); - let mut target_outer_flags = flags.clone(); - target_outer_flags.subcommand = - DenoSubcommand::Compile(target_flags.clone()); - super::compile::compile(target_outer_flags, target_flags).await?; + let mut desktop_flags = desktop_flags.clone(); + desktop_flags.target = Some(target.to_string()); + compile_desktop(flags.clone(), desktop_flags, cli_options).await?; } Ok(()) } else { - super::compile::compile(flags, compile_flags).await + compile_desktop(flags, desktop_flags, cli_options).await + } +} + +async fn compile_desktop( + mut flags: Flags, + mut desktop_flags: DesktopFlags, + cli_options: &Arc, +) -> Result<(), AnyError> { + // Desktop framework detection: when --desktop is used and the source is + // "." (a directory), detect the framework and generate the entrypoint. + let _desktop_entrypoint_file = if desktop_flags.source_file == "." { + let cwd = flags + .initial_cwd + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap()); + if let Some(detection) = super::framework::detect_framework(&cwd)? { + let entrypoint_code = detection.entrypoint_code; + let includes = detection.include_paths; + log::info!("Detected {} framework", detection.name); + // Enable CJS detection for Node-based frameworks. + flags.unstable_config.detect_cjs = true; + if detection.name == "Next.js" + && !matches!(flags.type_check_mode, TypeCheckMode::None) + { + log::info!( + "Disabling Deno type checking for Next.js desktop compile; Next handles app compilation itself" + ); + flags.type_check_mode = TypeCheckMode::None; + } + // Write a temporary entrypoint file. + let entrypoint_path = cwd.join(".deno_desktop_entry.ts"); + std::fs::write(&entrypoint_path, entrypoint_code)?; + let entrypoint_str = entrypoint_path.display().to_string(); + desktop_flags.source_file = entrypoint_str.clone(); + if desktop_flags.output.is_none() { + if let Some(dir_name) = cwd.file_name() { + desktop_flags.output = Some(dir_name.to_string_lossy().into_owned()); + } + } + // Add framework build output to includes. + for inc in includes { + if !desktop_flags.include.contains(&inc) { + desktop_flags.include.push(inc.clone()); + } + } + Some(entrypoint_path) + } else { + bail!( + "Could not detect a supported framework in the current directory.\nSupported frameworks: Next.js, Astro\nProvide an explicit entrypoint instead of \".\"." + ); + } + } else { + None + }; + + let self_extracting = _desktop_entrypoint_file.is_some(); + + // Clean up temp entrypoint on exit. + struct CleanupGuard(Option); + impl Drop for CleanupGuard { + fn drop(&mut self) { + if let Some(ref path) = self.0 { + let _ = std::fs::remove_file(path); + } + } } + let _cleanup = CleanupGuard(_desktop_entrypoint_file); + + let compile_flags = CompileFlags { + source_file: desktop_flags.source_file.clone(), + output: desktop_flags.output.clone(), + args: desktop_flags.args.clone(), + target: desktop_flags.target.clone(), + no_terminal: false, + icon: desktop_flags.icon.clone(), + include: desktop_flags.include.clone(), + exclude: desktop_flags.exclude.clone(), + eszip: false, + self_extracting, + }; + + let mut temp_flags = flags.clone(); + temp_flags.subcommand = DenoSubcommand::Compile(compile_flags.clone()); + + let output_path = super::compile::compile_binary( + Arc::new(temp_flags), + compile_flags, + true, + ) + .await?; + + if desktop_flags.hmr { + let cwd = cli_options.initial_cwd(); + let framework = super::framework::detect_framework(cwd)?; + let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); + run_desktop_hmr(&output_path, cwd, framework.as_ref(), backend).await?; + } else { + // Package the dylib into a platform-specific app bundle. + let bundle_path = + package_desktop_app(&output_path, &desktop_flags, cli_options)?; + let initial_cwd = + deno_path_util::url_from_directory_path(cli_options.initial_cwd())?; + log::info!( + "{} {}", + colors::green("Bundle"), + if let Ok(bundle_url) = deno_path_util::url_from_file_path(&bundle_path) { + crate::util::path::relative_specifier_path_for_display( + &initial_cwd, + &bundle_url, + ) + } else { + bundle_path.display().to_string() + } + ); + } + + Ok(()) +} + +/// Launch the desktop app with HMR enabled after compilation. +/// +/// The compiled dylib contains the entrypoint with both production and dev +/// code paths. The `dev_entrypoint_code` is parsed as KEY=VALUE env vars +/// that switch the entrypoint to dev mode (e.g. DENO_DESKTOP_DEV=1). +/// +/// Framework dev servers provide HMR via websocket. Since they run inside +/// the Deno desktop runtime, `Deno.desktop` APIs remain available. +/// `child_process.fork()` works because forked workers use +/// `override_main_module` to run the target script instead of the +/// embedded entrypoint. +async fn run_desktop_hmr( + dylib_path: &Path, + source_dir: &Path, + framework: Option<&super::framework::FrameworkDetection>, + backend: &str, +) -> Result<(), AnyError> { + let wef_backend = find_wef_backend(backend)?; + let dylib_abs = dylib_path + .canonicalize() + .unwrap_or(dylib_path.to_path_buf()); + let source_abs = source_dir + .canonicalize() + .unwrap_or(source_dir.to_path_buf()); + + if let Some(fw) = framework { + if fw.dev_entrypoint_code.is_some() { + log::info!( + "{} {} dev server with HMR in desktop mode", + colors::green("Running"), + fw.name, + ); + } + } + + log::info!( + "{} desktop app with HMR (watching {})", + colors::green("Running"), + source_abs.display(), + ); + + let mut cmd = std::process::Command::new(&wef_backend); + cmd + .arg("--runtime") + .arg(&dylib_abs) + .env("WEF_RUNTIME_PATH", &dylib_abs) + .env("DENO_DESKTOP_HMR", &source_abs) + .current_dir(&source_abs); + + // Set framework dev env vars (e.g. DENO_DESKTOP_DEV=1) so the + // embedded entrypoint branches into dev mode. + if let Some(fw) = framework { + if let Some(ref dev_env) = fw.dev_entrypoint_code { + for line in dev_env.lines() { + if let Some((key, value)) = line.split_once('=') { + cmd.env(key.trim(), value.trim()); + } + } + } + } + + let mut child = cmd.spawn().with_context(|| { + format!("Failed to launch WEF backend: {}", wef_backend.display()) + })?; + + let status = child.wait().context("Failed waiting for WEF backend")?; + + if !status.success() { + bail!("WEF backend exited with status: {}", status); + } + Ok(()) +} + +/// Package a compiled desktop dylib into a platform-specific app bundle. +fn package_desktop_app( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, +) -> Result { + let is_darwin = match &desktop_flags.target { + Some(target) => target.contains("darwin"), + None => cfg!(target_os = "macos"), + }; + + if is_darwin { + package_macos_app_bundle(dylib_path, desktop_flags, cli_options) + } else { + // TODO(divy): Windows and Linux packaging + Ok(dylib_path.to_path_buf()) + } +} + +/// Environment variable for the WEF backend executable path. +const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; + +/// Find the deno repo root from the current exe path. +/// Dev builds live at `/target/{debug,release}/deno`. +/// Resolve WEF backend binary search paths based on the chosen backend. +fn wef_backend_search_paths(backend: &str) -> Vec { + let wef_base = option_env!("CARGO_MANIFEST_DIR") + .map(|d| std::path::Path::new(d).join("../../wef")) + .filter(|p| p.exists()); + + let mut paths = Vec::new(); + if let Some(ref wef) = wef_base { + match backend { + "cef" => { + paths + .push(wef.join("result-cef/Applications/wef.app/Contents/MacOS/wef")); + paths.push(wef.join("result/Applications/wef.app/Contents/MacOS/wef")); + paths.push(wef.join("cef/build/Release/wef.app/Contents/MacOS/wef")); + paths.push(wef.join("cef/build/wef.app/Contents/MacOS/wef")); + } + "servo" => { + paths.push(wef.join("target/release/wef_servo")); + paths.push(wef.join("target/debug/wef_servo")); + } + "raw" => { + paths.push(wef.join("target/release/wef_winit")); + paths.push(wef.join("target/debug/wef_winit")); + } + _ => { + paths.push(wef.join( + "result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview", + )); + paths.push(wef.join( + "result/Applications/wef_webview.app/Contents/MacOS/wef_webview", + )); + paths.push( + wef.join("webview/build/wef_webview.app/Contents/MacOS/wef_webview"), + ); + } + } + } + paths +} + +/// Environment variable pointing to a WEF backend .app bundle. +const WEF_BACKEND_APP_ENV: &str = "WEF_BACKEND_APP"; + +/// Resolve WEF backend .app search paths based on the chosen backend. +fn wef_backend_app_search_paths(backend: &str) -> Vec { + // Derive the wef repo path from this crate's location. + // CARGO_MANIFEST_DIR is cli/, the wef repo is at ../../wef relative to that + // (i.e. a sibling of the deno repo in the parent directory). + let wef_base = option_env!("CARGO_MANIFEST_DIR") + .map(|d| std::path::Path::new(d).join("../../wef")) + .filter(|p| p.exists()); + + let mut paths = Vec::new(); + if let Some(ref wef) = wef_base { + match backend { + "cef" => { + paths.push( + wef + .join("result-cef/Applications/wef.app") + .to_string_lossy() + .to_string(), + ); + paths.push( + wef + .join("result/Applications/wef.app") + .to_string_lossy() + .to_string(), + ); + paths.push( + wef + .join("cef/build/Release/wef.app") + .to_string_lossy() + .to_string(), + ); + paths.push(wef.join("cef/build/wef.app").to_string_lossy().to_string()); + } + "raw" | "servo" => {} // Not .app bundles + _ => { + paths.push( + wef + .join("result-1/Applications/wef_webview.app") + .to_string_lossy() + .to_string(), + ); + paths.push( + wef + .join("result/Applications/wef_webview.app") + .to_string_lossy() + .to_string(), + ); + paths.push( + wef + .join("webview/build/wef_webview.app") + .to_string_lossy() + .to_string(), + ); + } + } + } + paths +} + +/// Find the WEF backend .app bundle directory. +fn find_wef_backend_app_bundle(backend: &str) -> Result { + // Check explicit env var first. + if let Ok(path) = std::env::var(WEF_BACKEND_APP_ENV) { + let p = PathBuf::from(&path); + if p.exists() && p.extension().map_or(false, |e| e == "app") { + return Ok(p); + } + bail!( + "WEF backend .app not found at {} (set via {})", + path, + WEF_BACKEND_APP_ENV + ); + } + + // Search well-known paths. + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + for search_path in wef_backend_app_search_paths(backend) { + let p = PathBuf::from(&search_path); + if p.exists() { + return Ok(p); + } + if let Some(exe_dir) = &exe_dir { + let p = exe_dir.join(&search_path); + if p.exists() { + return Ok(p); + } + } + } + + // Derive from the WEF backend binary path (walk up to .app). + if let Ok(backend_binary) = find_wef_backend(backend) { + let mut current = backend_binary.as_path(); + while let Some(parent) = current.parent() { + if parent.extension().map_or(false, |e| e == "app") { + return Ok(parent.to_path_buf()); + } + current = parent; + } + } + + bail!( + "WEF backend '{}' .app bundle not found. Set {} to the path of the WEF .app bundle.", + backend, + WEF_BACKEND_APP_ENV + ) +} + +/// Extract a string value from a plist XML by key. +fn extract_plist_string(plist_xml: &str, key: &str) -> Option { + let key_tag = format!("{}", key); + let pos = plist_xml.find(&key_tag)?; + let after_key = &plist_xml[pos + key_tag.len()..]; + let start = after_key.find("")? + "".len(); + let end = after_key.find("")?; + Some(after_key[start..end].to_string()) +} + +/// Create a macOS .app bundle from the compiled desktop dylib. +/// +/// Bundle structure: +/// ```text +/// AppName.app/ +/// Contents/ +/// Info.plist +/// MacOS/ +/// AppName (launcher script) +/// wef_webview (WEF backend binary) +/// libapp.dylib (compiled Deno runtime + user code) +/// Resources/ +/// AppIcon.icns (optional) +/// ``` +fn package_macos_app_bundle( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, +) -> Result { + let app_name = dylib_path + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + let app_bundle = dylib_path + .parent() + .unwrap() + .join(format!("{}.app", app_name)); + + // Find the WEF backend .app and its main executable. + let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); + let wef_app = find_wef_backend_app_bundle(backend)?; + let wef_plist_path = wef_app.join("Contents/Info.plist"); + let wef_executable_name = if wef_plist_path.exists() { + let plist_content = std::fs::read_to_string(&wef_plist_path)?; + extract_plist_string(&plist_content, "CFBundleExecutable") + .unwrap_or_else(|| "wef_webview".to_string()) + } else { + "wef_webview".to_string() + }; + let wef_binary = wef_app.join("Contents/MacOS").join(&wef_executable_name); + if !wef_binary.exists() { + bail!( + "WEF backend executable not found at '{}'", + wef_binary.display() + ); + } + + // Remove existing bundle. + if app_bundle.exists() { + std::fs::remove_dir_all(&app_bundle)?; + } + + // Copy the entire WEF .app as the shell (CEF needs Frameworks/, Resources/, etc.). + crate::tools::compile::copy_dir_all(&wef_app, &app_bundle)?; + + let contents_dir = app_bundle.join("Contents"); + let macos_dir = contents_dir.join("MacOS"); + let resources_dir = contents_dir.join("Resources"); + std::fs::create_dir_all(&resources_dir)?; + + // Strip unnecessary bulk from the CEF framework. + strip_cef_bloat(&contents_dir); + + // Copy the compiled dylib. + let dylib_filename = dylib_path.file_name().unwrap(); + std::fs::copy(dylib_path, macos_dir.join(dylib_filename))?; + + // Create launcher script as the main executable. + let launcher_path = macos_dir.join(&app_name); + std::fs::write( + &launcher_path, + format!( + "#!/bin/bash\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + exec \"$DIR/{wef_binary}\" --runtime \"$DIR/{dylib}\" \"$@\"\n", + wef_binary = wef_executable_name, + dylib = dylib_filename.to_string_lossy(), + ), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &launcher_path, + std::fs::Permissions::from_mode(0o755), + )?; + } + + // Generate Info.plist. + let has_icon = desktop_flags.icon.is_some(); + let bundle_id = app_name.to_lowercase().replace(' ', "-"); + let info_plist = format!( + r#" + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + {app_name} + CFBundleIconFile + {icon_file} + CFBundleIdentifier + com.deno.desktop.{bundle_id} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {app_name} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 10.15 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + +"#, + app_name = app_name, + bundle_id = bundle_id, + icon_file = if has_icon { "AppIcon" } else { "" }, + ); + std::fs::write(contents_dir.join("Info.plist"), info_plist)?; + + // Handle icon. + if let Some(ref icon) = desktop_flags.icon { + let icon_path = cli_options.initial_cwd().join(icon); + if icon_path.exists() { + let dest = resources_dir.join("AppIcon.icns"); + match icon_path.extension().and_then(|e| e.to_str()) { + Some("icns") => { + std::fs::copy(&icon_path, &dest)?; + } + Some("png") => { + crate::tools::compile::convert_png_to_icns(&icon_path, &dest)?; + } + _ => { + log::warn!( + "Icon '{}' is not .icns or .png, skipping", + icon_path.display() + ); + } + } + } else { + log::warn!("Icon '{}' not found, skipping", icon_path.display()); + } + } + + // Remove the standalone dylib (it's now inside the .app). + let _ = std::fs::remove_file(dylib_path); + + Ok(app_bundle) +} + +/// Recursively copy a directory tree, ensuring writable permissions on the +/// destination. This is needed because source files from the Nix store are +/// read-only. +/// Strip unnecessary files from a CEF-based app bundle to reduce size. +/// +/// Removes: +/// - Non-English locale packs (~47MB) +/// - SwiftShader software Vulkan renderer (~16MB, not needed on macOS with Metal) +/// - OpenGL ES emulation library (~6.5MB, not needed on macOS with Metal) +fn strip_cef_bloat(contents_dir: &Path) { + let frameworks_dir = contents_dir.join("Frameworks"); + let cef_framework = frameworks_dir + .join("Chromium Embedded Framework.framework") + .join("Versions") + .join("A"); + + if !cef_framework.exists() { + return; + } + + let cef_resources = cef_framework.join("Resources"); + let cef_libraries = cef_framework.join("Libraries"); + + // Remove non-English locale packs. + if let Ok(entries) = std::fs::read_dir(&cef_resources) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(".lproj") && name != "en.lproj" { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } + + // Remove SwiftShader (software Vulkan fallback, not needed on macOS with Metal). + let _ = std::fs::remove_file(cef_libraries.join("libvk_swiftshader.dylib")); + let _ = std::fs::remove_file(cef_libraries.join("vk_swiftshader_icd.json")); +} + +/// Find the WEF backend executable. +fn find_wef_backend(backend: &str) -> Result { + if let Ok(path) = std::env::var(WEF_BACKEND_ENV) { + let p = PathBuf::from(&path); + if p.exists() { + return Ok(p); + } + bail!( + "WEF backend not found at {} (set via {})", + path, + WEF_BACKEND_ENV + ); + } + + // Search relative to the deno executable for development. + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + for search_path in wef_backend_search_paths(backend) { + let p = PathBuf::from(&search_path); + if p.exists() { + return Ok(p); + } + if let Some(exe_dir) = &exe_dir { + let p = exe_dir.join(&search_path); + if p.exists() { + return Ok(p); + } + } + } + + bail!( + "WEF backend '{}' not found. Set {} to the path of the WEF backend executable.", + backend, + WEF_BACKEND_ENV + ) } diff --git a/cli/tools/installer/mod.rs b/cli/tools/installer/mod.rs index b81adc4d3d62ae..ce5352930ced5f 100644 --- a/cli/tools/installer/mod.rs +++ b/cli/tools/installer/mod.rs @@ -1112,9 +1112,6 @@ async fn install_global_compiled( exclude: vec![], eszip: false, self_extracting: false, - desktop: false, - hmr: false, - backend: None, }; let mut new_flags = flags.as_ref().clone(); diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 57a1d3b98f201d..d71c8fcef1df78 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -121,7 +121,8 @@ pub struct DesktopEventSender( pub tokio::sync::mpsc::UnboundedSender, ); -pub fn create_desktop_event_channel() -> (DesktopEventSender, DesktopEventReceiver) { +pub fn create_desktop_event_channel() +-> (DesktopEventSender, DesktopEventReceiver) { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); (DesktopEventSender(tx), DesktopEventReceiver(rx)) } @@ -130,13 +131,12 @@ pub fn create_desktop_event_channel() -> (DesktopEventSender, DesktopEventReceiv pub struct PendingBindCall { pub name: String, pub args: serde_json::Value, - pub response: - tokio::sync::oneshot::Sender>, + pub response: tokio::sync::oneshot::Sender>, } #[derive(Clone)] pub struct PendingBindResponses( - pub Arc< + pub Arc< std::sync::Mutex< HashMap< u32, @@ -385,10 +385,12 @@ impl BrowserWindow { #[fast] fn execute_js(&self, #[string] _script: &str) { - todo!("implement execute_js — async + v8 scope borrowing needs a different approach") + todo!( + "implement execute_js — async + v8 scope borrowing needs a different approach" + ) /* - self.api.execute_js(scope, script).await - */ + self.api.execute_js(scope, script).await + */ } #[fast] @@ -500,7 +502,6 @@ pub fn op_desktop_confirm_update(state: &mut OpState) { } } - #[op2] fn op_desktop_resolve_bind_call( state: &mut OpState, @@ -540,7 +541,6 @@ pub fn op_desktop_init( }); } - deno_core::extension!( deno_desktop, ops = [ From f3bb534c8a5efd27cdbe9d395715f3af76fbebec Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 25 Mar 2026 17:11:10 +0100 Subject: [PATCH 017/137] cleanup subcommand handling --- cli/rt/desktop.rs | 2 +- runtime/ops/desktop.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 78edeaaf581526..d17d0b01009497 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -150,7 +150,7 @@ pub const DESKTOP_JS: &str = r#" const nativeConstructor = BrowserWindow; const OrigBW = function(...args) { const instance = new nativeConstructor(...args); - const windowId = instance.getWindowId(); + const windowId = instance.windowId; windows.set(windowId, instance); return instance; }; diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index d71c8fcef1df78..cae5aeec136843 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -290,6 +290,11 @@ impl BrowserWindow { v8::Global::new(scope, window) } + #[getter] + fn window_id(&self) -> u32 { + self.window_id + } + #[fast] fn bind(&self, #[string] name: &str) { self.api.bind(self.window_id, name); From df091042a055dea84d6a99321ab38d5ae0c12e73 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 26 Mar 2026 02:57:19 +0100 Subject: [PATCH 018/137] fix typechecking --- cli/args/flags.rs | 2 ++ cli/args/mod.rs | 6 +++++- cli/build.rs | 1 + cli/rt_desktop/lib.rs | 6 ++---- cli/tools/compile.rs | 14 ++++++++++++++ cli/tools/desktop.rs | 1 + cli/tsc/dts/lib.deno.desktop.d.ts | 5 +++-- cli/tsc/mod.rs | 1 + libs/config/workspace/mod.rs | 1 + libs/resolver/deno_json.rs | 7 +++++++ 10 files changed, 37 insertions(+), 7 deletions(-) diff --git a/cli/args/flags.rs b/cli/args/flags.rs index ab297e4ac72b7b..72b26c49368e7e 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -874,6 +874,8 @@ pub struct InternalFlags { pub root_node_modules_dir_override: Option, /// Only reads to the lockfile instead of writing to it. pub lockfile_skip_write: bool, + /// Set when running the desktop subcommand to use desktop type libs. + pub is_desktop: bool, } #[derive(Clone, Debug, Eq, PartialEq, Default)] diff --git a/cli/args/mod.rs b/cli/args/mod.rs index 60d99c95a63869..c4764472dd1be4 100644 --- a/cli/args/mod.rs +++ b/cli/args/mod.rs @@ -543,7 +543,11 @@ impl CliOptions { } pub fn ts_type_lib_window(&self) -> TsTypeLib { - TsTypeLib::DenoWindow + if self.flags.internal.is_desktop { + TsTypeLib::DenoDesktop + } else { + TsTypeLib::DenoWindow + } } pub fn ts_type_lib_worker(&self) -> TsTypeLib { diff --git a/cli/build.rs b/cli/build.rs index c32e8017723daa..d540f9d31495b1 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -14,6 +14,7 @@ fn compress_decls(out_dir: &Path) { "lib.deno.window.d.ts", "lib.deno.worker.d.ts", "lib.deno.shared_globals.d.ts", + "lib.deno.desktop.d.ts", "lib.deno.unstable.d.ts", "lib.deno_console.d.ts", "lib.deno_url.d.ts", diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 447a03ab98beb8..6d416616da1da2 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -996,10 +996,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { }; tokio::select! { - result = async { - // Drive the navigate future alongside the runtime. - tokio::join!(navigate_fut, run_fut).1 - } => { + result = run_fut => { match result { Ok(exit_code) => { eprintln!("[desktop] Deno runtime exited with code {}", exit_code); @@ -1010,6 +1007,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { } } } + _ = navigate_fut => {} _ = wef_fut => { eprintln!("[desktop] WEF event loop ended (window closed)"); } diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index b6e0448471b16e..eb668cde376e0e 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -134,6 +134,20 @@ pub async fn compile_binary( ); validate_output_path(&output_path)?; + // Clean up stale temp files from previous interrupted compilations. + if let Some(parent) = output_path.parent() { + if let Some(stem) = output_path.file_name() { + let prefix = format!("{}.tmp-", stem.to_string_lossy()); + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().starts_with(&prefix) { + let _ = std::fs::remove_file(entry.path()); + } + } + } + } + } + let mut temp_filename = output_path.file_name().unwrap().to_owned(); temp_filename.push(format!( ".tmp-{}", diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 7d17d17be2e3f4..3d5fb082d07fc6 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -166,6 +166,7 @@ async fn compile_desktop( let mut temp_flags = flags.clone(); temp_flags.subcommand = DenoSubcommand::Compile(compile_flags.clone()); + temp_flags.internal.is_desktop = true; let output_path = super::compile::compile_binary( Arc::new(temp_flags), diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 794d4105db7444..db9490f42549af 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -2,11 +2,12 @@ /// /// +/// +/// /// +/// /// -// TODO: hook this file up - declare namespace Deno { export {}; // stop default export type behavior diff --git a/cli/tsc/mod.rs b/cli/tsc/mod.rs index 772e8581ed35cb..0b685e25a8f709 100644 --- a/cli/tsc/mod.rs +++ b/cli/tsc/mod.rs @@ -208,6 +208,7 @@ pub static LAZILY_LOADED_STATIC_ASSETS: Lazy< maybe_compressed_lib!("lib.deno.webgpu.d.ts", "lib.deno_webgpu.d.ts"), maybe_compressed_lib!("lib.deno.window.d.ts"), maybe_compressed_lib!("lib.deno.worker.d.ts"), + maybe_compressed_lib!("lib.deno.desktop.d.ts"), maybe_compressed_lib!("lib.deno.shared_globals.d.ts"), maybe_compressed_lib!("lib.deno.ns.d.ts"), maybe_compressed_lib!("lib.deno.unstable.d.ts"), diff --git a/libs/config/workspace/mod.rs b/libs/config/workspace/mod.rs index 7077414271565f..502bac032193ae 100644 --- a/libs/config/workspace/mod.rs +++ b/libs/config/workspace/mod.rs @@ -1590,6 +1590,7 @@ pub enum TsTypeLib { #[default] DenoWindow, DenoWorker, + DenoDesktop, } #[derive(Debug, Clone)] diff --git a/libs/resolver/deno_json.rs b/libs/resolver/deno_json.rs index 113531c8cfbb42..8a88d1ac2fc672 100644 --- a/libs/resolver/deno_json.rs +++ b/libs/resolver/deno_json.rs @@ -326,6 +326,8 @@ pub fn get_base_compiler_options_for_emit( (TsTypeLib::DenoWindow, CompilerOptionsSourceKind::TsConfig) => vec!["deno.window", "deno.unstable", "dom", "node"], (TsTypeLib::DenoWorker, CompilerOptionsSourceKind::DenoJson) => vec!["deno.worker", "deno.unstable", "node"], (TsTypeLib::DenoWorker, CompilerOptionsSourceKind::TsConfig) => vec!["deno.worker", "deno.unstable", "dom", "node"], + (TsTypeLib::DenoDesktop, CompilerOptionsSourceKind::DenoJson) => vec!["deno.desktop", "deno.unstable", "node"], + (TsTypeLib::DenoDesktop, CompilerOptionsSourceKind::TsConfig) => vec!["deno.desktop", "deno.unstable", "dom", "node"], }, "module": "NodeNext", "moduleDetection": "force", @@ -487,6 +489,8 @@ struct MemoizedValues { OnceCell>, deno_worker_check_compiler_options: OnceCell>, + deno_desktop_check_compiler_options: + OnceCell>, emit_compiler_options: OnceCell>, #[cfg(feature = "deno_ast")] @@ -573,6 +577,9 @@ impl CompilerOptionsData { CompilerOptionsType::Check { lib: TsTypeLib::DenoWorker, } => &self.memoized.deno_worker_check_compiler_options, + CompilerOptionsType::Check { + lib: TsTypeLib::DenoDesktop, + } => &self.memoized.deno_desktop_check_compiler_options, CompilerOptionsType::Emit => &self.memoized.emit_compiler_options, }; let result = cell.get_or_init(|| { From 081c3fbccdebf67919ad95f05d8c679bca656eb0 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 26 Mar 2026 03:12:38 +0100 Subject: [PATCH 019/137] fix events --- cli/rt/desktop.rs | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index d17d0b01009497..7f0c58ada61c34 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -27,7 +27,32 @@ pub const DESKTOP_JS: &str = r#" const BrowserWindowPrototype = BrowserWindow.prototype; Object.setPrototypeOf(BrowserWindowPrototype, EventTarget.prototype); - class KeyboardEvent extends Event { + class UIEvent extends Event { + #detail = 0; + #view = null; + + get detail() { return this.#detail; } + get view() { return this.#view; } + + constructor(type, init = {}) { + super(type, init); + this.#detail = init.detail ?? 0; + this.#view = init.view ?? null; + } + } + + class FocusEvent extends UIEvent { + #relatedTarget = null; + + get relatedTarget() { return this.#relatedTarget; } + + constructor(type, init = {}) { + super(type, init); + this.#relatedTarget = init.relatedTarget ?? null; + } + } + + class KeyboardEvent extends UIEvent { #key = ""; #code = ""; #location = 0; @@ -72,7 +97,7 @@ pub const DESKTOP_JS: &str = r#" } } - class MouseEvent extends Event { + class MouseEvent extends UIEvent { #button = 0; #clientX = 0; #clientY = 0; @@ -80,7 +105,6 @@ pub const DESKTOP_JS: &str = r#" #shiftKey = false; #altKey = false; #metaKey = false; - #detail = 0; get button() { return this.#button; } get clientX() { return this.#clientX; } @@ -91,7 +115,6 @@ pub const DESKTOP_JS: &str = r#" get shiftKey() { return this.#shiftKey; } get altKey() { return this.#altKey; } get metaKey() { return this.#metaKey; } - get detail() { return this.#detail; } constructor(type, init = {}) { super(type, init); @@ -102,7 +125,6 @@ pub const DESKTOP_JS: &str = r#" this.#shiftKey = init.shiftKey ?? false; this.#altKey = init.altKey ?? false; this.#metaKey = init.metaKey ?? false; - this.#detail = init.detail ?? 0; } getModifierState(key) { @@ -136,6 +158,8 @@ pub const DESKTOP_JS: &str = r#" } } + globalThis.UIEvent = internals.core.propNonEnumerable(UIEvent); + globalThis.FocusEvent = internals.core.propNonEnumerable(FocusEvent); globalThis.KeyboardEvent = internals.core.propNonEnumerable(KeyboardEvent); globalThis.MouseEvent = internals.core.propNonEnumerable(MouseEvent); globalThis.WheelEvent = internals.core.propNonEnumerable(WheelEvent); From 7246832c91ca4c38c4a62375b1377d557442d114 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 26 Mar 2026 10:53:17 +0100 Subject: [PATCH 020/137] add alert, confirm & prompt --- cli/rt/desktop.rs | 33 ++++++++++++++++++----- cli/rt_desktop/lib.rs | 23 ++++++++++++++++ runtime/js/99_main.js | 3 +++ runtime/ops/desktop.rs | 61 ++++++++++++++++++++++++++++++++++++++++++ runtime/tokio_util.rs | 8 ++++++ 5 files changed, 122 insertions(+), 6 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 7f0c58ada61c34..f27d8691952493 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -23,6 +23,9 @@ pub const DESKTOP_JS: &str = r#" op_desktop_recv_event, op_desktop_resolve_bind_call, op_desktop_reject_bind_call, + op_desktop_alert, + op_desktop_confirm, + op_desktop_prompt, } = internals.core.ops; const BrowserWindowPrototype = BrowserWindow.prototype; Object.setPrototypeOf(BrowserWindowPrototype, EventTarget.prototype); @@ -158,12 +161,6 @@ pub const DESKTOP_JS: &str = r#" } } - globalThis.UIEvent = internals.core.propNonEnumerable(UIEvent); - globalThis.FocusEvent = internals.core.propNonEnumerable(FocusEvent); - globalThis.KeyboardEvent = internals.core.propNonEnumerable(KeyboardEvent); - globalThis.MouseEvent = internals.core.propNonEnumerable(MouseEvent); - globalThis.WheelEvent = internals.core.propNonEnumerable(WheelEvent); - op_desktop_init( internals.webidlBrand, internals.setEventTargetData, @@ -219,6 +216,30 @@ pub const DESKTOP_JS: &str = r#" nativeUnbind.call(this, name); }; + function alert(message = "Alert") { + op_desktop_alert(String(message)); + } + + function confirm(message = "Confirm") { + return op_desktop_confirm(String(message)); + } + + function prompt(message = "Prompt", defaultValue) { + return op_desktop_prompt(String(message), defaultValue != null ? String(defaultValue) : null); + } + + + Object.defineProperties(globalThis, { + alert: internals.core.propWritable(alert), + confirm: internals.core.propWritable(confirm), + prompt: internals.core.propWritable(prompt), + UIEvent: internals.core.propNonEnumerable(UIEvent), + FocusEvent: internals.core.propNonEnumerable(FocusEvent), + KeyboardEvent: internals.core.propNonEnumerable(KeyboardEvent), + MouseEvent: internals.core.propNonEnumerable(MouseEvent), + WheelEvent: internals.core.propNonEnumerable(WheelEvent), + }); + // Start polling loops immediately. Use core.unrefOpPromise so these // pending ops don't block event loop completion (e.g. the pre-module // tick used by HMR, or module evaluation with top-level await). diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 6d416616da1da2..4ae1066a712325 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -309,6 +309,29 @@ impl denort::desktop::DesktopApi for WefDesktopApi { let wef_value = json_to_wef_value(&json); wef::set_application_menu_raw(wef_value); } + + fn alert(&self, title: &str, message: &str) { + wef::alert(title, message); + } + + fn confirm( + &self, + title: &str, + message: &str, + callback: Box, + ) { + wef::confirm(title, message, callback); + } + + fn prompt( + &self, + title: &str, + message: &str, + default_value: &str, + callback: Box) + Send + 'static>, + ) { + wef::prompt(title, message, default_value, callback); + } } #[allow(dead_code)] diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 0ba75403a43112..6cb373c9518978 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -547,6 +547,9 @@ const NOT_IMPORTED_OPS = [ "op_desktop_recv_event", "op_desktop_resolve_bind_call", "op_desktop_reject_bind_call", + "op_desktop_alert", + "op_desktop_confirm", + "op_desktop_prompt", // deno deploy subcommand "op_deploy_token_get", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index cae5aeec136843..633b5ad0b92035 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -199,6 +199,21 @@ pub trait DesktopApi: Send + Sync + 'static { fn navigate(&self, window_id: u32, url: &str); fn quit(&self); fn set_application_menu(&self, template_json: &str); + + fn alert(&self, title: &str, message: &str); + fn confirm( + &self, + title: &str, + message: &str, + callback: Box, + ); + fn prompt( + &self, + title: &str, + message: &str, + default_value: &str, + callback: Box) + Send + 'static>, + ); } /// Stores the window ID of the initial window created during runtime init. @@ -546,6 +561,49 @@ pub fn op_desktop_init( }); } +#[op2(fast)] +fn op_desktop_alert(state: &mut OpState, #[string] message: &str) { + if let Some(api) = state.try_borrow::>() { + api.alert("", message); + } +} + +#[op2(fast)] +fn op_desktop_confirm(state: &mut OpState, #[string] message: &str) -> bool { + if let Some(api) = state.try_borrow::>() { + let (tx, rx) = std::sync::mpsc::channel(); + api.confirm("", message, Box::new(move |result| { + let _ = tx.send(result); + })); + rx.recv().unwrap_or(false) + } else { + false + } +} + +#[op2] +#[string] +fn op_desktop_prompt( + state: &mut OpState, + #[string] message: &str, + #[string] default_value: Option, +) -> Option { + if let Some(api) = state.try_borrow::>() { + let (tx, rx) = std::sync::mpsc::channel(); + api.prompt( + "", + message, + default_value.as_deref().unwrap_or(""), + Box::new(move |result| { + let _ = tx.send(result); + }), + ); + rx.recv().unwrap_or(None) + } else { + None + } +} + deno_core::extension!( deno_desktop, ops = [ @@ -555,6 +613,9 @@ deno_core::extension!( op_desktop_recv_event, op_desktop_resolve_bind_call, op_desktop_reject_bind_call, + op_desktop_alert, + op_desktop_confirm, + op_desktop_prompt, ], objects = [BrowserWindow,], ); diff --git a/runtime/tokio_util.rs b/runtime/tokio_util.rs index e8aeb62508ad55..92594d701ef901 100644 --- a/runtime/tokio_util.rs +++ b/runtime/tokio_util.rs @@ -38,6 +38,14 @@ pub fn create_basic_runtime() -> tokio::runtime::Runtime { "DENO_TOKIO_MAX_IO_EVENTS_PER_TICK", max_io_events_per_tick, )) + // In debug builds, blocking tasks (like swc parsing/emitting via + // spawn_blocking) can overflow the default 2MB thread stack due to + // unoptimized stack frames. Use 8MB to match the main thread size. + .thread_stack_size(if cfg!(debug_assertions) { + 8 * 1024 * 1024 + } else { + 2 * 1024 * 1024 + }) // This limits the number of threads for blocking operations (like for // synchronous fs ops) or CPU bound tasks like when we run dprint in // parallel for deno fmt. From 229483d4da45d4bedb9ec9e3727af6ac11e7151c Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 26 Mar 2026 12:09:09 +0100 Subject: [PATCH 021/137] error reporting --- cli/lib/standalone/binary.rs | 3 ++ cli/rt/desktop.rs | 77 ++++++++++++++++++++++++++++++++- cli/rt/hmr.rs | 21 +++++++++ cli/rt/run.rs | 11 +++++ cli/rt_desktop/lib.rs | 10 +++++ cli/standalone/binary.rs | 6 +++ libs/config/deno_json/mod.rs | 17 ++++++++ libs/config/workspace/mod.rs | 3 ++ runtime/js/99_main.js | 1 + runtime/ops/desktop.rs | 84 +++++++++++++++++++++++++++++++++++- 10 files changed, 230 insertions(+), 3 deletions(-) diff --git a/cli/lib/standalone/binary.rs b/cli/lib/standalone/binary.rs index ee7f88ea0a9937..bcb9be966f646c 100644 --- a/cli/lib/standalone/binary.rs +++ b/cli/lib/standalone/binary.rs @@ -100,6 +100,9 @@ pub struct Metadata { /// auto-update support in desktop apps. #[serde(default, skip_serializing_if = "Option::is_none")] pub app_version: Option, + /// Error reporting URL from deno.json `desktop.errorReporting.url`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_reporting_url: Option, } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index f27d8691952493..5de9fd780ef0d5 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -217,7 +217,7 @@ pub const DESKTOP_JS: &str = r#" }; function alert(message = "Alert") { - op_desktop_alert(String(message)); + op_desktop_alert("", String(message)); } function confirm(message = "Confirm") { @@ -384,6 +384,13 @@ pub const DESKTOP_JS: &str = r#" target.dispatchEvent(new Event("close")); break; } + case "runtimeError": { + dispatchEvent(new ErrorEvent("error", { + message: ev.message, + error: new Error(ev.message), + })); + break; + } } } })(); @@ -481,6 +488,74 @@ pub fn desktop_auto_update_js( ) } +/// JS code that initializes error reporting. Installs `"error"` and +/// `"unhandledrejection"` listeners that show a native alert and +/// optionally POST error reports to a configured URL. +pub fn desktop_error_reporting_js( + url: Option<&str>, + version: Option<&str>, +) -> String { + format!( + r#"(() => {{ + const {{ op_desktop_alert, op_desktop_send_error_report }} = Deno[Deno.internal].core.ops; + const _errorReportingUrl = {url}; + const _appVersion = {version}; + + function handleError(message, stack) {{ + if (_errorReportingUrl) {{ + const body = JSON.stringify({{ + version: 1, + message: String(message), + stack: stack ?? null, + appVersion: _appVersion, + timestamp: new Date().toISOString(), + platform: Deno.build.os, + arch: Deno.build.arch, + }}); + op_desktop_send_error_report(_errorReportingUrl, body); + }} + + try {{ + op_desktop_alert("Application Error", String(message)); + }} catch (_) {{}} + }} + + addEventListener("error", (ev) => {{ + if (ev.defaultPrevented) return; + const err = ev.error; + handleError( + err?.message ?? ev.message ?? "Unknown error", + err?.stack ?? null, + ); + }}); + + addEventListener("unhandledrejection", (ev) => {{ + if (ev.defaultPrevented) return; + const err = ev.reason; + handleError( + err?.message ?? String(err ?? "Unhandled promise rejection"), + err?.stack ?? null, + ); + }}); +}})(); +"#, + url = match url { + Some(u) => format!( + "\"{}\"", + u.replace('\\', "\\\\").replace('"', "\\\"") + ), + None => "null".to_string(), + }, + version = match version { + Some(v) => format!( + "\"{}\"", + v.replace('\\', "\\\\").replace('"', "\\\"") + ), + None => "null".to_string(), + }, + ) +} + pub use deno_runtime::ops::desktop::DesktopEvent; pub use deno_runtime::ops::desktop::DesktopEventReceiver; pub use deno_runtime::ops::desktop::DesktopEventSender; diff --git a/cli/rt/hmr.rs b/cli/rt/hmr.rs index 71f715e53057a5..4aa8df926f30c3 100644 --- a/cli/rt/hmr.rs +++ b/cli/rt/hmr.rs @@ -264,6 +264,9 @@ pub struct DesktopHmrRunner { _watcher: notify::RecommendedWatcher, /// Optional callback to trigger a reload (e.g. refresh the webview). on_reload: Option, + /// Optional sender to dispatch errors as DesktopEvents for the error reporter. + desktop_event_tx: + Option>, } impl DesktopHmrRunner { @@ -314,6 +317,7 @@ impl DesktopHmrRunner { vfs_root, _watcher: watcher, on_reload: None, + desktop_event_tx: None, }) } @@ -339,6 +343,12 @@ impl DesktopHmrRunner { maybe_error = self.exception_rx.recv() => { if let Some(err) = maybe_error { log::error!("HMR exception: {}", err); + if let Some(tx) = &self.desktop_event_tx { + let _ = tx.send(crate::desktop::DesktopEvent::RuntimeError { + message: err.to_string(), + stack: None, + }); + } } } @@ -500,6 +510,17 @@ pub fn setup_desktop_hmr( let mut runner = DesktopHmrRunner::new(session, state, watch_dir, vfs_root, exception_rx)?; + + // Extract the desktop event sender from OpState if available, so HMR + // errors can be dispatched as DesktopEvent::RuntimeError. + let desktop_event_tx = worker + .js_runtime() + .op_state() + .borrow() + .try_borrow::() + .map(|s| s.0.clone()); + runner.desktop_event_tx = desktop_event_tx; + runner.start(); Ok(runner) } diff --git a/cli/rt/run.rs b/cli/rt/run.rs index a9221544b50c48..f53b3264da0db3 100644 --- a/cli/rt/run.rs +++ b/cli/rt/run.rs @@ -821,6 +821,8 @@ pub struct RunOptions { /// Version and rollback state for desktop auto-update JS initialization. pub auto_update_version: Option, pub auto_update_rolled_back: bool, + /// Error reporting URL from deno.json `desktop.errorReporting.url`. + pub error_reporting_url: Option, } impl Default for RunOptions { @@ -835,6 +837,7 @@ impl Default for RunOptions { override_main_module: None, auto_update_version: None, auto_update_rolled_back: false, + error_reporting_url: None, } } } @@ -1291,6 +1294,14 @@ pub async fn run_with_options( worker .js_runtime() .execute_script("ext:deno_desktop/auto_update", js)?; + + let js = crate::desktop::desktop_error_reporting_js( + options.error_reporting_url.as_deref(), + options.auto_update_version.as_deref(), + ); + worker + .js_runtime() + .execute_script("ext:deno_desktop/error_reporting", js)?; } if let Some(watch_dir) = hmr_watch_dir { diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 4ae1066a712325..19fa29f41bd0d1 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -608,6 +608,7 @@ wef::main!(|| { match run_desktop(update_rolled_back).await { Ok(()) => eprintln!("[desktop] run_desktop completed OK"), Err(error) => { + let is_js_error = js_error_downcast_ref(&error).is_some(); let error_string = match js_error_downcast_ref(&error) { Some(js_error) => format_js_error(js_error, None), None => format!("{:?}", error), @@ -617,6 +618,14 @@ wef::main!(|| { colors::red_bold("error"), error_string.trim_start_matches("error: ") ); + // Only show native alert for non-JS errors (startup crashes). + // JS errors are already handled by the error reporting JS listener. + if !is_js_error { + wef::alert( + "Application Error", + error_string.trim_start_matches("error: "), + ); + } } } }); @@ -965,6 +974,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { override_main_module: None, auto_update_version, auto_update_rolled_back, + error_reporting_url: data.metadata.error_reporting_url.clone(), }; // Run the Deno runtime and WEF event loop concurrently. diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index 7ddc3ff0587d8d..91e364558ff1b6 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -911,6 +911,12 @@ impl<'a> DenoCompileBinaryWriter<'a> { .workspace() .root_deno_json() .and_then(|c| c.json.version.clone()), + error_reporting_url: self + .cli_options + .start_dir + .to_desktop_config() + .ok() + .and_then(|c| c.error_reporting.as_ref()?.url.clone()), }; let (data_section_bytes, section_sizes) = serialize_binary_data_section( diff --git a/libs/config/deno_json/mod.rs b/libs/config/deno_json/mod.rs index f334dadf70678f..892e8a77a29633 100644 --- a/libs/config/deno_json/mod.rs +++ b/libs/config/deno_json/mod.rs @@ -800,6 +800,12 @@ struct SerializedDesktopReleaseConfig { pub base_url: Option, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +struct SerializedDesktopErrorReportingConfig { + pub url: Option, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] struct SerializedDesktopConfig { @@ -807,6 +813,8 @@ struct SerializedDesktopConfig { pub backend: Option, pub output: Option, pub release: Option, + #[serde(rename = "errorReporting")] + pub error_reporting: Option, } impl SerializedDesktopConfig { @@ -829,6 +837,9 @@ impl SerializedDesktopConfig { release: self.release.map(|r| DesktopReleaseConfig { base_url: r.base_url, }), + error_reporting: self.error_reporting.map(|e| { + DesktopErrorReportingConfig { url: e.url } + }), } } } @@ -858,12 +869,18 @@ pub struct DesktopReleaseConfig { pub base_url: Option, } +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopErrorReportingConfig { + pub url: Option, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct DesktopConfig { pub app: Option, pub backend: Option, pub output: Option, pub release: Option, + pub error_reporting: Option, } #[derive(Clone, Debug, Deserialize, PartialEq)] diff --git a/libs/config/workspace/mod.rs b/libs/config/workspace/mod.rs index 502bac032193ae..b87dab810d4768 100644 --- a/libs/config/workspace/mod.rs +++ b/libs/config/workspace/mod.rs @@ -2215,6 +2215,9 @@ impl WorkspaceDirectory { backend: member_config.backend.or(root_config.backend), output: member_config.output.or(root_config.output), release: member_config.release.or(root_config.release), + error_reporting: member_config + .error_reporting + .or(root_config.error_reporting), }) } diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 6cb373c9518978..fe0d8f4f4dee5a 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -550,6 +550,7 @@ const NOT_IMPORTED_OPS = [ "op_desktop_alert", "op_desktop_confirm", "op_desktop_prompt", + "op_desktop_send_error_report", // deno deploy subcommand "op_deploy_token_get", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 633b5ad0b92035..b2a81f7647c2ae 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -112,6 +112,11 @@ pub enum DesktopEvent { CloseRequested { window_id: u32, }, + #[serde(rename_all = "camelCase")] + RuntimeError { + message: String, + stack: Option, + }, } pub struct DesktopEventReceiver( @@ -562,9 +567,83 @@ pub fn op_desktop_init( } #[op2(fast)] -fn op_desktop_alert(state: &mut OpState, #[string] message: &str) { +fn op_desktop_alert( + state: &mut OpState, + #[string] title: &str, + #[string] message: &str, +) { if let Some(api) = state.try_borrow::>() { - api.alert("", message); + api.alert(title, message); + } +} + +#[op2(fast)] +fn op_desktop_send_error_report( + state: &mut OpState, + #[string] url: &str, + #[string] body: &str, +) { + let url = url.to_string(); + let body = body.to_string(); + + let Ok(parsed) = deno_core::url::Url::parse(&url) else { + // Not a valid URL — treat as a file path. + let mut line = body; + line.push('\n'); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&url) + .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes())); + return; + }; + + match parsed.scheme() { + "file" => { + if let Ok(path) = parsed.to_file_path() { + let mut line = body; + line.push('\n'); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut f| { + std::io::Write::write_all(&mut f, line.as_bytes()) + }); + } + } + "http" | "https" => { + let Ok(client) = + deno_fetch::get_or_create_client_from_state(state) + else { + return; + }; + let _ = std::thread::spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + else { + return; + }; + runtime.block_on(async move { + let Ok(uri) = parsed.as_str().parse::() else { + return; + }; + let mut req = + http::Request::new(deno_fetch::ReqBody::full(body.into())); + *req.method_mut() = http::Method::POST; + *req.uri_mut() = uri; + req.headers_mut().insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + let _ = client.send(req).await; + }); + }) + .join(); + } + _ => {} } } @@ -616,6 +695,7 @@ deno_core::extension!( op_desktop_alert, op_desktop_confirm, op_desktop_prompt, + op_desktop_send_error_report, ], objects = [BrowserWindow,], ); From 00a76fbc597f330c8d04ce9caf4ddadd18233ed7 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 31 Mar 2026 17:28:14 +0200 Subject: [PATCH 022/137] rework app menu and add contextmenu --- Cargo.lock | 2 + cli/rt/desktop.rs | 28 ++++--- cli/rt_desktop/Cargo.toml | 1 + cli/rt_desktop/lib.rs | 158 +++++++++++++++++++++++++++++++---- cli/tools/compile.rs | 4 +- cli/tools/desktop.rs | 9 +- ext/webgpu/lib.rs | 4 +- libs/config/deno_json/mod.rs | 6 +- runtime/Cargo.toml | 1 + runtime/ops/desktop.rs | 130 ++++++++++++++++++++++++---- 10 files changed, 289 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40e9163aba564a..d19562b1c2f6ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3150,6 +3150,7 @@ dependencies = [ "ntapi", "once_cell", "qbsdiff", + "raw-window-handle", "regex", "rustyline", "same-file", @@ -3570,6 +3571,7 @@ dependencies = [ "denort", "libsui", "log", + "raw-window-handle", "rustls", "serde_json", "tokio", diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 5de9fd780ef0d5..6597bd258cfb96 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -12,6 +12,7 @@ use deno_core::OpState; // Re-export from runtime so denort_desktop can use them. pub use deno_runtime::ops::desktop::AutoUpdateState; pub use deno_runtime::ops::desktop::DesktopApi; +pub use deno_runtime::ops::desktop::MenuItem; /// JS code that exposes desktop APIs via `Deno.BrowserWindow` and `Deno.desktop`. pub const DESKTOP_JS: &str = r#" @@ -194,6 +195,8 @@ pub const DESKTOP_JS: &str = r#" internals.defineEventHandler(BrowserWindowPrototype, "resize"); internals.defineEventHandler(BrowserWindowPrototype, "move"); internals.defineEventHandler(BrowserWindowPrototype, "close"); + internals.defineEventHandler(BrowserWindowPrototype, "menuclick"); + internals.defineEventHandler(BrowserWindowPrototype, "contextmenuclick"); // Per-window bind callback registry: windowId -> Map const windowBindCallbacks = new Map(); @@ -253,9 +256,18 @@ pub const DESKTOP_JS: &str = r#" const ev = await p; if (ev == null) break; switch (ev.kind) { - case "menuClick": - dispatchEvent(new CustomEvent("menuclick", { detail: { id: ev.id } })); + case "appMenuClick": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("menuclick", { detail: { id: ev.id } })); break; + } + case "contextMenuClick": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("contextmenuclick", { detail: { id: ev.id } })); + break; + } case "keyboardEvent": { const target = windows.get(ev.windowId); if (!target) break; @@ -540,17 +552,13 @@ pub fn desktop_error_reporting_js( }})(); "#, url = match url { - Some(u) => format!( - "\"{}\"", - u.replace('\\', "\\\\").replace('"', "\\\"") - ), + Some(u) => + format!("\"{}\"", u.replace('\\', "\\\\").replace('"', "\\\"")), None => "null".to_string(), }, version = match version { - Some(v) => format!( - "\"{}\"", - v.replace('\\', "\\\\").replace('"', "\\\"") - ), + Some(v) => + format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\"")), None => "null".to_string(), }, ) diff --git a/cli/rt_desktop/Cargo.toml b/cli/rt_desktop/Cargo.toml index ef4694d3a33446..49fcd357132418 100644 --- a/cli/rt_desktop/Cargo.toml +++ b/cli/rt_desktop/Cargo.toml @@ -31,6 +31,7 @@ libsui.workspace = true wef = { path = "../../../wef/capi" } log = { workspace = true, features = ["serde"] } +raw-window-handle.workspace = true rustls.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 19fa29f41bd0d1..f9bd864a2eaa59 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -298,16 +298,119 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::quit(); } - fn set_application_menu(&self, template_json: &str) { - let json: serde_json::Value = match serde_json::from_str(template_json) { - Ok(v) => v, - Err(e) => { - log::error!("Invalid menu template JSON: {}", e); - return; + fn set_application_menu( + &self, + window_id: u32, + menu: Vec, + ) { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_wef_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + wef::Window::from_id(window_id).set_menu(&menu, move |id: &str| { + let _ = + tx.send(deno_runtime::ops::desktop::DesktopEvent::AppMenuClick { + window_id, + id: id.to_string(), + }); + }); + } + + fn show_context_menu( + &self, + window_id: u32, + x: i32, + y: i32, + menu: Vec, + ) { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_wef_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + wef::Window::from_id(window_id).show_context_menu( + x, + y, + &menu, + move |id: &str| { + let _ = tx.send( + deno_runtime::ops::desktop::DesktopEvent::ContextMenuClick { + window_id, + id: id.to_string(), + }, + ); + }, + ); + } + + fn get_raw_window_handle( + &self, + window_id: u32, + ) -> ( + raw_window_handle::RawWindowHandle, + raw_window_handle::RawDisplayHandle, + ) { + let window = wef::Window::from_id(window_id); + let handle_type = window.get_window_handle_type(); + let raw_win = window.get_window_handle(); + let raw_display = window.get_display_handle(); + + match handle_type { + wef::WEF_WINDOW_HANDLE_APPKIT => { + use raw_window_handle::*; + let win = RawWindowHandle::AppKit( + AppKitWindowHandle::new( + std::ptr::NonNull::new(raw_win).expect("null window handle"), + ), + ); + let display = + RawDisplayHandle::AppKit(AppKitDisplayHandle::new()); + (win, display) } - }; - let wef_value = json_to_wef_value(&json); - wef::set_application_menu_raw(wef_value); + wef::WEF_WINDOW_HANDLE_WIN32 => { + use raw_window_handle::*; + let mut handle = Win32WindowHandle::new( + std::num::NonZeroIsize::new(raw_win as isize) + .expect("null window handle"), + ); + handle.hinstance = std::num::NonZeroIsize::new(raw_display as isize).map(|v| v.into()); + let win = RawWindowHandle::Win32(handle); + let display = RawDisplayHandle::Windows( + WindowsDisplayHandle::new(), + ); + (win, display) + } + wef::WEF_WINDOW_HANDLE_X11 => { + use raw_window_handle::*; + let win = RawWindowHandle::Xlib( + XlibWindowHandle::new(raw_win as _), + ); + let display = RawDisplayHandle::Xlib( + XlibDisplayHandle::new( + std::ptr::NonNull::new(raw_display), + 0, + ), + ); + (win, display) + } + wef::WEF_WINDOW_HANDLE_WAYLAND => { + use raw_window_handle::*; + let win = RawWindowHandle::Wayland( + WaylandWindowHandle::new( + std::ptr::NonNull::new(raw_win).expect("null window handle"), + ), + ); + let display = RawDisplayHandle::Wayland( + WaylandDisplayHandle::new( + std::ptr::NonNull::new(raw_display) + .expect("null display handle"), + ), + ); + (win, display) + } + _ => panic!("unknown window handle type: {handle_type}"), + } } fn alert(&self, title: &str, message: &str) { @@ -334,6 +437,35 @@ impl denort::desktop::DesktopApi for WefDesktopApi { } } +fn desktop_menu_item_to_wef_menu_item( + item: denort::desktop::MenuItem, +) -> wef::MenuItem { + match item { + denort::desktop::MenuItem::Item { + label, + id, + accelerator, + enabled, + } => wef::MenuItem::Item { + label, + id, + accelerator, + enabled, + }, + denort::desktop::MenuItem::Submenu { label, items } => { + wef::MenuItem::Submenu { + label, + items: items + .into_iter() + .map(desktop_menu_item_to_wef_menu_item) + .collect(), + } + } + denort::desktop::MenuItem::Separator => wef::MenuItem::Separator, + denort::desktop::MenuItem::Role { role } => wef::MenuItem::Role { role }, + } +} + #[allow(dead_code)] fn wef_value_to_v8<'a>( scope: &v8::PinScope<'a, '_>, @@ -951,7 +1083,6 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let window_id = api.create_window(800, 600); initial_window_id.store(window_id, Ordering::Release); - let menu_tx = event_tx.0.clone(); denort::desktop::init_desktop_state( state, Box::new(api), @@ -963,13 +1094,6 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { state.put(denort::desktop::InitialWindowId(std::sync::Mutex::new( Some(window_id), ))); - - wef::set_menu_click_handler(move |id| { - let _ = - menu_tx.send(deno_runtime::ops::desktop::DesktopEvent::MenuClick { - id: id.to_string(), - }); - }); })), override_main_module: None, auto_update_version, diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index eb668cde376e0e..cf570ee2bf6a17 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -59,7 +59,9 @@ pub async fn compile( if compile_flags.eszip { compile_eszip(flags, compile_flags).boxed_local().await?; } else { - compile_binary(flags, compile_flags, false).boxed_local().await?; + compile_binary(flags, compile_flags, false) + .boxed_local() + .await?; } Ok(()) diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 3d5fb082d07fc6..58e95371753c6c 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -168,12 +168,9 @@ async fn compile_desktop( temp_flags.subcommand = DenoSubcommand::Compile(compile_flags.clone()); temp_flags.internal.is_desktop = true; - let output_path = super::compile::compile_binary( - Arc::new(temp_flags), - compile_flags, - true, - ) - .await?; + let output_path = + super::compile::compile_binary(Arc::new(temp_flags), compile_flags, true) + .await?; if desktop_flags.hmr { let cwd = cli_options.initial_cwd(); diff --git a/ext/webgpu/lib.rs b/ext/webgpu/lib.rs index 00ad11b1cf6de1..574bd893af7282 100644 --- a/ext/webgpu/lib.rs +++ b/ext/webgpu/lib.rs @@ -21,7 +21,7 @@ mod adapter; mod bind_group; mod bind_group_layout; pub mod buffer; -mod byow; +pub mod byow; mod command_buffer; mod command_encoder; mod compute_pass; @@ -36,7 +36,7 @@ mod render_pass; mod render_pipeline; mod sampler; mod shader; -mod surface; +pub mod surface; pub mod texture; mod webidl; diff --git a/libs/config/deno_json/mod.rs b/libs/config/deno_json/mod.rs index 892e8a77a29633..1a25fbc350478f 100644 --- a/libs/config/deno_json/mod.rs +++ b/libs/config/deno_json/mod.rs @@ -837,9 +837,9 @@ impl SerializedDesktopConfig { release: self.release.map(|r| DesktopReleaseConfig { base_url: r.base_url, }), - error_reporting: self.error_reporting.map(|e| { - DesktopErrorReportingConfig { url: e.url } - }), + error_reporting: self + .error_reporting + .map(|e| DesktopErrorReportingConfig { url: e.url }), } } } diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 8580d35458ffc1..1f823253466678 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -89,6 +89,7 @@ log.workspace = true notify.workspace = true once_cell.workspace = true qbsdiff.workspace = true +raw-window-handle.workspace = true regex.workspace = true rustyline = { workspace = true, features = ["custom-bindings"] } same-file.workspace = true diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index b2a81f7647c2ae..ca09119dd80973 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -7,6 +7,7 @@ //! builds), the ops silently no-op. use std::borrow::Cow; +use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicU32; @@ -14,6 +15,7 @@ use std::sync::atomic::Ordering; use deno_core::FromV8; use deno_core::OpState; +use deno_core::cppgc::SameObject; use deno_core::op2; use deno_core::serde_json; use deno_core::v8; @@ -22,7 +24,14 @@ use deno_core::v8; #[derive(Debug, serde::Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum DesktopEvent { - MenuClick { + #[serde(rename_all = "camelCase")] + AppMenuClick { + window_id: u32, + id: String, + }, + #[serde(rename_all = "camelCase")] + ContextMenuClick { + window_id: u32, id: String, }, #[serde(rename_all = "camelCase")] @@ -203,7 +212,23 @@ pub trait DesktopApi: Send + Sync + 'static { fn navigate(&self, window_id: u32, url: &str); fn quit(&self); - fn set_application_menu(&self, template_json: &str); + fn set_application_menu(&self, window_id: u32, menu: Vec); + fn show_context_menu( + &self, + window_id: u32, + x: i32, + y: i32, + menu: Vec, + ); + + /// Returns the raw window and display handles for the given window. + fn get_raw_window_handle( + &self, + window_id: u32, + ) -> ( + raw_window_handle::RawWindowHandle, + raw_window_handle::RawDisplayHandle, + ); fn alert(&self, title: &str, message: &str); fn confirm( @@ -229,6 +254,7 @@ pub struct InitialWindowId(pub std::sync::Mutex>); struct BrowserWindow { api: Arc, window_id: u32, + surface: SameObject, } // SAFETY: we're sure this can be GCed @@ -295,7 +321,11 @@ impl BrowserWindow { } } - let window = BrowserWindow { api, window_id }; + let window = BrowserWindow { + api, + window_id, + surface: SameObject::new(), + }; let window = deno_core::cppgc::make_cppgc_object(scope, window); let event_target_setup = state.borrow::(); let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); @@ -418,10 +448,60 @@ impl BrowserWindow { */ } - #[fast] - fn set_application_menu(&self, #[string] template_json: &str) { - // TODO - self.api.set_application_menu(template_json); + fn set_application_menu(&self, #[serde] menu: Vec) { + self.api.set_application_menu(self.window_id, menu); + } + + fn show_context_menu( + &self, + #[smi] x: i32, + #[smi] y: i32, + #[serde] menu: Vec, + ) { + self + .api + .show_context_menu(self.window_id, x, y, menu); + } + + fn get_surface( + &self, + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + ) -> Result, deno_error::JsErrorBox> { + let instance = state + .try_borrow::() + .ok_or_else(|| { + deno_error::JsErrorBox::type_error( + "Cannot create surface outside of WebGPU context. Did you forget to call `navigator.gpu.requestAdapter()`?", + ) + })? + .clone(); + + let api = self.api.clone(); + let window_id = self.window_id; + + Ok(self.surface.get(scope, move |_| { + let (win_handle, display_handle) = + api.get_raw_window_handle(window_id); + + // SAFETY: The raw handles are valid for the lifetime of the window, + // which is guaranteed by the BrowserWindow preventing the window from + // being destroyed while this surface exists. + let surface_id = unsafe { + instance + .instance_create_surface(display_handle, win_handle, None) + .expect("failed to create surface") + }; + + let (width, height) = api.get_window_size(window_id); + + deno_webgpu::byow::UnsafeWindowSurface { + id: surface_id, + width: RefCell::new(width as u32), + height: RefCell::new(height as u32), + context: SameObject::new(), + } + })) } } @@ -436,6 +516,25 @@ struct BrowserWindowOptions { always_on_top: Option, } +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MenuItem { + Item { + label: String, + id: Option, + accelerator: Option, + enabled: bool, + }, + Submenu { + label: String, + items: Vec, + }, + Separator, + Role { + role: String, + }, +} + /// State for the auto-update system, placed into OpState at init. pub struct AutoUpdateState { /// Path to the currently running dylib on disk. @@ -607,14 +706,11 @@ fn op_desktop_send_error_report( .create(true) .append(true) .open(path) - .and_then(|mut f| { - std::io::Write::write_all(&mut f, line.as_bytes()) - }); + .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes())); } } "http" | "https" => { - let Ok(client) = - deno_fetch::get_or_create_client_from_state(state) + let Ok(client) = deno_fetch::get_or_create_client_from_state(state) else { return; }; @@ -651,9 +747,13 @@ fn op_desktop_send_error_report( fn op_desktop_confirm(state: &mut OpState, #[string] message: &str) -> bool { if let Some(api) = state.try_borrow::>() { let (tx, rx) = std::sync::mpsc::channel(); - api.confirm("", message, Box::new(move |result| { - let _ = tx.send(result); - })); + api.confirm( + "", + message, + Box::new(move |result| { + let _ = tx.send(result); + }), + ); rx.recv().unwrap_or(false) } else { false From 2d62bbc0e872fa63a3f5363efe10c3daaa763bca Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 1 Apr 2026 17:40:20 +0200 Subject: [PATCH 023/137] implement devtools & executejs --- cli/factory.rs | 1 + cli/lib/worker.rs | 3 +- cli/rt/desktop.rs | 4 +- cli/rt/run.rs | 1 + cli/rt_desktop/lib.rs | 61 ++++++++++++++++++++++++- runtime/ops/desktop.rs | 101 +++++++++++++++++++++++++++++++++++++---- 6 files changed, 157 insertions(+), 14 deletions(-) diff --git a/cli/factory.rs b/cli/factory.rs index a17c21d3ea72d8..6a362780972b89 100644 --- a/cli/factory.rs +++ b/cli/factory.rs @@ -1204,6 +1204,7 @@ impl CliFactory { no_legacy_abort: cli_options.no_legacy_abort(), startup_snapshot: deno_snapshots::CLI_SNAPSHOT, enable_raw_imports: cli_options.unstable_raw_imports(), + close_on_idle: true, maybe_initial_cwd: Some(deno_path_util::url_from_directory_path( cli_options.initial_cwd(), )?), diff --git a/cli/lib/worker.rs b/cli/lib/worker.rs index 887b2f4eb5c2ea..55f64f2d53e9b9 100644 --- a/cli/lib/worker.rs +++ b/cli/lib/worker.rs @@ -256,6 +256,7 @@ pub struct LibMainWorkerOptions { pub startup_snapshot: Option<&'static [u8]>, pub serve_port: Option, pub serve_host: Option, + pub close_on_idle: bool, pub maybe_initial_cwd: Option, } @@ -597,7 +598,7 @@ impl LibMainWorkerFactory { serve_port: shared.options.serve_port, serve_host: shared.options.serve_host.clone(), otel_config: shared.options.otel_config.clone(), - close_on_idle: true, + close_on_idle: shared.options.close_on_idle, }, extensions: custom_extensions, startup_snapshot: shared.options.startup_snapshot, diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 6597bd258cfb96..574f496ab39fad 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -203,7 +203,7 @@ pub const DESKTOP_JS: &str = r#" const nativeBind = BrowserWindowPrototype.bind; BrowserWindowPrototype.bind = function(name, fn) { - const windowId = this.getWindowId(); + const windowId = this.windowId; if (!windowBindCallbacks.has(windowId)) { windowBindCallbacks.set(windowId, new Map()); } @@ -213,7 +213,7 @@ pub const DESKTOP_JS: &str = r#" const nativeUnbind = BrowserWindowPrototype.unbind; BrowserWindowPrototype.unbind = function(name) { - const windowId = this.getWindowId(); + const windowId = this.windowId; const callbacks = windowBindCallbacks.get(windowId); if (callbacks) callbacks.delete(name); nativeUnbind.call(this, name); diff --git a/cli/rt/run.rs b/cli/rt/run.rs index f53b3264da0db3..fa086bafcd02b5 100644 --- a/cli/rt/run.rs +++ b/cli/rt/run.rs @@ -1210,6 +1210,7 @@ pub async fn run_with_options( no_legacy_abort: false, startup_snapshot: deno_snapshots::CLI_SNAPSHOT, enable_raw_imports: metadata.unstable_config.raw_imports, + close_on_idle: !options.auto_serve, maybe_initial_cwd: None, }; let worker_factory = LibMainWorkerFactory::new( diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index f9bd864a2eaa59..212e9a1e1d3fdb 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -244,6 +244,35 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::Window::from_id(window_id).focus(); } + fn open_devtools(&self, window_id: u32) { + wef::Window::from_id(window_id).open_devtools(); + } + + fn execute_js( + &self, + window_id: u32, + script: &str, + callback: Box< + dyn FnOnce( + Result, + ) + Send + + 'static, + >, + ) { + wef::Window::from_id(window_id).execute_js( + script, + Some(move |result: Result| { + callback(match result { + Ok(val) => Ok(wef_value_to_desktop_value(val)), + Err(err) => Err(match err { + wef::Value::String(s) => s, + _ => "execute_js failed".to_string(), + }), + }); + }), + ); + } + fn bind(&self, window_id: u32, name: &str) { let tx = self.event_tx.clone(); let responses = self.pending_responses.clone(); @@ -730,6 +759,8 @@ wef::main!(|| { .install_default() .unwrap(); + wef::set_js_namespace("bindings"); + let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -906,6 +937,31 @@ fn extract_fork_script_path( None } +/// Convert a wef::Value to a DesktopValue for direct V8 conversion. +fn wef_value_to_desktop_value( + v: wef::Value, +) -> deno_runtime::ops::desktop::DesktopValue { + use deno_runtime::ops::desktop::DesktopValue; + match v { + wef::Value::Null => DesktopValue::Null, + wef::Value::Bool(b) => DesktopValue::Bool(b), + wef::Value::Int(i) => DesktopValue::Int(i), + wef::Value::Double(d) => DesktopValue::Double(d), + wef::Value::String(s) => DesktopValue::String(s), + wef::Value::List(l) => { + DesktopValue::List(l.into_iter().map(wef_value_to_desktop_value).collect()) + } + wef::Value::Dict(d) => { + DesktopValue::Dict( + d.into_iter() + .map(|(k, v)| (k, wef_value_to_desktop_value(v))) + .collect(), + ) + } + wef::Value::Binary(b) => DesktopValue::Binary(b), + } +} + /// Convert a wef::Value to a serde_json::Value for channel transport. fn wef_value_to_json(v: &wef::Value) -> serde_json::Value { match v { @@ -1113,7 +1169,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { // Wait for the server to be ready, then navigate the initial window. // Do a full HTTP request instead of just a TCP connect — frameworks // like Vite accept connections before they're ready to serve. - let navigate_fut = async { + let navigate_fut = async move { use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; for i in 0..60 { @@ -1152,6 +1208,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { wef::Window::from_id(id).navigate(&url); }; + tokio::spawn(navigate_fut); + tokio::select! { result = run_fut => { match result { @@ -1164,7 +1222,6 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { } } } - _ = navigate_fut => {} _ = wef_fut => { eprintln!("[desktop] WEF event loop ended (window closed)"); } diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index ca09119dd80973..661a9707ff6123 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -15,11 +15,69 @@ use std::sync::atomic::Ordering; use deno_core::FromV8; use deno_core::OpState; +use deno_core::ToV8; use deno_core::cppgc::SameObject; use deno_core::op2; use deno_core::serde_json; use deno_core::v8; +/// Thread-safe intermediate value type for crossing the WEF ↔ Deno boundary. +/// Converts directly to V8 values without going through serde. +pub enum DesktopValue { + Null, + Bool(bool), + Int(i32), + Double(f64), + String(String), + List(Vec), + Dict(Vec<(String, DesktopValue)>), + Binary(Vec), +} + +impl<'a> ToV8<'a> for DesktopValue { + type Error = std::convert::Infallible; + + fn to_v8( + self, + scope: &mut v8::PinScope<'a, '_>, + ) -> Result, Self::Error> { + Ok(match self { + DesktopValue::Null => v8::null(scope).into(), + DesktopValue::Bool(b) => v8::Boolean::new(scope, b).into(), + DesktopValue::Int(i) => v8::Integer::new(scope, i).into(), + DesktopValue::Double(d) => v8::Number::new(scope, d).into(), + DesktopValue::String(s) => { + v8::String::new(scope, &s).unwrap().into() + } + DesktopValue::List(l) => { + let arr = v8::Array::new(scope, l.len() as i32); + for (i, v) in l.into_iter().enumerate() { + let val = v.to_v8(scope)?; + arr.set_index(scope, i as u32, val); + } + arr.into() + } + DesktopValue::Dict(d) => { + let obj = v8::Object::new(scope); + for (k, v) in d { + let key: v8::Local = + v8::String::new(scope, &k).unwrap().into(); + let val = v.to_v8(scope)?; + obj.set(scope, key, val); + } + obj.into() + } + DesktopValue::Binary(b) => { + let len = b.len(); + let store = v8::ArrayBuffer::new_backing_store_from_vec(b); + let ab = + v8::ArrayBuffer::with_backing_store(scope, &store.into()); + v8::Uint8Array::new(scope, ab, 0, len).unwrap().into() + } + }) + } +} + /// A single event type that flows from the WEF backend to the Deno runtime. #[derive(Debug, serde::Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] @@ -230,6 +288,17 @@ pub trait DesktopApi: Send + Sync + 'static { raw_window_handle::RawDisplayHandle, ); + fn open_devtools(&self, window_id: u32); + + fn execute_js( + &self, + window_id: u32, + script: &str, + callback: Box< + dyn FnOnce(Result) + Send + 'static, + >, + ); + fn alert(&self, title: &str, message: &str); fn confirm( &self, @@ -433,19 +502,33 @@ impl BrowserWindow { self.api.navigate(self.window_id, url); } + #[fast] + fn open_devtools(&self) { + self.api.open_devtools(self.window_id); + } + #[fast] fn reload(&self) { todo!("implement") } - #[fast] - fn execute_js(&self, #[string] _script: &str) { - todo!( - "implement execute_js — async + v8 scope borrowing needs a different approach" - ) - /* - self.api.execute_js(scope, script).await - */ + async fn execute_js( + &self, + #[string] script: String, + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.api.execute_js( + self.window_id, + &script, + Box::new(move |result| { + let _ = tx.send(result); + }), + ); + rx.await + .map_err(|_| { + deno_error::JsErrorBox::generic("execute_js callback dropped") + })? + .map_err(|e| deno_error::JsErrorBox::generic(e)) } fn set_application_menu(&self, #[serde] menu: Vec) { @@ -463,7 +546,7 @@ impl BrowserWindow { .show_context_menu(self.window_id, x, y, menu); } - fn get_surface( + fn get_native_window( &self, state: &OpState, scope: &mut v8::PinScope<'_, '_>, From 31722bf840bad5db8c66530861ab8204a2d21c2f Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 1 Apr 2026 17:57:32 +0200 Subject: [PATCH 024/137] better handling --- cli/rt_desktop/lib.rs | 10 +++++----- runtime/ops/desktop.rs | 42 +++++++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 212e9a1e1d3fdb..63c3b6aca42759 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -254,7 +254,10 @@ impl denort::desktop::DesktopApi for WefDesktopApi { script: &str, callback: Box< dyn FnOnce( - Result, + Result< + deno_runtime::ops::desktop::DesktopValue, + deno_runtime::ops::desktop::DesktopValue, + >, ) + Send + 'static, >, @@ -264,10 +267,7 @@ impl denort::desktop::DesktopApi for WefDesktopApi { Some(move |result: Result| { callback(match result { Ok(val) => Ok(wef_value_to_desktop_value(val)), - Err(err) => Err(match err { - wef::Value::String(s) => s, - _ => "execute_js failed".to_string(), - }), + Err(err) => Err(wef_value_to_desktop_value(err)), }); }), ); diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 661a9707ff6123..0a6b17f9f53cbc 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -78,6 +78,35 @@ impl<'a> ToV8<'a> for DesktopValue { } } +/// Wraps a `Result` from `execute_js`. +/// Converts to `{ ok: true, value }` or `{ ok: false, value }`. +pub struct ExecuteJsResult(pub Result); + +impl<'a> ToV8<'a> for ExecuteJsResult { + type Error = std::convert::Infallible; + + fn to_v8( + self, + scope: &mut v8::PinScope<'a, '_>, + ) -> Result, Self::Error> { + let obj = v8::Object::new(scope); + + let ok_key: v8::Local = + v8::String::new(scope, "ok").unwrap().into(); + let value_key: v8::Local = + v8::String::new(scope, "value").unwrap().into(); + + let (ok, val) = match self.0 { + Ok(v) => (true, v.to_v8(scope)?), + Err(v) => (false, v.to_v8(scope)?), + }; + + obj.set(scope, ok_key, v8::Boolean::new(scope, ok).into()); + obj.set(scope, value_key, val); + Ok(obj.into()) + } +} + /// A single event type that flows from the WEF backend to the Deno runtime. #[derive(Debug, serde::Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] @@ -295,7 +324,7 @@ pub trait DesktopApi: Send + Sync + 'static { window_id: u32, script: &str, callback: Box< - dyn FnOnce(Result) + Send + 'static, + dyn FnOnce(Result) + Send + 'static, >, ); @@ -515,7 +544,7 @@ impl BrowserWindow { async fn execute_js( &self, #[string] script: String, - ) -> Result { + ) -> Result { let (tx, rx) = tokio::sync::oneshot::channel(); self.api.execute_js( self.window_id, @@ -524,11 +553,10 @@ impl BrowserWindow { let _ = tx.send(result); }), ); - rx.await - .map_err(|_| { - deno_error::JsErrorBox::generic("execute_js callback dropped") - })? - .map_err(|e| deno_error::JsErrorBox::generic(e)) + let result = rx.await.map_err(|_| { + deno_error::JsErrorBox::generic("execute_js callback dropped") + })?; + Ok(ExecuteJsResult(result)) } fn set_application_menu(&self, #[serde] menu: Vec) { From 6d9327b9d7fefb3de05dbb9667bbccdfd792c8ad Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 1 Apr 2026 18:18:14 +0200 Subject: [PATCH 025/137] fixes, update typings --- cli/rt_desktop/lib.rs | 60 +++++++++++++ cli/tsc/dts/lib.deno.desktop.d.ts | 142 ++++++++++++++++++++++++++++-- runtime/ops/desktop.rs | 104 ++++++++++++++++++---- 3 files changed, 282 insertions(+), 24 deletions(-) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 63c3b6aca42759..aaff3640bb769f 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -751,6 +751,58 @@ wef::main!(|| { return; } + // Set up panic hook for desktop error reporting. The error reporting + // URL is only known after binary metadata is parsed (in run_desktop), + // so the hook reads from a global that gets set later. + { + let orig_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + use deno_runtime::ops::desktop::error_report_config; + use deno_runtime::ops::desktop::send_error_report; + + if let Some((url, app_version)) = error_report_config() { + let message = + if let Some(s) = panic_info.payload().downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = + panic_info.payload().downcast_ref::() + { + s.clone() + } else { + "Deno runtime panicked".to_string() + }; + + let location = panic_info.location().map(|l| { + format!("at {}:{}:{}", l.file(), l.line(), l.column()) + }); + + let body = deno_core::serde_json::json!({ + "version": 1, + "message": message, + "stack": location, + "appVersion": app_version, + "platform": env::consts::OS, + "arch": env::consts::ARCH, + }); + send_error_report(url, &body.to_string()); + } + + eprintln!("\n============================================================"); + eprintln!("Deno has panicked. This is a bug in Deno. Please report this"); + eprintln!("at https://github.com/denoland/deno/issues/new."); + eprintln!(); + eprintln!( + "Platform: {} {}", + env::consts::OS, + env::consts::ARCH + ); + eprintln!(); + + orig_hook(panic_info); + deno_runtime::exit(1); + })); + } + denort::init_logging(None, None); deno_runtime::deno_permissions::mark_standalone(); @@ -1030,6 +1082,14 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { find_section_in_dylib, )?; + // Make the error reporting URL available to the panic hook. + if let Some(ref url) = data.metadata.error_reporting_url { + deno_runtime::ops::desktop::set_error_report_config( + url.clone(), + data.metadata.app_version.clone(), + ); + } + deno_runtime::deno_telemetry::init( otel_runtime_config(), data.metadata.otel_config.clone(), diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index db9490f42549af..96bef3d3a80648 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -8,6 +8,91 @@ /// /// +declare interface UIEventInit extends EventInit { + detail?: number; + view?: null; +} + +declare class UIEvent extends Event { + constructor(type: string, init?: UIEventInit); + readonly detail: number; + readonly view: null; +} + +declare interface FocusEventInit extends UIEventInit { + relatedTarget?: EventTarget | null; +} + +declare class FocusEvent extends UIEvent { + constructor(type: string, init?: FocusEventInit); + readonly relatedTarget: EventTarget | null; +} + +declare interface KeyboardEventInit extends UIEventInit { + key?: string; + code?: string; + location?: number; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + repeat?: boolean; + isComposing?: boolean; +} + +declare class KeyboardEvent extends UIEvent { + constructor(type: string, init?: KeyboardEventInit); + readonly key: string; + readonly code: string; + readonly location: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly repeat: boolean; + readonly isComposing: boolean; + getModifierState(key: string): boolean; +} + +declare interface MouseEventInit extends UIEventInit { + button?: number; + clientX?: number; + clientY?: number; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +declare class MouseEvent extends UIEvent { + constructor(type: string, init?: MouseEventInit); + readonly button: number; + readonly clientX: number; + readonly clientY: number; + readonly screenX: number; + readonly screenY: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + getModifierState(key: string): boolean; +} + +declare interface WheelEventInit extends MouseEventInit { + deltaX?: number; + deltaY?: number; + deltaZ?: number; + deltaMode?: number; +} + +declare class WheelEvent extends MouseEvent { + constructor(type: string, init?: WheelEventInit); + readonly deltaX: number; + readonly deltaY: number; + readonly deltaZ: number; + readonly deltaMode: number; +} + declare namespace Deno { export {}; // stop default export type behavior @@ -54,6 +139,28 @@ declare namespace Deno { ) => Promise; }; + export type MenuItem = + | { + item: { + label: string; + id?: string; + accelerator?: string; + enabled: boolean; + }; + } + | { + submenu: { + label: string; + items: MenuItem[]; + }; + } + | "separator" + | { + role: { + role: string; + }; + }; + interface BrowserWindowResizeDetail { width: number; height: number; @@ -64,6 +171,10 @@ declare namespace Deno { y: number; } + interface MenuClickDetail { + id: string; + } + interface BrowserWindowEventMap { keydown: KeyboardEvent; keyup: KeyboardEvent; @@ -82,6 +193,8 @@ declare namespace Deno { resize: CustomEvent; move: CustomEvent; close: Event; + menuclick: CustomEvent; + contextmenuclick: CustomEvent; } type BrowserWindowEventHandlers = { @@ -93,13 +206,20 @@ declare namespace Deno { export interface BrowserWindow = WindowBindings> extends BrowserWindowEventHandlers {} + export interface UnsafeWindowSurface { + getContext(): GPUCanvasContext; + present(): void; + } + export class BrowserWindow< T extends ValidBindings = WindowBindings, > extends EventTarget { constructor(options?: BrowserWindowOptions); - bind(name: N, fn: T[N]): void; - unbind(name: N): void; + readonly windowId: number; + + bind(name: N, fn: T[N]); + unbind(name: N); /** @throws {BrowserWindowValue} */ executeJs(script: string): Promise; @@ -118,14 +238,20 @@ declare namespace Deno { setAlwaysOnTop(alwaysOnTop: boolean); isClosed(): boolean; - close(): void; + close(); isVisible(): boolean; - show(): void; - hide(): void; - focus(): void; - navigate(url: string): void; - reload(): void; + show(); + hide(); + focus(); + navigate(url: string); + openDevtools(); + reload(); + + setApplicationMenu(menu: MenuItem[]); + showContextMenu(x: number, y: number, menu: MenuItem[]); + + getNativeWindow(): UnsafeWindowSurface; addEventListener( type: K, diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 0a6b17f9f53cbc..449d9277017552 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -10,6 +10,7 @@ use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; +use std::sync::OnceLock; use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; @@ -787,23 +788,41 @@ fn op_desktop_alert( } } -#[op2(fast)] -fn op_desktop_send_error_report( - state: &mut OpState, - #[string] url: &str, - #[string] body: &str, +struct ErrorReportConfig { + url: String, + app_version: Option, +} + +static ERROR_REPORT_CONFIG: OnceLock = OnceLock::new(); + +/// Store the error reporting URL and app version so the panic hook can +/// send reports without access to OpState. +pub fn set_error_report_config( + url: String, + app_version: Option, ) { - let url = url.to_string(); - let body = body.to_string(); + let _ = ERROR_REPORT_CONFIG.set(ErrorReportConfig { url, app_version }); +} + +/// Returns the error reporting URL and app version, if configured. +pub fn error_report_config() -> Option<(&'static str, Option<&'static str>)> { + ERROR_REPORT_CONFIG + .get() + .map(|c| (c.url.as_str(), c.app_version.as_deref())) +} - let Ok(parsed) = deno_core::url::Url::parse(&url) else { +/// Send a JSON error report to the given URL. Handles `file://`, +/// `http://`, and `https://` URLs. Best-effort — never panics. +/// Safe to call from a panic hook (creates its own tokio runtime for HTTP). +pub fn send_error_report(url: &str, body: &str) { + let Ok(parsed) = deno_core::url::Url::parse(url) else { // Not a valid URL — treat as a file path. - let mut line = body; + let mut line = body.to_string(); line.push('\n'); let _ = std::fs::OpenOptions::new() .create(true) .append(true) - .open(&url) + .open(url) .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes())); return; }; @@ -811,7 +830,7 @@ fn op_desktop_send_error_report( match parsed.scheme() { "file" => { if let Ok(path) = parsed.to_file_path() { - let mut line = body; + let mut line = body.to_string(); line.push('\n'); let _ = std::fs::OpenOptions::new() .create(true) @@ -821,10 +840,8 @@ fn op_desktop_send_error_report( } } "http" | "https" => { - let Ok(client) = deno_fetch::get_or_create_client_from_state(state) - else { - return; - }; + let url_str = parsed.to_string(); + let body = body.to_string(); let _ = std::thread::spawn(move || { let Ok(runtime) = tokio::runtime::Builder::new_current_thread() .enable_io() @@ -834,7 +851,13 @@ fn op_desktop_send_error_report( return; }; runtime.block_on(async move { - let Ok(uri) = parsed.as_str().parse::() else { + let Ok(client) = deno_fetch::create_http_client( + "deno-desktop", + Default::default(), + ) else { + return; + }; + let Ok(uri) = url_str.parse::() else { return; }; let mut req = @@ -854,6 +877,55 @@ fn op_desktop_send_error_report( } } +#[op2(fast)] +fn op_desktop_send_error_report( + state: &mut OpState, + #[string] url: &str, + #[string] body: &str, +) { + let parsed = deno_core::url::Url::parse(url); + + // For HTTP(S) URLs, prefer the client from OpState (has user's TLS config). + if let Ok(ref parsed) = parsed { + if matches!(parsed.scheme(), "http" | "https") { + if let Ok(client) = + deno_fetch::get_or_create_client_from_state(state) + { + let url_str = parsed.to_string(); + let body = body.to_string(); + let _ = std::thread::spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + else { + return; + }; + runtime.block_on(async move { + let Ok(uri) = url_str.parse::() else { + return; + }; + let mut req = + http::Request::new(deno_fetch::ReqBody::full(body.into())); + *req.method_mut() = http::Method::POST; + *req.uri_mut() = uri; + req.headers_mut().insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + let _ = client.send(req).await; + }); + }) + .join(); + return; + } + } + } + + // Fall back to the standalone sender for file URLs and non-HTTP. + send_error_report(url, body); +} + #[op2(fast)] fn op_desktop_confirm(state: &mut OpState, #[string] message: &str) -> bool { if let Some(api) = state.try_borrow::>() { From 0f0cce9de5c72c656f25783c8230d9a9aa8fdfb1 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 1 Apr 2026 18:18:24 +0200 Subject: [PATCH 026/137] fmt --- cli/rt_desktop/lib.rs | 130 +++++++++++++++++------------------------ runtime/ops/desktop.rs | 55 +++++------------ 2 files changed, 69 insertions(+), 116 deletions(-) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index aaff3640bb769f..5c232e6edfbc5d 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -254,11 +254,11 @@ impl denort::desktop::DesktopApi for WefDesktopApi { script: &str, callback: Box< dyn FnOnce( - Result< - deno_runtime::ops::desktop::DesktopValue, - deno_runtime::ops::desktop::DesktopValue, - >, - ) + Send + Result< + deno_runtime::ops::desktop::DesktopValue, + deno_runtime::ops::desktop::DesktopValue, + >, + ) + Send + 'static, >, ) { @@ -338,11 +338,10 @@ impl denort::desktop::DesktopApi for WefDesktopApi { .collect::>(); let tx = self.event_tx.clone(); wef::Window::from_id(window_id).set_menu(&menu, move |id: &str| { - let _ = - tx.send(deno_runtime::ops::desktop::DesktopEvent::AppMenuClick { - window_id, - id: id.to_string(), - }); + let _ = tx.send(deno_runtime::ops::desktop::DesktopEvent::AppMenuClick { + window_id, + id: id.to_string(), + }); }); } @@ -363,12 +362,11 @@ impl denort::desktop::DesktopApi for WefDesktopApi { y, &menu, move |id: &str| { - let _ = tx.send( - deno_runtime::ops::desktop::DesktopEvent::ContextMenuClick { + let _ = + tx.send(deno_runtime::ops::desktop::DesktopEvent::ContextMenuClick { window_id, id: id.to_string(), - }, - ); + }); }, ); } @@ -388,13 +386,10 @@ impl denort::desktop::DesktopApi for WefDesktopApi { match handle_type { wef::WEF_WINDOW_HANDLE_APPKIT => { use raw_window_handle::*; - let win = RawWindowHandle::AppKit( - AppKitWindowHandle::new( - std::ptr::NonNull::new(raw_win).expect("null window handle"), - ), - ); - let display = - RawDisplayHandle::AppKit(AppKitDisplayHandle::new()); + let win = RawWindowHandle::AppKit(AppKitWindowHandle::new( + std::ptr::NonNull::new(raw_win).expect("null window handle"), + )); + let display = RawDisplayHandle::AppKit(AppKitDisplayHandle::new()); (win, display) } wef::WEF_WINDOW_HANDLE_WIN32 => { @@ -403,39 +398,29 @@ impl denort::desktop::DesktopApi for WefDesktopApi { std::num::NonZeroIsize::new(raw_win as isize) .expect("null window handle"), ); - handle.hinstance = std::num::NonZeroIsize::new(raw_display as isize).map(|v| v.into()); + handle.hinstance = + std::num::NonZeroIsize::new(raw_display as isize).map(|v| v.into()); let win = RawWindowHandle::Win32(handle); - let display = RawDisplayHandle::Windows( - WindowsDisplayHandle::new(), - ); + let display = RawDisplayHandle::Windows(WindowsDisplayHandle::new()); (win, display) } wef::WEF_WINDOW_HANDLE_X11 => { use raw_window_handle::*; - let win = RawWindowHandle::Xlib( - XlibWindowHandle::new(raw_win as _), - ); - let display = RawDisplayHandle::Xlib( - XlibDisplayHandle::new( - std::ptr::NonNull::new(raw_display), - 0, - ), - ); + let win = RawWindowHandle::Xlib(XlibWindowHandle::new(raw_win as _)); + let display = RawDisplayHandle::Xlib(XlibDisplayHandle::new( + std::ptr::NonNull::new(raw_display), + 0, + )); (win, display) } wef::WEF_WINDOW_HANDLE_WAYLAND => { use raw_window_handle::*; - let win = RawWindowHandle::Wayland( - WaylandWindowHandle::new( - std::ptr::NonNull::new(raw_win).expect("null window handle"), - ), - ); - let display = RawDisplayHandle::Wayland( - WaylandDisplayHandle::new( - std::ptr::NonNull::new(raw_display) - .expect("null display handle"), - ), - ); + let win = RawWindowHandle::Wayland(WaylandWindowHandle::new( + std::ptr::NonNull::new(raw_win).expect("null window handle"), + )); + let display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new( + std::ptr::NonNull::new(raw_display).expect("null display handle"), + )); (win, display) } _ => panic!("unknown window handle type: {handle_type}"), @@ -761,20 +746,19 @@ wef::main!(|| { use deno_runtime::ops::desktop::send_error_report; if let Some((url, app_version)) = error_report_config() { - let message = - if let Some(s) = panic_info.payload().downcast_ref::<&str>() { - (*s).to_string() - } else if let Some(s) = - panic_info.payload().downcast_ref::() - { - s.clone() - } else { - "Deno runtime panicked".to_string() - }; - - let location = panic_info.location().map(|l| { - format!("at {}:{}:{}", l.file(), l.line(), l.column()) - }); + let message = if let Some(s) = + panic_info.payload().downcast_ref::<&str>() + { + (*s).to_string() + } else if let Some(s) = panic_info.payload().downcast_ref::() { + s.clone() + } else { + "Deno runtime panicked".to_string() + }; + + let location = panic_info + .location() + .map(|l| format!("at {}:{}:{}", l.file(), l.line(), l.column())); let body = deno_core::serde_json::json!({ "version": 1, @@ -787,15 +771,13 @@ wef::main!(|| { send_error_report(url, &body.to_string()); } - eprintln!("\n============================================================"); + eprintln!( + "\n============================================================" + ); eprintln!("Deno has panicked. This is a bug in Deno. Please report this"); eprintln!("at https://github.com/denoland/deno/issues/new."); eprintln!(); - eprintln!( - "Platform: {} {}", - env::consts::OS, - env::consts::ARCH - ); + eprintln!("Platform: {} {}", env::consts::OS, env::consts::ARCH); eprintln!(); orig_hook(panic_info); @@ -1000,16 +982,14 @@ fn wef_value_to_desktop_value( wef::Value::Int(i) => DesktopValue::Int(i), wef::Value::Double(d) => DesktopValue::Double(d), wef::Value::String(s) => DesktopValue::String(s), - wef::Value::List(l) => { - DesktopValue::List(l.into_iter().map(wef_value_to_desktop_value).collect()) - } - wef::Value::Dict(d) => { - DesktopValue::Dict( - d.into_iter() - .map(|(k, v)| (k, wef_value_to_desktop_value(v))) - .collect(), - ) - } + wef::Value::List(l) => DesktopValue::List( + l.into_iter().map(wef_value_to_desktop_value).collect(), + ), + wef::Value::Dict(d) => DesktopValue::Dict( + d.into_iter() + .map(|(k, v)| (k, wef_value_to_desktop_value(v))) + .collect(), + ), wef::Value::Binary(b) => DesktopValue::Binary(b), } } diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 449d9277017552..aa427f6649529c 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -47,9 +47,7 @@ impl<'a> ToV8<'a> for DesktopValue { DesktopValue::Bool(b) => v8::Boolean::new(scope, b).into(), DesktopValue::Int(i) => v8::Integer::new(scope, i).into(), DesktopValue::Double(d) => v8::Number::new(scope, d).into(), - DesktopValue::String(s) => { - v8::String::new(scope, &s).unwrap().into() - } + DesktopValue::String(s) => v8::String::new(scope, &s).unwrap().into(), DesktopValue::List(l) => { let arr = v8::Array::new(scope, l.len() as i32); for (i, v) in l.into_iter().enumerate() { @@ -71,8 +69,7 @@ impl<'a> ToV8<'a> for DesktopValue { DesktopValue::Binary(b) => { let len = b.len(); let store = v8::ArrayBuffer::new_backing_store_from_vec(b); - let ab = - v8::ArrayBuffer::with_backing_store(scope, &store.into()); + let ab = v8::ArrayBuffer::with_backing_store(scope, &store.into()); v8::Uint8Array::new(scope, ab, 0, len).unwrap().into() } }) @@ -113,15 +110,9 @@ impl<'a> ToV8<'a> for ExecuteJsResult { #[serde(tag = "kind", rename_all = "camelCase")] pub enum DesktopEvent { #[serde(rename_all = "camelCase")] - AppMenuClick { - window_id: u32, - id: String, - }, + AppMenuClick { window_id: u32, id: String }, #[serde(rename_all = "camelCase")] - ContextMenuClick { - window_id: u32, - id: String, - }, + ContextMenuClick { window_id: u32, id: String }, #[serde(rename_all = "camelCase")] KeyboardEvent { window_id: u32, @@ -189,10 +180,7 @@ pub enum DesktopEvent { meta: bool, }, #[serde(rename_all = "camelCase")] - FocusChanged { - window_id: u32, - focused: bool, - }, + FocusChanged { window_id: u32, focused: bool }, #[serde(rename_all = "camelCase")] WindowResize { window_id: u32, @@ -200,15 +188,9 @@ pub enum DesktopEvent { height: i32, }, #[serde(rename_all = "camelCase")] - WindowMove { - window_id: u32, - x: i32, - y: i32, - }, + WindowMove { window_id: u32, x: i32, y: i32 }, #[serde(rename_all = "camelCase")] - CloseRequested { - window_id: u32, - }, + CloseRequested { window_id: u32 }, #[serde(rename_all = "camelCase")] RuntimeError { message: String, @@ -570,9 +552,7 @@ impl BrowserWindow { #[smi] y: i32, #[serde] menu: Vec, ) { - self - .api - .show_context_menu(self.window_id, x, y, menu); + self.api.show_context_menu(self.window_id, x, y, menu); } fn get_native_window( @@ -593,8 +573,7 @@ impl BrowserWindow { let window_id = self.window_id; Ok(self.surface.get(scope, move |_| { - let (win_handle, display_handle) = - api.get_raw_window_handle(window_id); + let (win_handle, display_handle) = api.get_raw_window_handle(window_id); // SAFETY: The raw handles are valid for the lifetime of the window, // which is guaranteed by the BrowserWindow preventing the window from @@ -797,10 +776,7 @@ static ERROR_REPORT_CONFIG: OnceLock = OnceLock::new(); /// Store the error reporting URL and app version so the panic hook can /// send reports without access to OpState. -pub fn set_error_report_config( - url: String, - app_version: Option, -) { +pub fn set_error_report_config(url: String, app_version: Option) { let _ = ERROR_REPORT_CONFIG.set(ErrorReportConfig { url, app_version }); } @@ -851,10 +827,9 @@ pub fn send_error_report(url: &str, body: &str) { return; }; runtime.block_on(async move { - let Ok(client) = deno_fetch::create_http_client( - "deno-desktop", - Default::default(), - ) else { + let Ok(client) = + deno_fetch::create_http_client("deno-desktop", Default::default()) + else { return; }; let Ok(uri) = url_str.parse::() else { @@ -888,9 +863,7 @@ fn op_desktop_send_error_report( // For HTTP(S) URLs, prefer the client from OpState (has user's TLS config). if let Ok(ref parsed) = parsed { if matches!(parsed.scheme(), "http" | "https") { - if let Ok(client) = - deno_fetch::get_or_create_client_from_state(state) - { + if let Ok(client) = deno_fetch::get_or_create_client_from_state(state) { let url_str = parsed.to_string(); let body = body.to_string(); let _ = std::thread::spawn(move || { From 13182609513a6fcd0728f0d987f3cb1af50cc9c2 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 2 Apr 2026 15:48:01 +0200 Subject: [PATCH 027/137] fixes, add feature test --- cli/args/flags.rs | 18 +- cli/rt/desktop.rs | 297 +++++++------ cli/rt_desktop/lib.rs | 1 - cli/tools/desktop.rs | 202 ++++++++- feature_test.ts | 838 +++++++++++++++++++++++++++++++++++ libs/config/deno_json/mod.rs | 69 ++- runtime/ops/desktop.rs | 20 +- 7 files changed, 1260 insertions(+), 185 deletions(-) create mode 100644 feature_test.ts diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 72b26c49368e7e..897b4c644e5a5a 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -177,13 +177,27 @@ pub struct CompileFlags { pub self_extracting: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IconSetEntry { + pub path: String, + pub size: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IconConfig { + /// A single icon file (`.icns`, `.ico`, or `.png`). + Single(String), + /// Multiple PNGs at specific pixel sizes. + Set(Vec), +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct DesktopFlags { pub source_file: String, pub output: Option, pub args: Vec, pub target: Option, - pub icon: Option, + pub icon: Option, pub include: Vec, pub exclude: Vec, pub hmr: bool, @@ -6358,7 +6372,7 @@ fn desktop_parse( output, args, target, - icon, + icon: icon.map(IconConfig::Single), include, exclude, hmr, diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 574f496ab39fad..7a93c2b0cd8e8c 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -251,158 +251,165 @@ pub const DESKTOP_JS: &str = r#" // Single polling loop for all native desktop events. (async () => { while (true) { - const p = op_desktop_recv_event(); - unrefOpPromise(p); - const ev = await p; - if (ev == null) break; - switch (ev.kind) { - case "appMenuClick": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new CustomEvent("menuclick", { detail: { id: ev.id } })); - break; - } - case "contextMenuClick": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new CustomEvent("contextmenuclick", { detail: { id: ev.id } })); - break; - } - case "keyboardEvent": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new KeyboardEvent(ev.type, { - key: ev.key, - code: ev.code, - shiftKey: ev.shift, - ctrlKey: ev.control, - altKey: ev.alt, - metaKey: ev.meta, - repeat: ev.repeat, - })); - break; - } - case "bindCall": { - const callbacks = windowBindCallbacks.get(ev.windowId); - const fn_ = callbacks?.get(ev.name); - if (!fn_) { - op_desktop_reject_bind_call(ev.callId, "No callback bound for: " + ev.name); + try { + const p = op_desktop_recv_event(); + unrefOpPromise(p); + const ev = await p; + if (ev == null) break; + switch (ev.kind) { + case "appMenuClick": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("menuclick", { detail: { id: ev.id } })); break; } - try { - const args = Array.isArray(ev.args) ? ev.args : []; - const result = await fn_(...args); - op_desktop_resolve_bind_call(ev.callId, result ?? null); - } catch (e) { - op_desktop_reject_bind_call(ev.callId, String(e)); + case "contextMenuClick": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("contextmenuclick", { detail: { id: ev.id } })); + break; } - break; - } - case "mouseClick": { - const target = windows.get(ev.windowId); - if (!target) break; - const init = { - button: ev.button, - clientX: ev.clientX, - clientY: ev.clientY, - ctrlKey: ev.control, - shiftKey: ev.shift, - altKey: ev.alt, - metaKey: ev.meta, - detail: ev.clickCount, - }; - if (ev.state === "pressed") { - target.dispatchEvent(new MouseEvent("mousedown", init)); - } else { - target.dispatchEvent(new MouseEvent("mouseup", init)); - if (ev.button === 0) { - target.dispatchEvent(new MouseEvent("click", init)); - if (ev.clickCount >= 2) { - target.dispatchEvent(new MouseEvent("dblclick", init)); + case "keyboardEvent": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new KeyboardEvent(ev.type, { + key: ev.key, + code: ev.code, + shiftKey: ev.shift, + ctrlKey: ev.control, + altKey: ev.alt, + metaKey: ev.meta, + repeat: ev.repeat, + })); + break; + } + case "bindCall": { + const callbacks = windowBindCallbacks.get(ev.windowId); + const fn_ = callbacks?.get(ev.name); + if (!fn_) { + op_desktop_reject_bind_call(ev.callId, "No callback bound for: " + ev.name); + break; + } + // Run async so it doesn't block the event loop + (async () => { + try { + const args = Array.isArray(ev.args) ? ev.args : []; + const result = await fn_(...args); + op_desktop_resolve_bind_call(ev.callId, result ?? null); + } catch (e) { + op_desktop_reject_bind_call(ev.callId, String(e)); + } + })(); + break; + } + case "mouseClick": { + const target = windows.get(ev.windowId); + if (!target) break; + const init = { + button: ev.button, + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + detail: ev.clickCount, + }; + if (ev.state === "pressed") { + target.dispatchEvent(new MouseEvent("mousedown", init)); + } else { + target.dispatchEvent(new MouseEvent("mouseup", init)); + if (ev.button === 0) { + target.dispatchEvent(new MouseEvent("click", init)); + if (ev.clickCount >= 2) { + target.dispatchEvent(new MouseEvent("dblclick", init)); + } } } + break; + } + case "mouseMove": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new MouseEvent("mousemove", { + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + })); + break; + } + case "wheel": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new WheelEvent("wheel", { + deltaX: ev.deltaX, + deltaY: ev.deltaY, + deltaMode: ev.deltaMode, + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + })); + break; + } + case "cursorEnterLeave": { + const target = windows.get(ev.windowId); + if (!target) break; + const init = { + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + }; + target.dispatchEvent(new MouseEvent( + ev.entered ? "mouseenter" : "mouseleave", init)); + break; + } + case "focusChanged": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new FocusEvent(ev.focused ? "focus" : "blur")); + break; + } + case "windowResize": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("resize", { + detail: { width: ev.width, height: ev.height }, + })); + break; + } + case "windowMove": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("move", { + detail: { x: ev.x, y: ev.y }, + })); + break; + } + case "closeRequested": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new Event("close")); + break; + } + case "runtimeError": { + dispatchEvent(new ErrorEvent("error", { + message: ev.message, + error: new Error(ev.message), + })); + break; } - break; - } - case "mouseMove": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new MouseEvent("mousemove", { - clientX: ev.clientX, - clientY: ev.clientY, - ctrlKey: ev.control, - shiftKey: ev.shift, - altKey: ev.alt, - metaKey: ev.meta, - })); - break; - } - case "wheel": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new WheelEvent("wheel", { - deltaX: ev.deltaX, - deltaY: ev.deltaY, - deltaMode: ev.deltaMode, - clientX: ev.clientX, - clientY: ev.clientY, - ctrlKey: ev.control, - shiftKey: ev.shift, - altKey: ev.alt, - metaKey: ev.meta, - })); - break; - } - case "cursorEnterLeave": { - const target = windows.get(ev.windowId); - if (!target) break; - const init = { - clientX: ev.clientX, - clientY: ev.clientY, - ctrlKey: ev.control, - shiftKey: ev.shift, - altKey: ev.alt, - metaKey: ev.meta, - }; - target.dispatchEvent(new MouseEvent( - ev.entered ? "mouseenter" : "mouseleave", init)); - break; - } - case "focusChanged": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new FocusEvent(ev.focused ? "focus" : "blur")); - break; - } - case "windowResize": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new CustomEvent("resize", { - detail: { width: ev.width, height: ev.height }, - })); - break; - } - case "windowMove": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new CustomEvent("move", { - detail: { x: ev.x, y: ev.y }, - })); - break; - } - case "closeRequested": { - const target = windows.get(ev.windowId); - if (!target) break; - target.dispatchEvent(new Event("close")); - break; - } - case "runtimeError": { - dispatchEvent(new ErrorEvent("error", { - message: ev.message, - error: new Error(ev.message), - })); - break; } + } catch (e) { + console.error("Desktop event loop error:", e?.stack ?? e); } } })(); diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 5c232e6edfbc5d..77b54b2e93358d 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -1169,7 +1169,6 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let (event_tx, event_rx) = denort::desktop::create_desktop_event_channel(); let pending_responses = denort::desktop::PendingBindResponses::new(); - let api = WefDesktopApi { event_tx: event_tx.0.clone(), pending_responses: pending_responses.clone(), diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 58e95371753c6c..db8e371ec59713 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -44,13 +44,26 @@ pub async fn desktop( if let Some(icons) = app_config.icons && desktop_flags.icon.is_none() { - desktop_flags.icon = if cfg!(target_os = "macos") { + use deno_config::deno_json::DesktopIconValue; + let platform_icon = if cfg!(target_os = "macos") { icons.macos } else if cfg!(target_os = "windows") { icons.windows } else { icons.linux }; + desktop_flags.icon = platform_icon.map(|v| match v { + DesktopIconValue::Single(s) => crate::args::IconConfig::Single(s), + DesktopIconValue::Set(entries) => crate::args::IconConfig::Set( + entries + .into_iter() + .map(|e| crate::args::IconSetEntry { + path: e.path, + size: e.size, + }) + .collect(), + ), + }); } if let Some(name) = app_config.name @@ -157,7 +170,10 @@ async fn compile_desktop( args: desktop_flags.args.clone(), target: desktop_flags.target.clone(), no_terminal: false, - icon: desktop_flags.icon.clone(), + icon: match &desktop_flags.icon { + Some(crate::args::IconConfig::Single(s)) => Some(s.clone()), + _ => None, + }, include: desktop_flags.include.clone(), exclude: desktop_flags.exclude.clone(), eszip: false, @@ -596,25 +612,41 @@ fn package_macos_app_bundle( // Handle icon. if let Some(ref icon) = desktop_flags.icon { - let icon_path = cli_options.initial_cwd().join(icon); - if icon_path.exists() { - let dest = resources_dir.join("AppIcon.icns"); - match icon_path.extension().and_then(|e| e.to_str()) { - Some("icns") => { - std::fs::copy(&icon_path, &dest)?; - } - Some("png") => { - crate::tools::compile::convert_png_to_icns(&icon_path, &dest)?; - } - _ => { + let dest = resources_dir.join("AppIcon.icns"); + match icon { + crate::args::IconConfig::Single(path) => { + let icon_path = cli_options.initial_cwd().join(path); + if icon_path.exists() { + match icon_path.extension().and_then(|e| e.to_str()) { + Some("icns") => { + std::fs::copy(&icon_path, &dest)?; + } + Some("png") => { + crate::tools::compile::convert_png_to_icns( + &icon_path, &dest, + )?; + } + _ => { + log::warn!( + "Icon '{}' is not .icns or .png, skipping", + icon_path.display() + ); + } + } + } else { log::warn!( - "Icon '{}' is not .icns or .png, skipping", + "Icon '{}' not found, skipping", icon_path.display() ); } } - } else { - log::warn!("Icon '{}' not found, skipping", icon_path.display()); + crate::args::IconConfig::Set(entries) => { + convert_icon_set_to_icns( + cli_options.initial_cwd(), + entries, + &dest, + )?; + } } } @@ -700,3 +732,141 @@ fn find_wef_backend(backend: &str) -> Result { WEF_BACKEND_ENV ) } + +/// Build a macOS `.icns` from an icon set (multiple PNGs at specified sizes). +/// +/// Maps each provided size to the correct `.iconset` filename. The standard +/// macOS iconset uses 1x and 2x variants: +/// 16px → icon_16x16.png +/// 32px → icon_16x16@2x.png AND icon_32x32.png +/// 64px → icon_32x32@2x.png +/// 128px → icon_128x128.png +/// 256px → icon_128x128@2x.png AND icon_256x256.png +/// 512px → icon_256x256@2x.png AND icon_512x512.png +/// 1024px→ icon_512x512@2x.png +fn convert_icon_set_to_icns( + cwd: &Path, + entries: &[crate::args::IconSetEntry], + icns_path: &Path, +) -> Result<(), deno_core::error::AnyError> { + let iconset_dir = icns_path.with_extension("iconset"); + std::fs::create_dir_all(&iconset_dir)?; + + for entry in entries { + let src = cwd.join(&entry.path); + if !src.exists() { + log::warn!("Icon '{}' not found, skipping", src.display()); + continue; + } + + let names = iconset_names_for_size(entry.size); + for name in names { + std::fs::copy(&src, iconset_dir.join(name))?; + } + } + + let status = std::process::Command::new("iconutil") + .args([ + "-c", + "icns", + &iconset_dir.display().to_string(), + "-o", + &icns_path.display().to_string(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + + let _ = std::fs::remove_dir_all(&iconset_dir); + + if !status.success() { + deno_core::anyhow::bail!( + "iconutil failed to create .icns from icon set" + ); + } + + Ok(()) +} + +/// Returns the `.iconset` filenames a given pixel size maps to. +fn iconset_names_for_size(size: u32) -> Vec<&'static str> { + match size { + 16 => vec!["icon_16x16.png"], + 32 => vec!["icon_16x16@2x.png", "icon_32x32.png"], + 64 => vec!["icon_32x32@2x.png"], + 128 => vec!["icon_128x128.png"], + 256 => vec!["icon_128x128@2x.png", "icon_256x256.png"], + 512 => vec!["icon_256x256@2x.png", "icon_512x512.png"], + 1024 => vec!["icon_512x512@2x.png"], + _ => { + log::warn!( + "Icon size {}px doesn't map to a standard macOS iconset slot, skipping", + size + ); + vec![] + } + } +} + +/// Build a Windows `.ico` from an icon set (multiple PNGs at specified sizes). +/// +/// The ICO format stores PNG images directly (Vista+ supports PNG-compressed +/// entries). We write the ICO header, one directory entry per image, then the +/// raw PNG data for each. +pub fn convert_icon_set_to_ico( + cwd: &Path, + entries: &[crate::args::IconSetEntry], + ico_path: &Path, +) -> Result<(), deno_core::error::AnyError> { + use std::io::Write; + + let mut images: Vec<(u32, Vec)> = Vec::new(); + for entry in entries { + let src = cwd.join(&entry.path); + if !src.exists() { + log::warn!("Icon '{}' not found, skipping", src.display()); + continue; + } + let data = std::fs::read(&src)?; + images.push((entry.size, data)); + } + + if images.is_empty() { + deno_core::anyhow::bail!("No valid icon images found for .ico"); + } + + let count = images.len() as u16; + // ICO header: 6 bytes + // Each directory entry: 16 bytes + let header_size = 6 + (count as u32) * 16; + + let mut buf = Vec::new(); + // ICO header + buf.write_all(&0u16.to_le_bytes())?; // reserved + buf.write_all(&1u16.to_le_bytes())?; // type: 1 = ICO + buf.write_all(&count.to_le_bytes())?; // image count + + // Directory entries + let mut data_offset = header_size; + for (size, data) in &images { + // Width/height: 0 means 256 in ICO format + let dim = if *size >= 256 { 0u8 } else { *size as u8 }; + buf.push(dim); // width + buf.push(dim); // height + buf.push(0); // color palette count + buf.push(0); // reserved + buf.write_all(&1u16.to_le_bytes())?; // color planes + buf.write_all(&32u16.to_le_bytes())?; // bits per pixel + buf.write_all(&(data.len() as u32).to_le_bytes())?; // image data size + buf.write_all(&data_offset.to_le_bytes())?; // offset to image data + data_offset += data.len() as u32; + } + + // Image data + for (_, data) in &images { + buf.write_all(data)?; + } + + std::fs::write(ico_path, &buf)?; + Ok(()) +} diff --git a/feature_test.ts b/feature_test.ts new file mode 100644 index 00000000000000..faadceb7619877 --- /dev/null +++ b/feature_test.ts @@ -0,0 +1,838 @@ +// Comprehensive desktop feature test — exercises every BrowserWindow API. + +// ── State ────────────────────────────────────────────────────────────────── + +const testResults = new Map< + string, + { pass: boolean; detail: string } +>(); +const eventLog: Record[] = []; +let mouseMoveCount = 0; +let eventSeq = 0; + +function record(name: string, pass: boolean, detail = "") { + testResults.set(name, { pass, detail }); +} + +function pushEvent(entry: Record) { + entry.ts = Date.now(); + entry.seq = ++eventSeq; + eventLog.push(entry); + if (eventLog.length > 200) eventLog.shift(); +} + +// ── Window ───────────────────────────────────────────────────────────────── + +const win = new Deno.BrowserWindow({ + title: "Desktop Feature Test", + width: 1100, + height: 800, + x: 50, + y: 50, + resizable: true, + alwaysOnTop: false, +}); + +// ── Auto Tests (sync properties) ─────────────────────────────────────────── + +function runAutoTests() { + // windowId + try { + const id = win.windowId; + record("windowId", typeof id === "number" && id >= 0, `${id}`); + } catch (e) { + record("windowId", false, String(e)); + } + + // isResizable / setResizable + try { + const before = win.isResizable(); + win.setResizable(false); + const after = win.isResizable(); + win.setResizable(true); + record( + "resizable", + before === true && after === false, + `before=${before} after=${after}`, + ); + } catch (e) { + record("resizable", false, String(e)); + } + + // isAlwaysOnTop / setAlwaysOnTop + try { + const before = win.isAlwaysOnTop(); + win.setAlwaysOnTop(true); + const after = win.isAlwaysOnTop(); + win.setAlwaysOnTop(false); + record( + "alwaysOnTop", + before === false && after === true, + `before=${before} after=${after}`, + ); + } catch (e) { + record("alwaysOnTop", false, String(e)); + } + + // isVisible / hide / show + try { + const before = win.isVisible(); + win.hide(); + const hidden = win.isVisible(); + win.show(); + record( + "visibility", + before === true && hidden === false, + `before=${before} hidden=${hidden}`, + ); + } catch (e) { + record("visibility", false, String(e)); + } + + // setTitle + try { + win.setTitle("Test Title Changed"); + win.setTitle("Desktop Feature Test"); + record("setTitle", true, "no crash"); + } catch (e) { + record("setTitle", false, String(e)); + } + + // getSize (known WEF bug: may return [0,0]) + try { + const size = win.getSize(); + const isArr = Array.isArray(size) && size.length === 2; + record( + "getSize", + isArr, + `[${size}]${size[0] === 0 ? " (WEF cross-thread bug)" : ""}`, + ); + } catch (e) { + record("getSize", false, String(e)); + } + + // setSize + try { + win.setSize(1100, 800); + record("setSize", true, "no crash"); + } catch (e) { + record("setSize", false, String(e)); + } + + // getPosition (known WEF bug: may return [0,0]) + try { + const pos = win.getPosition(); + const isArr = Array.isArray(pos) && pos.length === 2; + record( + "getPosition", + isArr, + `[${pos}]${pos[0] === 0 ? " (WEF cross-thread bug)" : ""}`, + ); + } catch (e) { + record("getPosition", false, String(e)); + } + + // setPosition + try { + win.setPosition(50, 50); + record("setPosition", true, "no crash"); + } catch (e) { + record("setPosition", false, String(e)); + } + + // focus + try { + win.focus(); + record("focus", true, "no crash"); + } catch (e) { + record("focus", false, String(e)); + } + + /*// openDevtools + try { + win.openDevtools(); + record("openDevtools", true, "no crash"); + } catch (e) { + record("openDevtools", false, String(e)); + }*/ +} + +runAutoTests(); + +// ── Async Tests (executeJs) ──────────────────────────────────────────────── + +async function runAsyncTests() { + // executeJs: simple arithmetic + try { + const r = await win.executeJs("1 + 1"); + // r is { ok: boolean, value: any } + const ok = (r as any).ok === true && (r as any).value === 2; + record("executeJs:arithmetic", ok, JSON.stringify(r)); + } catch (e) { + record("executeJs:arithmetic", false, String(e)); + } + + // executeJs: error + try { + const r = await win.executeJs("throw new Error('test error')"); + const ok = (r as any).ok === false; + record("executeJs:error", ok, JSON.stringify(r)); + } catch (e) { + // if it throws instead of returning { ok: false }, that's also valid + record("executeJs:error", true, `threw: ${e}`); + } + + // executeJs: complex value + try { + const r = await win.executeJs("({a: 1, b: [2, 3]})"); + const v = (r as any).ok !== undefined ? (r as any).value : r; + const ok = + v && typeof v === "object" && v.a === 1 && Array.isArray(v.b); + record("executeJs:complex", ok, JSON.stringify(r)); + } catch (e) { + record("executeJs:complex", false, String(e)); + } + + // executeJs: string value + try { + const r = await win.executeJs("document.title"); + const v = (r as any).ok !== undefined ? (r as any).value : r; + record("executeJs:string", typeof v === "string", JSON.stringify(r)); + } catch (e) { + record("executeJs:string", false, String(e)); + } +} + +// Run after a short delay to let the page load +setTimeout(() => runAsyncTests(), 1500); + +// ── Bindings ─────────────────────────────────────────────────────────────── + +// deno-lint-ignore no-explicit-any +win.bind("echo", async (...args: any[]) => { + return args.length === 1 ? args[0] : args; +}); + +// deno-lint-ignore no-explicit-any +win.bind("add", async (a: any, b: any) => { + return (a as number) + (b as number); +}); + +win.bind("getTestResults", async () => { + return Object.fromEntries(testResults); +}); + +win.bind("getEventLog", async () => { + return eventLog.slice(-100); +}); + +win.bind("triggerAutoTests", async () => { + runAutoTests(); + await runAsyncTests(); + return Object.fromEntries(testResults); +}); + +// Context menu items +const contextMenuItems: Deno.MenuItem[] = [ + { item: { label: "Option A", id: "ctx-a", enabled: true } }, + { item: { label: "Option B", id: "ctx-b", enabled: true } }, + { separator: null } as unknown as Deno.MenuItem, + { + submenu: { + label: "More", + items: [ + { item: { label: "Option C", id: "ctx-c", enabled: true } }, + { item: { label: "Option D", id: "ctx-d", enabled: true } }, + ], + }, + }, +]; + +// deno-lint-ignore no-explicit-any +win.bind("triggerContextMenu", async (x: any, y: any) => { + win.showContextMenu(x as number, y as number, contextMenuItems); + return null; +}); + +win.bind("showAlert", async (msg: unknown) => { + alert(String(msg)); + return "done"; +}); + +win.bind("showConfirm", async (msg: unknown) => { + return confirm(String(msg)); +}); + +win.bind("showPrompt", async (msg: unknown, def: unknown) => { + return prompt(String(msg), def != null ? String(def) : undefined); +}); + +// ── Event Listeners ──────────────────────────────────────────────────────── + +for (const type of ["keydown", "keyup"] as const) { + win.addEventListener(type, (e) => { + pushEvent({ + type, + key: e.key, + code: e.code, + ctrl: e.ctrlKey, + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey, + repeat: e.repeat, + }); + }); +} + +for (const type of ["mousedown", "mouseup", "click", "dblclick"] as const) { + win.addEventListener(type, (e) => { + pushEvent({ + type, + button: e.button, + x: e.clientX, + y: e.clientY, + }); + }); +} + +win.addEventListener("mousemove", (e) => { + mouseMoveCount++; + if (mouseMoveCount % 20 === 0) { + pushEvent({ type: "mousemove", x: e.clientX, y: e.clientY }); + } +}); + +for (const type of ["mouseenter", "mouseleave"] as const) { + win.addEventListener(type, () => { + pushEvent({ type }); + }); +} + +win.addEventListener("wheel", (e) => { + pushEvent({ + type: "wheel", + deltaX: e.deltaX, + deltaY: e.deltaY, + deltaMode: e.deltaMode, + }); +}); + +for (const type of ["focus", "blur"] as const) { + win.addEventListener(type, () => { + pushEvent({ type }); + }); +} + +win.addEventListener("resize", (e: CustomEvent) => { + pushEvent({ + type: "resize", + width: e.detail.width, + height: e.detail.height, + }); +}); + +win.addEventListener("move", (e: CustomEvent) => { + pushEvent({ type: "move", x: e.detail.x, y: e.detail.y }); +}); + +win.addEventListener("menuclick", (e: CustomEvent) => { + pushEvent({ type: "menuclick", id: e.detail.id }); +}); + +win.addEventListener("contextmenuclick", (e: CustomEvent) => { + pushEvent({ type: "contextmenuclick", id: e.detail.id }); +}); + +win.addEventListener("close", () => { + pushEvent({ type: "close" }); +}); + +// ── Application Menu ─────────────────────────────────────────────────────── + +win.setApplicationMenu([ + // On macOS the first submenu becomes the application menu (label + // is replaced with the app name). Put standard app roles here. + { + submenu: { + label: "App", + items: [ + { role: { role: "quit" } }, + ], + }, + }, + { + submenu: { + label: "File", + items: [ + { + item: { + label: "Test Action", + id: "test-action", + accelerator: "CmdOrCtrl+T", + enabled: true, + }, + }, + ], + }, + }, + { + submenu: { + label: "Edit", + items: [ + { role: { role: "copy" } }, + { role: { role: "paste" } }, + { role: { role: "cut" } }, + ], + }, + }, + { + submenu: { + label: "Test", + items: [ + { + item: { + label: "Action 1", + id: "action-1", + enabled: true, + }, + }, + { + item: { + label: "Action 2", + id: "action-2", + enabled: true, + }, + }, + { separator: null } as unknown as Deno.MenuItem, + { + item: { + label: "Disabled Item", + id: "disabled", + enabled: false, + }, + }, + ], + }, + }, +]); + +// ── HTML Dashboard ───────────────────────────────────────────────────────── + +const html = ` + + + +Desktop Feature Test + + + +
+

Desktop Feature Test

+
+ + +
+
+ +
+ +
+

Window Properties

+
Loading...
+
+ + +
+

executeJs

+
Loading...
+
+ + +
+

Bindings Roundtrip

+
Running...
+
+ + +
+

App Menu 0

+

Click menu items: File > Test Action, Test > Action 1/2

+ +
+ + +
+

Keyboard 0

+

Press any key

+
Waiting for key events...
+
+ + +
+

Mouse 0

+
Click / double-click here
+
Waiting for mouse events...
+
+ + +
+

Wheel 0

+

Scroll anywhere in the window

+
Waiting for wheel events...
+
+ + +
+

Focus / Blur 0

+

Click outside the window, then back

+
Waiting for focus events...
+
+ + +
+

Resize / Move 0

+

Resize or drag the window

+
Waiting for resize/move events...
+
+ + +
+

Dialogs

+
+ + + +
+
Click a button above
+
+ + +
+

Context Menu 0

+
Right-click here
+
Waiting for context menu clicks...
+
+ + +
+

Close Event 0

+

Close button click is intercepted (preventDefault)

+
Try closing the window (it will be prevented)
+
+
+ + + + + +`; + +// ── HTTP Server ──────────────────────────────────────────────────────────── + +Deno.serve((req: Request) => { + const url = new URL(req.url); + if (url.pathname === "/api/results") { + return Response.json(Object.fromEntries(testResults)); + } + if (url.pathname === "/api/events") { + return Response.json(eventLog.slice(-100)); + } + return new Response(html, { + headers: { "content-type": "text/html" }, + }); +}); diff --git a/libs/config/deno_json/mod.rs b/libs/config/deno_json/mod.rs index 1a25fbc350478f..d5fa74d4cef459 100644 --- a/libs/config/deno_json/mod.rs +++ b/libs/config/deno_json/mod.rs @@ -770,12 +770,25 @@ pub struct CompileConfig { pub permissions: Option>, } +#[derive(Clone, Debug, Deserialize, PartialEq)] +struct SerializedDesktopIconEntry { + pub path: String, + pub size: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum SerializedDesktopIconValue { + Single(String), + Set(Vec), +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] struct SerializedDesktopIconsConfig { - pub macos: Option, - pub windows: Option, - pub linux: Option, + pub macos: Option, + pub windows: Option, + pub linux: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] @@ -822,10 +835,32 @@ impl SerializedDesktopConfig { DesktopConfig { app: self.app.map(|a| DesktopAppConfig { name: a.name, - icons: a.icons.map(|i| DesktopIconsConfig { - macos: i.macos, - windows: i.windows, - linux: i.linux, + icons: a.icons.map(|i| { + fn resolve_icon_value( + v: SerializedDesktopIconValue, + ) -> DesktopIconValue { + match v { + SerializedDesktopIconValue::Single(s) => { + DesktopIconValue::Single(s) + } + SerializedDesktopIconValue::Set(entries) => { + DesktopIconValue::Set( + entries + .into_iter() + .map(|e| DesktopIconEntry { + path: e.path, + size: e.size, + }) + .collect(), + ) + } + } + } + DesktopIconsConfig { + macos: i.macos.map(resolve_icon_value), + windows: i.windows.map(resolve_icon_value), + linux: i.linux.map(resolve_icon_value), + } }), }), backend: self.backend, @@ -844,11 +879,25 @@ impl SerializedDesktopConfig { } } +#[derive(Clone, Debug, PartialEq)] +pub struct DesktopIconEntry { + pub path: String, + pub size: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum DesktopIconValue { + /// A single icon file (`.icns`, `.ico`, or `.png`). + Single(String), + /// Multiple PNGs at specific sizes. + Set(Vec), +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct DesktopIconsConfig { - pub macos: Option, - pub windows: Option, - pub linux: Option, + pub macos: Option, + pub windows: Option, + pub linux: Option, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index aa427f6649529c..89b66b052ee54d 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -199,7 +199,7 @@ pub enum DesktopEvent { } pub struct DesktopEventReceiver( - pub tokio::sync::mpsc::UnboundedReceiver, + pub Arc>>, ); pub struct DesktopEventSender( pub tokio::sync::mpsc::UnboundedSender, @@ -208,7 +208,7 @@ pub struct DesktopEventSender( pub fn create_desktop_event_channel() -> (DesktopEventSender, DesktopEventReceiver) { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - (DesktopEventSender(tx), DesktopEventReceiver(rx)) + (DesktopEventSender(tx), DesktopEventReceiver(Arc::new(tokio::sync::Mutex::new(rx)))) } /// A pending call from the webview to a bound Deno function. @@ -460,13 +460,13 @@ impl BrowserWindow { } #[fast] - fn is_resizeable(&self) -> bool { + fn is_resizable(&self) -> bool { self.api.is_resizable(self.window_id) } #[fast] - fn set_resizeable(&self, resizeable: bool) { - self.api.set_resizable(self.window_id, resizeable); + fn set_resizable(&self, resizable: bool) { + self.api.set_resizable(self.window_id, resizable); } #[fast] @@ -692,13 +692,11 @@ async fn op_desktop_recv_event( state: std::rc::Rc>, ) -> Option { let rx = { - let mut s = state.borrow_mut(); - s.try_take::() + let s = state.borrow(); + s.try_borrow::().map(|r| r.0.clone()) }; - if let Some(mut rx) = rx { - let result = rx.0.recv().await; - state.borrow_mut().put(rx); - result + if let Some(rx) = rx { + rx.lock().await.recv().await } else { std::future::pending().await } From 48926efd44bc5a342f9b5f2418188ff568317bb4 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 2 Apr 2026 22:09:56 +0200 Subject: [PATCH 028/137] fixes --- cli/tsc/dts/lib.deno.desktop.d.ts | 7 +------ feature_test.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 96bef3d3a80648..22463642acf2d0 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -206,11 +206,6 @@ declare namespace Deno { export interface BrowserWindow = WindowBindings> extends BrowserWindowEventHandlers {} - export interface UnsafeWindowSurface { - getContext(): GPUCanvasContext; - present(): void; - } - export class BrowserWindow< T extends ValidBindings = WindowBindings, > extends EventTarget { @@ -251,7 +246,7 @@ declare namespace Deno { setApplicationMenu(menu: MenuItem[]); showContextMenu(x: number, y: number, menu: MenuItem[]); - getNativeWindow(): UnsafeWindowSurface; + getNativeWindow(): Deno.UnsafeWindowSurface; addEventListener( type: K, diff --git a/feature_test.ts b/feature_test.ts index faadceb7619877..b464d22d4467dc 100644 --- a/feature_test.ts +++ b/feature_test.ts @@ -148,13 +148,13 @@ function runAutoTests() { record("focus", false, String(e)); } - /*// openDevtools + // openDevtools try { win.openDevtools(); record("openDevtools", true, "no crash"); } catch (e) { record("openDevtools", false, String(e)); - }*/ + } } runAutoTests(); @@ -254,6 +254,13 @@ win.bind("triggerContextMenu", async (x: any, y: any) => { return null; }); +// Handle right-click from Deno side (WEF may not forward contextmenu to webview) +win.addEventListener("mousedown", (e) => { + if (e.button === 2) { + win.showContextMenu(e.clientX, e.clientY, contextMenuItems); + } +}); + win.bind("showAlert", async (msg: unknown) => { alert(String(msg)); return "done"; From 0cd4037dd12cace89627757c9a09447a0bd791a0 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 3 Apr 2026 13:20:41 +0200 Subject: [PATCH 029/137] random port allocation --- cli/rt_desktop/lib.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 77b54b2e93358d..b663b55893ef73 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -31,8 +31,11 @@ use deno_terminal::colors; use denort::desktop::DesktopApi; use denort::run::RunOptions; -/// Port used for the embedded HTTP server. -const DESKTOP_SERVE_PORT: u16 = 41520; +/// Allocate a random available port by binding to port 0. +fn allocate_random_port() -> std::io::Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + Ok(listener.local_addr()?.port()) +} /// WEF-backed implementation of [`denort::desktop::DesktopApi`]. struct WefDesktopApi { @@ -1080,13 +1083,15 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { ); denort::load_env_vars(&data.metadata.env_vars_from_env_file); + let desktop_serve_port = allocate_random_port()?; + // Set DENO_SERVE_ADDRESS so Deno.serve() and Node http servers // automatically bind to the desktop port. #[allow(clippy::undocumented_unsafe_blocks)] unsafe { std::env::set_var( "DENO_SERVE_ADDRESS", - format!("tcp:127.0.0.1:{}", DESKTOP_SERVE_PORT), + format!("tcp:127.0.0.1:{}", desktop_serve_port), ); } @@ -1157,7 +1162,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let run_opts = RunOptions { auto_serve: true, - serve_port: Some(DESKTOP_SERVE_PORT), + serve_port: Some(desktop_serve_port), serve_host: Some("127.0.0.1".to_string()), hmr_watch_dir: if is_framework_dev { None @@ -1199,7 +1204,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { // Run the Deno runtime and WEF event loop concurrently. // We spawn the runtime first, wait for the server to be ready, // then navigate the webview. - let url = format!("http://127.0.0.1:{}", DESKTOP_SERVE_PORT); + let url = format!("http://127.0.0.1:{}", desktop_serve_port); eprintln!("[desktop] starting runtime and wef event loop"); let run_fut = denort::run::run_with_options(Arc::new(sys.clone()), sys, data, run_opts); @@ -1213,11 +1218,11 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { use tokio::io::AsyncWriteExt; for i in 0..60 { if let Ok(mut stream) = - tokio::net::TcpStream::connect(("127.0.0.1", DESKTOP_SERVE_PORT)).await + tokio::net::TcpStream::connect(("127.0.0.1", desktop_serve_port)).await { let req = format!( "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", - DESKTOP_SERVE_PORT + desktop_serve_port ); if stream.write_all(req.as_bytes()).await.is_ok() { let mut buf = vec![0u8; 256]; From 5698f270571d8a9656b724515d68b2a3a3d59f7c Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 7 Apr 2026 16:01:28 +0200 Subject: [PATCH 030/137] fix --- Cargo.lock | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27acc8ee21649b..a41c41638ec8ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,26 +627,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "bindgen" -version = "0.71.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.9.3", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.117", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -7329,7 +7309,7 @@ checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" dependencies = [ "crc32fast", "hashbrown 0.14.5", - "indexmap 2.9.0", + "indexmap 2.12.0", "memchr", ] @@ -8991,12 +8971,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16c7f49c9d5caa3bf4b3106900484b447b9253fe99670ceb81cb6cb5027855e1" [[package]] -name = "safe_arch" -version = "0.7.4" +name = "sacabase" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +checksum = "9883fc3d6ce3d78bb54d908602f8bc1f7b5f983afe601dabe083009d86267a84" dependencies = [ - "bytemuck", + "num-traits", ] [[package]] From faeb9381c77e216f943d0a6655f5ab2daf4e7713 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 7 Apr 2026 16:53:14 +0200 Subject: [PATCH 031/137] fix --- cli/rt_desktop/lib.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index b663b55893ef73..5e20468cbd91e1 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -1073,7 +1073,18 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { ); } + let sys = if data.metadata.self_extracting.is_some() { + denort::binary::extract_vfs_to_disk(&data.vfs, &data.root_path)?; + // Set CWD to extraction directory so frameworks like Next.js + // can find their build output (e.g. .next/) relative to CWD. + std::env::set_current_dir(&data.root_path)?; + denort::file_system::DenoRtSys::new_self_extracting(data.vfs.clone()) + } else { + denort::file_system::DenoRtSys::new(data.vfs.clone()) + }; + deno_runtime::deno_telemetry::init( + &sys, otel_runtime_config(), data.metadata.otel_config.clone(), )?; @@ -1095,16 +1106,6 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { ); } - let sys = if data.metadata.self_extracting.is_some() { - denort::binary::extract_vfs_to_disk(&data.vfs, &data.root_path)?; - // Set CWD to extraction directory so frameworks like Next.js - // can find their build output (e.g. .next/) relative to CWD. - std::env::set_current_dir(&data.root_path)?; - denort::file_system::DenoRtSys::new_self_extracting(data.vfs.clone()) - } else { - denort::file_system::DenoRtSys::new(data.vfs.clone()) - }; - // Enable HMR if DENO_DESKTOP_HMR is set to a directory path // (set by `deno compile --desktop --hmr`). let hmr_watch_dir = env::var("DENO_DESKTOP_HMR").ok().map(PathBuf::from); From 6f4088fb29bf3c60f6e6877b415dde4b76989e86 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 15 Apr 2026 13:12:18 +0200 Subject: [PATCH 032/137] fixes --- cli/tools/desktop.rs | 248 +++++++++++++++++++++++++++++++++++++++---- feature_test.ts | 3 +- 2 files changed, 231 insertions(+), 20 deletions(-) diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index db8e371ec59713..67c427d8394359 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -295,17 +295,240 @@ fn package_desktop_app( desktop_flags: &DesktopFlags, cli_options: &CliOptions, ) -> Result { - let is_darwin = match &desktop_flags.target { + let target = desktop_flags.target.as_deref(); + let is_darwin = match target { Some(target) => target.contains("darwin"), None => cfg!(target_os = "macos"), }; + let is_windows = match target { + Some(target) => target.contains("windows"), + None => cfg!(target_os = "windows"), + }; if is_darwin { package_macos_app_bundle(dylib_path, desktop_flags, cli_options) + } else if is_windows { + package_windows_app_dir(dylib_path, desktop_flags, cli_options) } else { - // TODO(divy): Windows and Linux packaging - Ok(dylib_path.to_path_buf()) + package_linux_app_dir(dylib_path, desktop_flags, cli_options) + } +} + +/// Find the directory containing the WEF backend binary. For non-macOS +/// platforms, the binary and its support files (e.g., CEF DLLs, locales) +/// sit alongside each other in a single flat directory. +fn find_wef_backend_dir(backend: &str) -> Result { + let backend_binary = find_wef_backend(backend)?; + let parent = backend_binary.parent().ok_or_else(|| { + deno_core::anyhow::anyhow!( + "WEF backend binary has no parent directory: {}", + backend_binary.display() + ) + })?; + Ok(parent.to_path_buf()) +} + +/// Create a Windows app directory from the compiled desktop dylib. +/// +/// Directory structure: +/// ```text +/// AppName/ +/// AppName.bat (launcher) +/// wef.exe (WEF backend binary) +/// libcef.dll, ... (CEF support files, if any) +/// denort.dll (compiled Deno runtime + user code) +/// AppIcon.ico (optional) +/// ``` +fn package_windows_app_dir( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, +) -> Result { + let app_name = dylib_path + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + let app_dir = dylib_path.parent().unwrap().join(&app_name); + + let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); + let wef_dir = find_wef_backend_dir(backend)?; + let wef_binary = find_wef_backend(backend)?; + let wef_binary_name = wef_binary + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + + if app_dir.exists() { + std::fs::remove_dir_all(&app_dir)?; + } + + // Copy WEF backend directory (binary + CEF support files) as the shell. + crate::tools::compile::copy_dir_all(&wef_dir, &app_dir)?; + + // Copy the compiled dylib (denort.dll) alongside the backend binary. + let dylib_filename = dylib_path.file_name().unwrap(); + std::fs::copy(dylib_path, app_dir.join(dylib_filename))?; + + // Create a .bat launcher that invokes the backend with --runtime. + let launcher_path = app_dir.join(format!("{}.bat", app_name)); + std::fs::write( + &launcher_path, + format!( + "@echo off\r\n\ + set DIR=%~dp0\r\n\ + \"%DIR%{wef_binary}\" --runtime \"%DIR%{dylib}\" %*\r\n", + wef_binary = wef_binary_name, + dylib = dylib_filename.to_string_lossy(), + ), + )?; + + // Handle icon — drop an .ico next to the launcher. Embedding the icon + // into the .exe itself requires rcedit or equivalent and is out of scope. + if let Some(ref icon) = desktop_flags.icon { + let dest = app_dir.join("AppIcon.ico"); + match icon { + crate::args::IconConfig::Single(path) => { + let icon_path = cli_options.initial_cwd().join(path); + if icon_path.exists() { + match icon_path.extension().and_then(|e| e.to_str()) { + Some("ico") => { + std::fs::copy(&icon_path, &dest)?; + } + _ => { + log::warn!( + "Icon '{}' is not .ico, skipping", + icon_path.display() + ); + } + } + } else { + log::warn!("Icon '{}' not found, skipping", icon_path.display()); + } + } + crate::args::IconConfig::Set(entries) => { + convert_icon_set_to_ico(cli_options.initial_cwd(), entries, &dest)?; + } + } } + + // Remove the standalone dylib (it's now inside the app dir). + let _ = std::fs::remove_file(dylib_path); + + Ok(app_dir) +} + +/// Create a Linux app directory from the compiled desktop dylib. +/// +/// Directory structure: +/// ```text +/// AppName/ +/// AppName (launcher shell script) +/// wef (WEF backend binary) +/// libcef.so, ... (CEF support files, if any) +/// libdenort.so (compiled Deno runtime + user code) +/// AppIcon.png (optional) +/// ``` +fn package_linux_app_dir( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, +) -> Result { + let app_name = dylib_path + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + // `file_stem` on "libdenort.so" returns "libdenort" — strip the "lib" prefix + // so the app directory is named after the app, not the runtime library. + let app_name = app_name + .strip_prefix("lib") + .map(|s| s.to_string()) + .unwrap_or(app_name); + let app_dir = dylib_path.parent().unwrap().join(&app_name); + + let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); + let wef_dir = find_wef_backend_dir(backend)?; + let wef_binary = find_wef_backend(backend)?; + let wef_binary_name = wef_binary + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + + if app_dir.exists() { + std::fs::remove_dir_all(&app_dir)?; + } + + // Copy WEF backend directory (binary + CEF support files) as the shell. + crate::tools::compile::copy_dir_all(&wef_dir, &app_dir)?; + + // Copy the compiled dylib alongside the backend binary. + let dylib_filename = dylib_path.file_name().unwrap(); + std::fs::copy(dylib_path, app_dir.join(dylib_filename))?; + + // Create a shell launcher that invokes the backend with --runtime. + let launcher_path = app_dir.join(&app_name); + std::fs::write( + &launcher_path, + format!( + "#!/bin/bash\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + exec \"$DIR/{wef_binary}\" --runtime \"$DIR/{dylib}\" \"$@\"\n", + wef_binary = wef_binary_name, + dylib = dylib_filename.to_string_lossy(), + ), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &launcher_path, + std::fs::Permissions::from_mode(0o755), + )?; + } + + // Handle icon — copy a .png next to the launcher. + if let Some(ref icon) = desktop_flags.icon { + let dest = app_dir.join("AppIcon.png"); + match icon { + crate::args::IconConfig::Single(path) => { + let icon_path = cli_options.initial_cwd().join(path); + if icon_path.exists() { + match icon_path.extension().and_then(|e| e.to_str()) { + Some("png") => { + std::fs::copy(&icon_path, &dest)?; + } + _ => { + log::warn!( + "Icon '{}' is not .png, skipping", + icon_path.display() + ); + } + } + } else { + log::warn!("Icon '{}' not found, skipping", icon_path.display()); + } + } + crate::args::IconConfig::Set(entries) => { + // Pick the largest provided size as the single icon file. + if let Some(largest) = entries.iter().max_by_key(|e| e.size) { + let src = cli_options.initial_cwd().join(&largest.path); + if src.exists() { + std::fs::copy(&src, &dest)?; + } else { + log::warn!("Icon '{}' not found, skipping", src.display()); + } + } + } + } + } + + // Remove the standalone dylib (it's now inside the app dir). + let _ = std::fs::remove_file(dylib_path); + + Ok(app_dir) } /// Environment variable for the WEF backend executable path. @@ -622,9 +845,7 @@ fn package_macos_app_bundle( std::fs::copy(&icon_path, &dest)?; } Some("png") => { - crate::tools::compile::convert_png_to_icns( - &icon_path, &dest, - )?; + crate::tools::compile::convert_png_to_icns(&icon_path, &dest)?; } _ => { log::warn!( @@ -634,18 +855,11 @@ fn package_macos_app_bundle( } } } else { - log::warn!( - "Icon '{}' not found, skipping", - icon_path.display() - ); + log::warn!("Icon '{}' not found, skipping", icon_path.display()); } } crate::args::IconConfig::Set(entries) => { - convert_icon_set_to_icns( - cli_options.initial_cwd(), - entries, - &dest, - )?; + convert_icon_set_to_icns(cli_options.initial_cwd(), entries, &dest)?; } } } @@ -780,9 +994,7 @@ fn convert_icon_set_to_icns( let _ = std::fs::remove_dir_all(&iconset_dir); if !status.success() { - deno_core::anyhow::bail!( - "iconutil failed to create .icns from icon set" - ); + deno_core::anyhow::bail!("iconutil failed to create .icns from icon set"); } Ok(()) diff --git a/feature_test.ts b/feature_test.ts index b464d22d4467dc..6479b45f871118 100644 --- a/feature_test.ts +++ b/feature_test.ts @@ -186,8 +186,7 @@ async function runAsyncTests() { try { const r = await win.executeJs("({a: 1, b: [2, 3]})"); const v = (r as any).ok !== undefined ? (r as any).value : r; - const ok = - v && typeof v === "object" && v.a === 1 && Array.isArray(v.b); + const ok = v && typeof v === "object" && v.a === 1 && Array.isArray(v.b); record("executeJs:complex", ok, JSON.stringify(r)); } catch (e) { record("executeJs:complex", false, String(e)); From 983a43e11dd28db627e77c1f7cf4e6078341b6e1 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Wed, 15 Apr 2026 23:31:35 +0200 Subject: [PATCH 033/137] size fixes --- cli/tools/desktop.rs | 153 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 67c427d8394359..d6dfc999f54d2d 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -104,6 +104,28 @@ async fn compile_desktop( mut desktop_flags: DesktopFlags, cli_options: &Arc, ) -> Result<(), AnyError> { + // If the user asked for a `.dmg` (macOS) installer via `--output`, strip + // the extension for the intermediate compile/bundle step and remember the + // original so we can wrap the resulting .app in a DMG at the end. + let dmg_output = desktop_flags + .output + .as_ref() + .filter(|o| o.to_lowercase().ends_with(".dmg")) + .cloned(); + if let Some(ref dmg) = dmg_output { + let stem = Path::new(dmg) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + let parent = Path::new(dmg) + .parent() + .filter(|p| !p.as_os_str().is_empty()); + desktop_flags.output = Some(match parent { + Some(p) => p.join(&stem).to_string_lossy().into_owned(), + None => stem, + }); + } + // Desktop framework detection: when --desktop is used and the source is // "." (a directory), detect the framework and generate the entrypoint. let _desktop_entrypoint_file = if desktop_flags.source_file == "." { @@ -197,18 +219,28 @@ async fn compile_desktop( // Package the dylib into a platform-specific app bundle. let bundle_path = package_desktop_app(&output_path, &desktop_flags, cli_options)?; + + // If the user requested a .dmg, wrap the .app in one and report the DMG. + let final_path = if let Some(dmg) = dmg_output.as_deref() { + let dmg_abs = cli_options.initial_cwd().join(dmg); + create_macos_dmg(&bundle_path, &dmg_abs)?; + dmg_abs + } else { + bundle_path + }; + let initial_cwd = deno_path_util::url_from_directory_path(cli_options.initial_cwd())?; log::info!( "{} {}", colors::green("Bundle"), - if let Ok(bundle_url) = deno_path_util::url_from_file_path(&bundle_path) { + if let Ok(bundle_url) = deno_path_util::url_from_file_path(&final_path) { crate::util::path::relative_specifier_path_for_display( &initial_cwd, &bundle_url, ) } else { - bundle_path.display().to_string() + final_path.display().to_string() } ); } @@ -367,6 +399,20 @@ fn package_windows_app_dir( // Copy WEF backend directory (binary + CEF support files) as the shell. crate::tools::compile::copy_dir_all(&wef_dir, &app_dir)?; + // Drop any self-extracting runtime cache dir that tagged along. + let wef_exe_stem = Path::new(&wef_binary_name) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| wef_binary_name.clone()); + let cache_dir = app_dir.join(format!(".{}", wef_exe_stem)); + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(&cache_dir); + } + let cache_file = app_dir.join(format!(".{}.cache", wef_exe_stem)); + if cache_file.exists() { + let _ = std::fs::remove_file(&cache_file); + } + // Copy the compiled dylib (denort.dll) alongside the backend binary. let dylib_filename = dylib_path.file_name().unwrap(); std::fs::copy(dylib_path, app_dir.join(dylib_filename))?; @@ -464,6 +510,20 @@ fn package_linux_app_dir( // Copy WEF backend directory (binary + CEF support files) as the shell. crate::tools::compile::copy_dir_all(&wef_dir, &app_dir)?; + // Drop any self-extracting runtime cache dir that tagged along. + let wef_exe_stem = Path::new(&wef_binary_name) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| wef_binary_name.clone()); + let cache_dir = app_dir.join(format!(".{}", wef_exe_stem)); + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(&cache_dir); + } + let cache_file = app_dir.join(format!(".{}.cache", wef_exe_stem)); + if cache_file.exists() { + let _ = std::fs::remove_file(&cache_file); + } + // Copy the compiled dylib alongside the backend binary. let dylib_filename = dylib_path.file_name().unwrap(); std::fs::copy(dylib_path, app_dir.join(dylib_filename))?; @@ -759,6 +819,22 @@ fn package_macos_app_bundle( let resources_dir = contents_dir.join("Resources"); std::fs::create_dir_all(&resources_dir)?; + // The backend binary extracts its self-extracting VFS to a sibling + // `.` dir on first run. If the source wef.app was ever run, that dir + // gets copied along with it — drop any such runtime caches. + let wef_exe_stem = Path::new(&wef_executable_name) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| wef_executable_name.clone()); + let cache_dir = macos_dir.join(format!(".{}", wef_exe_stem)); + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(&cache_dir); + } + let cache_file = macos_dir.join(format!(".{}.cache", wef_exe_stem)); + if cache_file.exists() { + let _ = std::fs::remove_file(&cache_file); + } + // Strip unnecessary bulk from the CEF framework. strip_cef_bloat(&contents_dir); @@ -870,6 +946,79 @@ fn package_macos_app_bundle( Ok(app_bundle) } +/// Wrap a macOS `.app` bundle in a drag-to-Applications `.dmg` installer. +/// +/// Builds a staging directory containing the `.app` plus a symlink to +/// `/Applications`, then invokes `hdiutil` to create a compressed read-only +/// disk image. +fn create_macos_dmg( + app_bundle: &Path, + dmg_path: &Path, +) -> Result<(), AnyError> { + let app_name = app_bundle + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + + // Stage in a sibling temp directory so hdiutil doesn't traverse unrelated + // files. Use a unique suffix to avoid collisions with concurrent builds. + let staging = dmg_path.with_file_name(format!( + ".{}.dmg-staging", + dmg_path.file_name().unwrap_or_default().to_string_lossy() + )); + if staging.exists() { + std::fs::remove_dir_all(&staging)?; + } + std::fs::create_dir_all(&staging)?; + + // Copy the .app into the staging dir and add an /Applications symlink so + // users can drag the app across in the mounted DMG window. + let staged_app = staging.join( + app_bundle + .file_name() + .ok_or_else(|| deno_core::anyhow::anyhow!("app bundle has no name"))?, + ); + crate::tools::compile::copy_dir_all(app_bundle, &staged_app)?; + #[cfg(unix)] + { + let _ = + std::os::unix::fs::symlink("/Applications", staging.join("Applications")); + } + + if dmg_path.exists() { + std::fs::remove_file(dmg_path)?; + } + if let Some(parent) = dmg_path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + + let status = std::process::Command::new("hdiutil") + .args([ + "create", + "-volname", + &app_name, + "-srcfolder", + &staging.display().to_string(), + "-ov", + "-format", + "UDZO", + &dmg_path.display().to_string(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .context("Failed to run hdiutil")?; + + let _ = std::fs::remove_dir_all(&staging); + + if !status.success() { + bail!("hdiutil failed to create DMG at {}", dmg_path.display()); + } + Ok(()) +} + /// Recursively copy a directory tree, ensuring writable permissions on the /// destination. This is needed because source files from the Nix store are /// read-only. From a5f69228749cf16acb0c76c80d499da81c67969c Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 16 Apr 2026 06:25:57 +0200 Subject: [PATCH 034/137] set up WEF backend downloading --- cli/build.rs | 36 +++ cli/tools/desktop.rs | 586 ++++++++++++++++++++++++++++--------------- 2 files changed, 413 insertions(+), 209 deletions(-) diff --git a/cli/build.rs b/cli/build.rs index f8b86928fed31f..da6f8e4ef7efd4 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -259,6 +259,40 @@ fn compress_sources(out_dir: &Path) { } } +/// Read the pinned `wef` capi crate version from the workspace Cargo.lock and +/// expose it as the `WEF_VERSION` rustc env var. Desktop backend downloads are +/// resolved against `github.com/denoland/wef/releases/tag/v{WEF_VERSION}`, so +/// tying this to Cargo.lock keeps a single source of truth. +fn emit_wef_version() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let lock_path = Path::new(&manifest_dir).join("../Cargo.lock"); + println!("cargo:rerun-if-changed={}", lock_path.display()); + + let lock = match std::fs::read_to_string(&lock_path) { + Ok(s) => s, + Err(_) => return, + }; + + let mut in_wef = false; + for line in lock.lines() { + if line == "name = \"wef\"" { + in_wef = true; + continue; + } + if in_wef { + if let Some(rest) = line.strip_prefix("version = \"") + && let Some(version) = rest.strip_suffix('"') + { + println!("cargo:rustc-env=WEF_VERSION={}", version); + return; + } + if line.starts_with("[[package]]") { + break; + } + } + } +} + fn main() { // Skip building from docs.rs. if env::var_os("DOCS_RS").is_some() { @@ -296,6 +330,8 @@ fn main() { println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap()); println!("cargo:rustc-env=PROFILE={}", env::var("PROFILE").unwrap()); + emit_wef_version(); + #[cfg(target_os = "windows")] { let mut res = winres::WindowsResource::new(); diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index d6dfc999f54d2d..a430067e1fa985 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use deno_core::anyhow::Context; use deno_core::anyhow::bail; use deno_core::error::AnyError; +use deno_core::url::Url; use deno_terminal::colors; +use sha2::Digest; use crate::args::CliOptions; use crate::args::CompileFlags; @@ -16,6 +18,18 @@ use crate::args::DesktopFlags; use crate::args::Flags; use crate::args::TypeCheckMode; use crate::factory::CliFactory; +use crate::http_util::HttpClientProvider; +use crate::util::progress_bar::ProgressBar; +use crate::util::progress_bar::ProgressBarStyle; + +/// Version of the `wef` capi crate pinned in the workspace Cargo.lock. +/// Populated by `cli/build.rs` and used to resolve matching prebuilt backend +/// binaries from `github.com/denoland/wef/releases/tag/v{WEF_VERSION}`. +const WEF_VERSION: &str = env!("WEF_VERSION"); + +/// Rustc target triple the deno binary was built for. Used as the default +/// target when selecting a prebuilt wef backend archive. +const WEF_NATIVE_TARGET: &str = env!("TARGET"); pub async fn desktop( flags: Flags, @@ -27,6 +41,7 @@ pub async fn desktop( let factory = CliFactory::from_flags(Arc::new(config_flags)); let cli_options = factory.cli_options()?; let desktop_config = cli_options.start_dir.to_desktop_config()?.clone(); + let wef_resolver = Arc::new(WefBackendResolver::new(&factory)?); if let Some(output) = desktop_config.output && desktop_flags.output.is_none() @@ -91,11 +106,17 @@ pub async fn desktop( log::info!("Building for target: {}", target); let mut desktop_flags = desktop_flags.clone(); desktop_flags.target = Some(target.to_string()); - compile_desktop(flags.clone(), desktop_flags, cli_options).await?; + compile_desktop( + flags.clone(), + desktop_flags, + cli_options, + &wef_resolver, + ) + .await?; } Ok(()) } else { - compile_desktop(flags, desktop_flags, cli_options).await + compile_desktop(flags, desktop_flags, cli_options, &wef_resolver).await } } @@ -103,6 +124,7 @@ async fn compile_desktop( mut flags: Flags, mut desktop_flags: DesktopFlags, cli_options: &Arc, + wef_resolver: &WefBackendResolver, ) -> Result<(), AnyError> { // If the user asked for a `.dmg` (macOS) installer via `--output`, strip // the extension for the intermediate compile/bundle step and remember the @@ -214,11 +236,19 @@ async fn compile_desktop( let cwd = cli_options.initial_cwd(); let framework = super::framework::detect_framework(cwd)?; let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); - run_desktop_hmr(&output_path, cwd, framework.as_ref(), backend).await?; + run_desktop_hmr( + &output_path, + cwd, + framework.as_ref(), + backend, + wef_resolver, + ) + .await?; } else { // Package the dylib into a platform-specific app bundle. let bundle_path = - package_desktop_app(&output_path, &desktop_flags, cli_options)?; + package_desktop_app(&output_path, &desktop_flags, cli_options, wef_resolver) + .await?; // If the user requested a .dmg, wrap the .app in one and report the DMG. let final_path = if let Some(dmg) = dmg_output.as_deref() { @@ -264,8 +294,10 @@ async fn run_desktop_hmr( source_dir: &Path, framework: Option<&super::framework::FrameworkDetection>, backend: &str, + wef_resolver: &WefBackendResolver, ) -> Result<(), AnyError> { - let wef_backend = find_wef_backend(backend)?; + let wef_backend = + wef_resolver.find_binary(backend, WEF_NATIVE_TARGET).await?; let dylib_abs = dylib_path .canonicalize() .unwrap_or(dylib_path.to_path_buf()); @@ -322,10 +354,11 @@ async fn run_desktop_hmr( } /// Package a compiled desktop dylib into a platform-specific app bundle. -fn package_desktop_app( +async fn package_desktop_app( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, + wef_resolver: &WefBackendResolver, ) -> Result { let target = desktop_flags.target.as_deref(); let is_darwin = match target { @@ -338,28 +371,17 @@ fn package_desktop_app( }; if is_darwin { - package_macos_app_bundle(dylib_path, desktop_flags, cli_options) + package_macos_app_bundle(dylib_path, desktop_flags, cli_options, wef_resolver) + .await } else if is_windows { - package_windows_app_dir(dylib_path, desktop_flags, cli_options) + package_windows_app_dir(dylib_path, desktop_flags, cli_options, wef_resolver) + .await } else { - package_linux_app_dir(dylib_path, desktop_flags, cli_options) + package_linux_app_dir(dylib_path, desktop_flags, cli_options, wef_resolver) + .await } } -/// Find the directory containing the WEF backend binary. For non-macOS -/// platforms, the binary and its support files (e.g., CEF DLLs, locales) -/// sit alongside each other in a single flat directory. -fn find_wef_backend_dir(backend: &str) -> Result { - let backend_binary = find_wef_backend(backend)?; - let parent = backend_binary.parent().ok_or_else(|| { - deno_core::anyhow::anyhow!( - "WEF backend binary has no parent directory: {}", - backend_binary.display() - ) - })?; - Ok(parent.to_path_buf()) -} - /// Create a Windows app directory from the compiled desktop dylib. /// /// Directory structure: @@ -371,10 +393,11 @@ fn find_wef_backend_dir(backend: &str) -> Result { /// denort.dll (compiled Deno runtime + user code) /// AppIcon.ico (optional) /// ``` -fn package_windows_app_dir( +async fn package_windows_app_dir( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, + wef_resolver: &WefBackendResolver, ) -> Result { let app_name = dylib_path .file_stem() @@ -384,8 +407,9 @@ fn package_windows_app_dir( let app_dir = dylib_path.parent().unwrap().join(&app_name); let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); - let wef_dir = find_wef_backend_dir(backend)?; - let wef_binary = find_wef_backend(backend)?; + let target = wef_target_for(desktop_flags); + let wef_binary = wef_resolver.find_binary(backend, target).await?; + let wef_dir = wef_resolver.find_binary_dir(backend, target).await?; let wef_binary_name = wef_binary .file_name() .unwrap() @@ -476,10 +500,11 @@ fn package_windows_app_dir( /// libdenort.so (compiled Deno runtime + user code) /// AppIcon.png (optional) /// ``` -fn package_linux_app_dir( +async fn package_linux_app_dir( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, + wef_resolver: &WefBackendResolver, ) -> Result { let app_name = dylib_path .file_stem() @@ -495,8 +520,9 @@ fn package_linux_app_dir( let app_dir = dylib_path.parent().unwrap().join(&app_name); let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); - let wef_dir = find_wef_backend_dir(backend)?; - let wef_binary = find_wef_backend(backend)?; + let target = wef_target_for(desktop_flags); + let wef_binary = wef_resolver.find_binary(backend, target).await?; + let wef_dir = wef_resolver.find_binary_dir(backend, target).await?; let wef_binary_name = wef_binary .file_name() .unwrap() @@ -591,163 +617,341 @@ fn package_linux_app_dir( Ok(app_dir) } -/// Environment variable for the WEF backend executable path. -const WEF_BACKEND_ENV: &str = "WEF_BACKEND"; - -/// Find the deno repo root from the current exe path. -/// Dev builds live at `/target/{debug,release}/deno`. -/// Resolve WEF backend binary search paths based on the chosen backend. -fn wef_backend_search_paths(backend: &str) -> Vec { - let wef_base = option_env!("CARGO_MANIFEST_DIR") - .map(|d| std::path::Path::new(d).join("../../wef")) - .filter(|p| p.exists()); - - let mut paths = Vec::new(); - if let Some(ref wef) = wef_base { - match backend { - "cef" => { - paths - .push(wef.join("result-cef/Applications/wef.app/Contents/MacOS/wef")); - paths.push(wef.join("result/Applications/wef.app/Contents/MacOS/wef")); - paths.push(wef.join("cef/build/Release/wef.app/Contents/MacOS/wef")); - paths.push(wef.join("cef/build/wef.app/Contents/MacOS/wef")); - } - "servo" => { - paths.push(wef.join("target/release/wef_servo")); - paths.push(wef.join("target/debug/wef_servo")); - } - "raw" => { - paths.push(wef.join("target/release/wef_winit")); - paths.push(wef.join("target/debug/wef_winit")); - } - _ => { - paths.push(wef.join( - "result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview", - )); - paths.push(wef.join( - "result/Applications/wef_webview.app/Contents/MacOS/wef_webview", - )); - paths.push( - wef.join("webview/build/wef_webview.app/Contents/MacOS/wef_webview"), - ); - } - } - } - paths +/// Environment variable pointing at a local wef checkout, used to bypass the +/// download path during development. Build-tree subpaths under this directory +/// are searched the same way the old sibling-checkout heuristic searched. +const WEF_DEV_DIR_ENV: &str = "WEF_DEV_DIR"; + +/// Resolves WEF backend binaries and `.app` bundles, falling back to +/// downloading prebuilt archives from the wef GitHub releases when +/// `WEF_DEV_DIR` is not set. +struct WefBackendResolver { + http_client_provider: Arc, + /// `/wef//` + cache_root: PathBuf, } -/// Environment variable pointing to a WEF backend .app bundle. -const WEF_BACKEND_APP_ENV: &str = "WEF_BACKEND_APP"; - -/// Resolve WEF backend .app search paths based on the chosen backend. -fn wef_backend_app_search_paths(backend: &str) -> Vec { - // Derive the wef repo path from this crate's location. - // CARGO_MANIFEST_DIR is cli/, the wef repo is at ../../wef relative to that - // (i.e. a sibling of the deno repo in the parent directory). - let wef_base = option_env!("CARGO_MANIFEST_DIR") - .map(|d| std::path::Path::new(d).join("../../wef")) - .filter(|p| p.exists()); - - let mut paths = Vec::new(); - if let Some(ref wef) = wef_base { - match backend { - "cef" => { - paths.push( - wef - .join("result-cef/Applications/wef.app") - .to_string_lossy() - .to_string(), - ); - paths.push( - wef - .join("result/Applications/wef.app") - .to_string_lossy() - .to_string(), - ); - paths.push( - wef - .join("cef/build/Release/wef.app") - .to_string_lossy() - .to_string(), - ); - paths.push(wef.join("cef/build/wef.app").to_string_lossy().to_string()); - } - "raw" | "servo" => {} // Not .app bundles - _ => { - paths.push( - wef - .join("result-1/Applications/wef_webview.app") - .to_string_lossy() - .to_string(), - ); - paths.push( - wef - .join("result/Applications/wef_webview.app") - .to_string_lossy() - .to_string(), - ); - paths.push( - wef - .join("webview/build/wef_webview.app") - .to_string_lossy() - .to_string(), - ); - } - } +impl WefBackendResolver { + fn new(factory: &CliFactory) -> Result { + let cache_root = factory + .deno_dir()? + .root + .join("wef") + .join(WEF_VERSION); + Ok(Self { + http_client_provider: factory.http_client_provider().clone(), + cache_root, + }) } - paths -} -/// Find the WEF backend .app bundle directory. -fn find_wef_backend_app_bundle(backend: &str) -> Result { - // Check explicit env var first. - if let Ok(path) = std::env::var(WEF_BACKEND_APP_ENV) { - let p = PathBuf::from(&path); - if p.exists() && p.extension().map_or(false, |e| e == "app") { - return Ok(p); + fn backend_cache_dir(&self, backend: &str, target: &str) -> PathBuf { + self.cache_root.join(backend).join(target) + } + + /// Download + verify + extract a backend archive if it isn't already in + /// `/wef////`. + async fn ensure_downloaded( + &self, + backend: &str, + target: &str, + ) -> Result { + let dir = self.backend_cache_dir(backend, target); + let marker = dir.join(".downloaded"); + if marker.exists() { + return Ok(dir); } - bail!( - "WEF backend .app not found at {} (set via {})", - path, - WEF_BACKEND_APP_ENV + + let archive = wef_archive_name(backend, target); + let client = self.http_client_provider.get_or_create()?; + + let sums_url = Url::parse(&wef_release_url("SHA256SUMS"))?; + let sums = client + .download_text(sums_url.clone()) + .await + .with_context(|| { + format!( + "failed to fetch {sums_url} (wef v{WEF_VERSION} may not be published yet)" + ) + })?; + let expected = parse_sha256sum(&sums, &archive).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "no checksum for {archive} in SHA256SUMS (wef v{WEF_VERSION} release may not include backend '{backend}' for target '{target}')" + ) + })?; + + log::info!( + "{} wef {} backend for {} (v{})", + colors::green("Downloading"), + backend, + target, + WEF_VERSION, ); - } - // Search well-known paths. - let exe_dir = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())); - for search_path in wef_backend_app_search_paths(backend) { - let p = PathBuf::from(&search_path); - if p.exists() { - return Ok(p); + let url = Url::parse(&wef_release_url(&archive))?; + let progress_bar = ProgressBar::new(ProgressBarStyle::DownloadBars); + let progress = progress_bar.update(&archive); + let response = client + .download_with_progress_and_retries( + url.clone(), + &Default::default(), + &progress, + ) + .await + .with_context(|| format!("failed to download {url}"))?; + let data = response + .into_maybe_bytes()? + .ok_or_else(|| deno_core::anyhow::anyhow!("empty response from {url}"))?; + + let actual = + faster_hex::hex_string(&sha2::Sha256::digest(&data)).to_lowercase(); + let expected_lc = expected.to_lowercase(); + if actual != expected_lc { + bail!( + "checksum mismatch for {archive}\n expected: {expected_lc}\n actual: {actual}" + ); } - if let Some(exe_dir) = &exe_dir { - let p = exe_dir.join(&search_path); - if p.exists() { - return Ok(p); - } + + if dir.exists() { + std::fs::remove_dir_all(&dir)?; } + std::fs::create_dir_all(&dir)?; + extract_wef_archive(&archive, &data, &dir) + .with_context(|| format!("failed to extract {archive}"))?; + std::fs::write(&marker, format!("v{WEF_VERSION}\n"))?; + Ok(dir) } - // Derive from the WEF backend binary path (walk up to .app). - if let Ok(backend_binary) = find_wef_backend(backend) { - let mut current = backend_binary.as_path(); - while let Some(parent) = current.parent() { - if parent.extension().map_or(false, |e| e == "app") { - return Ok(parent.to_path_buf()); - } - current = parent; + /// Locate the WEF backend binary for `backend` on `target`. + /// + /// Resolution order: `WEF_DEV_DIR` checkout → cached download → + /// fresh download. + async fn find_binary( + &self, + backend: &str, + target: &str, + ) -> Result { + if let Some(dev_dir) = wef_dev_dir() { + return locate_dev_backend_binary(&dev_dir, backend).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' backend binary under {} (set via {})", + dev_dir.display(), + WEF_DEV_DIR_ENV + ) + }); + } + + let dir = self.ensure_downloaded(backend, target).await?; + locate_backend_binary(&dir, backend, target).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' backend binary inside {}", + dir.display() + ) + }) + } + + /// Locate the WEF `.app` bundle for `backend` on a macOS `target`. + async fn find_app_bundle( + &self, + backend: &str, + target: &str, + ) -> Result { + if let Some(dev_dir) = wef_dev_dir() { + return locate_dev_app_bundle(&dev_dir, backend).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' .app bundle under {} (set via {})", + dev_dir.display(), + WEF_DEV_DIR_ENV + ) + }); } + + let dir = self.ensure_downloaded(backend, target).await?; + locate_app_bundle(&dir, backend).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' .app bundle inside {} (backend may not ship as an app for target '{target}')", + dir.display() + ) + }) } - bail!( - "WEF backend '{}' .app bundle not found. Set {} to the path of the WEF .app bundle.", - backend, - WEF_BACKEND_APP_ENV + /// Directory containing the backend binary and its support files (used on + /// Windows / Linux where support files sit alongside the binary). + async fn find_binary_dir( + &self, + backend: &str, + target: &str, + ) -> Result { + let binary = self.find_binary(backend, target).await?; + let parent = binary.parent().ok_or_else(|| { + deno_core::anyhow::anyhow!( + "WEF backend binary has no parent directory: {}", + binary.display() + ) + })?; + Ok(parent.to_path_buf()) + } +} + +fn wef_archive_name(backend: &str, target: &str) -> String { + let ext = if target.contains("windows") { + "zip" + } else { + "tar.gz" + }; + format!("wef-{backend}-{target}.{ext}") +} + +fn wef_release_url(file: &str) -> String { + format!( + "https://github.com/denoland/wef/releases/download/v{WEF_VERSION}/{file}" ) } +/// Pick out the hex digest for `file` from a GNU `sha256sum`-style file. Each +/// line is ` ` (optionally ` *` for binary +/// mode). +fn parse_sha256sum(contents: &str, file: &str) -> Option { + for line in contents.lines() { + let mut parts = line.split_whitespace(); + let hex = parts.next()?; + let name = parts.next()?; + if name.trim_start_matches('*') == file { + return Some(hex.to_string()); + } + } + None +} + +fn extract_wef_archive( + name: &str, + data: &[u8], + dest: &Path, +) -> Result<(), AnyError> { + if name.ends_with(".tar.gz") { + let decoder = flate2::read::GzDecoder::new(data); + let mut archive = tar::Archive::new(decoder); + archive.set_preserve_permissions(true); + archive.unpack(dest)?; + } else if name.ends_with(".zip") { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data))?; + archive.extract(dest)?; + } else { + bail!("unsupported archive format: {name}"); + } + Ok(()) +} + +/// Resolve the backend binary path inside an extracted archive directory. +fn locate_backend_binary( + dir: &Path, + backend: &str, + target: &str, +) -> Option { + let is_windows = target.contains("windows"); + let is_macos = target.contains("apple-darwin"); + match backend { + "cef" if is_macos => { + let p = dir.join("wef.app/Contents/MacOS/wef"); + p.exists().then_some(p) + } + "webview" if is_macos => { + let p = dir.join("wef_webview.app/Contents/MacOS/wef_webview"); + p.exists().then_some(p) + } + _ => { + let stem = match backend { + "cef" => "wef", + "raw" => "wef_winit", + "servo" => "wef_servo", + _ => "wef_webview", + }; + let exe = if is_windows { + format!("{stem}.exe") + } else { + stem.to_string() + }; + let p = dir.join(&exe); + p.exists().then_some(p) + } + } +} + +fn locate_app_bundle(dir: &Path, backend: &str) -> Option { + let name = match backend { + "cef" => "wef.app", + _ => "wef_webview.app", + }; + let p = dir.join(name); + p.exists().then_some(p) +} + +/// Target triple to use when selecting a wef backend archive. Honors +/// `desktop_flags.target` (for cross-target packaging); otherwise defaults to +/// the host triple this deno binary was built for. +fn wef_target_for(desktop_flags: &DesktopFlags) -> &str { + desktop_flags + .target + .as_deref() + .unwrap_or(WEF_NATIVE_TARGET) +} + +/// Resolve `WEF_DEV_DIR` to a directory path if set and present on disk. +fn wef_dev_dir() -> Option { + let raw = std::env::var(WEF_DEV_DIR_ENV).ok()?; + let p = PathBuf::from(raw); + p.is_dir().then_some(p) +} + +/// Find a built backend binary inside a wef checkout. Mirrors the well-known +/// build-tree paths produced by wef's Makefile + Nix flakes. +fn locate_dev_backend_binary(wef: &Path, backend: &str) -> Option { + let candidates: Vec = match backend { + "cef" => vec![ + wef.join("result-cef/Applications/wef.app/Contents/MacOS/wef"), + wef.join("result/Applications/wef.app/Contents/MacOS/wef"), + wef.join("cef/build/Release/wef.app/Contents/MacOS/wef"), + wef.join("cef/build/wef.app/Contents/MacOS/wef"), + wef.join("cef/build/Release/wef"), + wef.join("cef/build/wef"), + ], + "servo" => vec![ + wef.join("target/release/wef_servo"), + wef.join("target/debug/wef_servo"), + ], + "raw" => vec![ + wef.join("target/release/wef_winit"), + wef.join("target/debug/wef_winit"), + ], + _ => vec![ + wef + .join("result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview"), + wef.join("result/Applications/wef_webview.app/Contents/MacOS/wef_webview"), + wef.join("webview/build/wef_webview.app/Contents/MacOS/wef_webview"), + wef.join("webview/build/wef_webview"), + ], + }; + candidates.into_iter().find(|p| p.exists()) +} + +/// Find a built backend `.app` bundle inside a wef checkout. +fn locate_dev_app_bundle(wef: &Path, backend: &str) -> Option { + let candidates: Vec = match backend { + "cef" => vec![ + wef.join("result-cef/Applications/wef.app"), + wef.join("result/Applications/wef.app"), + wef.join("cef/build/Release/wef.app"), + wef.join("cef/build/wef.app"), + ], + "raw" | "servo" => return None, + _ => vec![ + wef.join("result-1/Applications/wef_webview.app"), + wef.join("result/Applications/wef_webview.app"), + wef.join("webview/build/wef_webview.app"), + ], + }; + candidates.into_iter().find(|p| p.exists()) +} + /// Extract a string value from a plist XML by key. fn extract_plist_string(plist_xml: &str, key: &str) -> Option { let key_tag = format!("{}", key); @@ -772,10 +976,11 @@ fn extract_plist_string(plist_xml: &str, key: &str) -> Option { /// Resources/ /// AppIcon.icns (optional) /// ``` -fn package_macos_app_bundle( +async fn package_macos_app_bundle( dylib_path: &Path, desktop_flags: &DesktopFlags, cli_options: &CliOptions, + wef_resolver: &WefBackendResolver, ) -> Result { let app_name = dylib_path .file_stem() @@ -789,7 +994,8 @@ fn package_macos_app_bundle( // Find the WEF backend .app and its main executable. let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); - let wef_app = find_wef_backend_app_bundle(backend)?; + let target = wef_target_for(desktop_flags); + let wef_app = wef_resolver.find_app_bundle(backend, target).await?; let wef_plist_path = wef_app.join("Contents/Info.plist"); let wef_executable_name = if wef_plist_path.exists() { let plist_content = std::fs::read_to_string(&wef_plist_path)?; @@ -1058,44 +1264,6 @@ fn strip_cef_bloat(contents_dir: &Path) { let _ = std::fs::remove_file(cef_libraries.join("vk_swiftshader_icd.json")); } -/// Find the WEF backend executable. -fn find_wef_backend(backend: &str) -> Result { - if let Ok(path) = std::env::var(WEF_BACKEND_ENV) { - let p = PathBuf::from(&path); - if p.exists() { - return Ok(p); - } - bail!( - "WEF backend not found at {} (set via {})", - path, - WEF_BACKEND_ENV - ); - } - - // Search relative to the deno executable for development. - let exe_dir = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())); - for search_path in wef_backend_search_paths(backend) { - let p = PathBuf::from(&search_path); - if p.exists() { - return Ok(p); - } - if let Some(exe_dir) = &exe_dir { - let p = exe_dir.join(&search_path); - if p.exists() { - return Ok(p); - } - } - } - - bail!( - "WEF backend '{}' not found. Set {} to the path of the WEF backend executable.", - backend, - WEF_BACKEND_ENV - ) -} - /// Build a macOS `.icns` from an icon set (multiple PNGs at specified sizes). /// /// Maps each provided size to the correct `.iconset` filename. The standard From d39a9b0eccbe83479aac0bac22d820550f7512d5 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 16 Apr 2026 08:10:34 +0200 Subject: [PATCH 035/137] appimage output --- cli/tools/desktop.rs | 148 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index a430067e1fa985..ef92e60f5a35f2 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -148,6 +148,27 @@ async fn compile_desktop( }); } + // Same for `.AppImage` on Linux — strip extension, wrap app dir in an + // AppImage at the end. + let appimage_output = desktop_flags + .output + .as_ref() + .filter(|o| o.to_lowercase().ends_with(".appimage")) + .cloned(); + if let Some(ref appimage) = appimage_output { + let stem = Path::new(appimage) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + let parent = Path::new(appimage) + .parent() + .filter(|p| !p.as_os_str().is_empty()); + desktop_flags.output = Some(match parent { + Some(p) => p.join(&stem).to_string_lossy().into_owned(), + None => stem, + }); + } + // Desktop framework detection: when --desktop is used and the source is // "." (a directory), detect the framework and generate the entrypoint. let _desktop_entrypoint_file = if desktop_flags.source_file == "." { @@ -251,10 +272,15 @@ async fn compile_desktop( .await?; // If the user requested a .dmg, wrap the .app in one and report the DMG. + // If the user requested a .AppImage, wrap the Linux app dir in one. let final_path = if let Some(dmg) = dmg_output.as_deref() { let dmg_abs = cli_options.initial_cwd().join(dmg); create_macos_dmg(&bundle_path, &dmg_abs)?; dmg_abs + } else if let Some(appimage) = appimage_output.as_deref() { + let appimage_abs = cli_options.initial_cwd().join(appimage); + create_linux_appimage(&bundle_path, &appimage_abs)?; + appimage_abs } else { bundle_path }; @@ -555,13 +581,18 @@ async fn package_linux_app_dir( std::fs::copy(dylib_path, app_dir.join(dylib_filename))?; // Create a shell launcher that invokes the backend with --runtime. + // --ozone-platform=x11 forces CEF to create X11 windows (via XWayland on + // Wayland sessions). The Linux WEF mouse/focus/resize event monitor uses + // XI2 on X11 and does not support Wayland. + // GDK_BACKEND=x11 aligns GDK with Ozone so GDK_IS_X11_DISPLAY is true. let launcher_path = app_dir.join(&app_name); std::fs::write( &launcher_path, format!( "#!/bin/bash\n\ DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ - exec \"$DIR/{wef_binary}\" --runtime \"$DIR/{dylib}\" \"$@\"\n", + export GDK_BACKEND=x11\n\ + exec \"$DIR/{wef_binary}\" --ozone-platform=x11 --runtime \"$DIR/{dylib}\" \"$@\"\n", wef_binary = wef_binary_name, dylib = dylib_filename.to_string_lossy(), ), @@ -1225,6 +1256,121 @@ fn create_macos_dmg( Ok(()) } +/// Wrap a Linux app directory in an `.AppImage` single-file executable. +/// +/// Stages the app dir as an AppDir (adding `AppRun`, a `.desktop` entry, and +/// a top-level icon) and invokes `appimagetool` to build the AppImage. The +/// user must have `appimagetool` on `PATH`. +fn create_linux_appimage( + app_dir: &Path, + appimage_path: &Path, +) -> Result<(), AnyError> { + let app_name = app_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + + // Stage in a sibling directory. appimagetool treats the staging dir as the + // AppDir root. + let staging = appimage_path.with_file_name(format!( + ".{}.appimage-staging", + appimage_path.file_name().unwrap_or_default().to_string_lossy() + )); + if staging.exists() { + std::fs::remove_dir_all(&staging)?; + } + std::fs::create_dir_all(&staging)?; + + crate::tools::compile::copy_dir_all(app_dir, &staging)?; + + // AppRun is what the AppImage invokes on launch. Use a thin shell script + // so we don't have to deal with $ORIGIN-relative rpath in AppRun itself — + // the existing launcher already sets $DIR and execs the backend. + let apprun = staging.join("AppRun"); + std::fs::write( + &apprun, + format!( + "#!/bin/bash\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + exec \"$DIR/{app_name}\" \"$@\"\n", + ), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&apprun, std::fs::Permissions::from_mode(0o755))?; + } + + // .desktop entry. appimagetool requires one at the AppDir root. + let desktop_entry = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name={app_name}\n\ + Exec={app_name}\n\ + Icon={app_name}\n\ + Categories=Utility;\n", + ); + std::fs::write(staging.join(format!("{app_name}.desktop")), desktop_entry)?; + + // Icon: appimagetool looks for .png (or .svg) at the AppDir root. + // package_linux_app_dir writes the user icon as AppIcon.png — rename it. + // Fall back to a 1x1 transparent PNG if no icon was provided. + let icon_target = staging.join(format!("{app_name}.png")); + if !icon_target.exists() { + let app_icon = staging.join("AppIcon.png"); + if app_icon.exists() { + std::fs::rename(&app_icon, &icon_target)?; + } else { + const STUB_PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + std::fs::write(&icon_target, STUB_PNG)?; + } + } + + if appimage_path.exists() { + std::fs::remove_file(appimage_path)?; + } + if let Some(parent) = appimage_path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + + // ARCH env var is required by appimagetool to name the runtime correctly. + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => other, + }; + + let status = std::process::Command::new("appimagetool") + .env("ARCH", arch) + .arg(&staging) + .arg(appimage_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .context( + "Failed to run appimagetool (install from https://appimage.github.io/appimagetool/)", + )?; + + let _ = std::fs::remove_dir_all(&staging); + + if !status.success() { + bail!( + "appimagetool failed to create AppImage at {}", + appimage_path.display() + ); + } + Ok(()) +} + /// Recursively copy a directory tree, ensuring writable permissions on the /// destination. This is needed because source files from the Nix store are /// read-only. From 2dccfb18305438b685e79179af7424de0ec02fe7 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 16 Apr 2026 17:07:39 +0200 Subject: [PATCH 036/137] inspect --- Cargo.lock | 3 + cli/Cargo.toml | 3 + cli/args/flags.rs | 23 ++++++- cli/rt/run.rs | 17 +++++- cli/rt_desktop/lib.rs | 35 +++++++++++ cli/tools/desktop.rs | 139 +++++++++++++++++++++++++++++++++--------- cli/tools/mod.rs | 1 + 7 files changed, 186 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24ac923452c371..e22963a905b24e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1895,11 +1895,14 @@ dependencies = [ "eszip", "fancy-regex 0.14.0", "faster-hex", + "fastwebsockets 0.8.1", "flate2", "fluent-uri", "http 1.4.0", "http-body 1.0.0", "http-body-util", + "hyper 1.6.0", + "hyper-util", "imara-diff", "import_map", "indexmap 2.12.0", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 53c692af6f7be9..ed410518624944 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -119,12 +119,15 @@ dprint-plugin-typescript.workspace = true esbuild_client = { version = "0.7.1", features = ["serde"] } fancy-regex.workspace = true faster-hex.workspace = true +fastwebsockets.workspace = true # If you disable the default __vendored_zlib_ng feature above, you _must_ be able to link against `-lz`. flate2.workspace = true fluent-uri.workspace = true http.workspace = true http-body.workspace = true http-body-util.workspace = true +hyper.workspace = true +hyper-util.workspace = true imara-diff.workspace = true import_map.workspace = true indexmap.workspace = true diff --git a/cli/args/flags.rs b/cli/args/flags.rs index b6c3dc6ec1454a..a8767253b3180a 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -203,6 +203,10 @@ pub struct DesktopFlags { pub hmr: bool, pub backend: Option, pub all_targets: bool, + /// Optional override for the CEF renderer debugger port. When unset, a free + /// port is allocated. The user-visible inspector port (from `--inspect`) is + /// separate and is carried on `Flags::inspect`. + pub inspect_renderer: Option, } impl CompileFlags { @@ -2922,8 +2926,21 @@ framework (Next.js, Astro, etc.). UnstableArgsConfig::ResolutionAndRuntime, ) .defer(|cmd| { - runtime_args(cmd, true, false, true) + runtime_args(cmd, true, true, true) .arg(check_arg(true)) + .arg( + Arg::new("inspect-renderer") + .long("inspect-renderer") + .value_name("HOST_PORT") + .default_missing_value("127.0.0.1:0") + .help( + "Override the CEF renderer debugger listen address; defaults to an auto-allocated port", + ) + .num_args(0..=1) + .require_equals(true) + .value_parser(inspect_value_parser) + .help_heading(DEBUGGING_HEADING), + ) .arg( Arg::new("include") .long("include") @@ -6442,7 +6459,7 @@ fn desktop_parse( matches: &mut ArgMatches, ) -> clap::error::Result<()> { flags.type_check_mode = TypeCheckMode::Local; - runtime_args_parse(flags, matches, true, false, true)?; + runtime_args_parse(flags, matches, true, true, true)?; if let Some(initial_cwd) = flags.initial_cwd.take() { flags.initial_cwd = Some( @@ -6461,6 +6478,7 @@ fn desktop_parse( let hmr = matches.get_flag("hmr"); let backend = matches.remove_one::("backend"); let all_targets = matches.get_flag("all-targets"); + let inspect_renderer = matches.remove_one::("inspect-renderer"); let include = matches .remove_many::("include") .map(|f| f.collect::>()) @@ -6484,6 +6502,7 @@ fn desktop_parse( hmr, backend, all_targets, + inspect_renderer, }); Ok(()) diff --git a/cli/rt/run.rs b/cli/rt/run.rs index 37092a8c917148..5489272ddf7a81 100644 --- a/cli/rt/run.rs +++ b/cli/rt/run.rs @@ -833,6 +833,14 @@ pub struct RunOptions { pub auto_update_rolled_back: bool, /// Error reporting URL from deno.json `desktop.errorReporting.url`. pub error_reporting_url: Option, + /// Stop on the first line of user code. Mirrors `--inspect-brk`. + pub inspect_brk: bool, + /// Wait for a DevTools session to attach before running user code. + /// Mirrors `--inspect-wait`. + pub inspect_wait: bool, + /// Enables inspector-related setup in the worker (registers the runtime + /// with the global inspector server). Mirrors `--inspect`/`-brk`/`-wait`. + pub is_inspecting: bool, } impl Default for RunOptions { @@ -848,6 +856,9 @@ impl Default for RunOptions { auto_update_version: None, auto_update_rolled_back: false, error_reporting_url: None, + inspect_brk: false, + inspect_wait: false, + is_inspecting: false, } } } @@ -1196,10 +1207,10 @@ pub async fn run_with_options( log_level: WorkerLogLevel::Info, enable_testing_features: false, has_node_modules_dir, - inspect_brk: false, - inspect_wait: false, + inspect_brk: options.inspect_brk, + inspect_wait: options.inspect_wait, trace_ops: None, - is_inspecting: false, + is_inspecting: options.is_inspecting, is_standalone: true, auto_serve: options.auto_serve, skip_op_registration: true, diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 5e20468cbd91e1..3594466364b017 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -1096,6 +1096,38 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let desktop_serve_port = allocate_random_port()?; + // Wire up the Deno-side inspector when launched under + // `deno desktop --inspect[-brk|-wait]`. The parent process binds the + // user-visible port and runs a multiplexer that fronts both this + // inspector and the CEF renderer's debug port; we just listen on the + // internal port that the parent allocated for us. + let inspect_internal_port_raw = + env::var("DENO_DESKTOP_INSPECT_INTERNAL_PORT").ok(); + let inspect_internal_port = inspect_internal_port_raw + .as_deref() + .and_then(|s| s.parse::().ok()); + let inspect_brk = env::var("DENO_DESKTOP_INSPECT_BRK").is_ok(); + let inspect_wait = env::var("DENO_DESKTOP_INSPECT_WAIT").is_ok(); + eprintln!( + "[desktop] inspect env: DENO_DESKTOP_INSPECT_INTERNAL_PORT={:?} parsed={:?} brk={} wait={}", + inspect_internal_port_raw, inspect_internal_port, inspect_brk, inspect_wait, + ); + if let Some(addr) = inspect_internal_port { + match deno_runtime::deno_inspector_server::create_inspector_server( + addr, + "deno-desktop", + // Don't print the ws:// URL ourselves — DevTools attaches via the + // parent mux's user-visible port. + deno_runtime::deno_inspector_server::InspectPublishUid { + console: false, + http: true, + }, + ) { + Ok(_) => eprintln!("[desktop] inspector server bound on {addr}"), + Err(err) => eprintln!("[desktop] inspector server bind failed: {err:?}"), + } + } + // Set DENO_SERVE_ADDRESS so Deno.serve() and Node http servers // automatically bind to the desktop port. #[allow(clippy::undocumented_unsafe_blocks)] @@ -1200,6 +1232,9 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { auto_update_version, auto_update_rolled_back, error_reporting_url: data.metadata.error_reporting_url.clone(), + inspect_brk, + inspect_wait, + is_inspecting: inspect_internal_port.is_some(), }; // Run the Deno runtime and WEF event loop concurrently. diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index a430067e1fa985..3f5070e47d874c 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -1,5 +1,6 @@ // Copyright 2018-2026 the Deno authors. MIT license. +use std::net::SocketAddr; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -106,13 +107,8 @@ pub async fn desktop( log::info!("Building for target: {}", target); let mut desktop_flags = desktop_flags.clone(); desktop_flags.target = Some(target.to_string()); - compile_desktop( - flags.clone(), - desktop_flags, - cli_options, - &wef_resolver, - ) - .await?; + compile_desktop(flags.clone(), desktop_flags, cli_options, &wef_resolver) + .await?; } Ok(()) } else { @@ -232,7 +228,11 @@ async fn compile_desktop( super::compile::compile_binary(Arc::new(temp_flags), compile_flags, true) .await?; - if desktop_flags.hmr { + let inspector_requested = flags.inspect.is_some() + || flags.inspect_brk.is_some() + || flags.inspect_wait.is_some(); + + if desktop_flags.hmr || inspector_requested { let cwd = cli_options.initial_cwd(); let framework = super::framework::detect_framework(cwd)?; let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); @@ -242,13 +242,19 @@ async fn compile_desktop( framework.as_ref(), backend, wef_resolver, + &flags, + &desktop_flags, ) .await?; } else { // Package the dylib into a platform-specific app bundle. - let bundle_path = - package_desktop_app(&output_path, &desktop_flags, cli_options, wef_resolver) - .await?; + let bundle_path = package_desktop_app( + &output_path, + &desktop_flags, + cli_options, + wef_resolver, + ) + .await?; // If the user requested a .dmg, wrap the .app in one and report the DMG. let final_path = if let Some(dmg) = dmg_output.as_deref() { @@ -295,6 +301,8 @@ async fn run_desktop_hmr( framework: Option<&super::framework::FrameworkDetection>, backend: &str, wef_resolver: &WefBackendResolver, + flags: &Flags, + desktop_flags: &DesktopFlags, ) -> Result<(), AnyError> { let wef_backend = wef_resolver.find_binary(backend, WEF_NATIVE_TARGET).await?; @@ -341,11 +349,77 @@ async fn run_desktop_hmr( } } - let mut child = cmd.spawn().with_context(|| { - format!("Failed to launch WEF backend: {}", wef_backend.display()) - })?; + // Wire up the unified DevTools multiplexer when --inspect is set. + // The mux runs in this (parent) process and fronts both the Deno runtime + // inspector (in the WEF subprocess) and the CEF renderer's debug port + // (in CEF's child process). We allocate two internal ports here, hand + // them to the subprocess via env vars, and bind the user-visible port + // for DevTools to attach to. + let user_inspect = flags.inspect.or(flags.inspect_brk).or(flags.inspect_wait); + let mux_handle = if let Some(user_addr) = user_inspect { + let deno_internal: SocketAddr = format!( + "127.0.0.1:{}", + crate::tools::desktop_devtools::allocate_random_port()? + ) + .parse() + .unwrap(); + let cef_internal: SocketAddr = match desktop_flags.inspect_renderer { + Some(addr) => addr, + None => format!( + "127.0.0.1:{}", + crate::tools::desktop_devtools::allocate_random_port()? + ) + .parse() + .unwrap(), + }; + let handle = crate::tools::desktop_devtools::spawn_mux( + crate::tools::desktop_devtools::MuxConfig { + listen: user_addr, + deno_internal, + cef_internal, + }, + ) + .await?; + + log::info!( + "{} unified DevTools at ws://{}/ (chrome://inspect)", + colors::green("Listening"), + handle.listen, + ); + log::info!( + "[desktop] internal upstream ports: deno={} cef={}", + deno_internal, + cef_internal, + ); + + cmd + .env( + "DENO_DESKTOP_INSPECT_INTERNAL_PORT", + deno_internal.to_string(), + ) + .env("WEF_REMOTE_DEBUGGING_PORT", cef_internal.port().to_string()); + if flags.inspect_brk.is_some() { + cmd.env("DENO_DESKTOP_INSPECT_BRK", "1"); + } + if flags.inspect_wait.is_some() { + cmd.env("DENO_DESKTOP_INSPECT_WAIT", "1"); + } + Some(handle) + } else { + None + }; + + let mut child = tokio::process::Command::from(cmd).spawn().with_context( + || format!("Failed to launch WEF backend: {}", wef_backend.display()), + )?; + + let status = child + .wait() + .await + .context("Failed waiting for WEF backend")?; - let status = child.wait().context("Failed waiting for WEF backend")?; + // Keep the mux alive until the subprocess exits, then drop it. + drop(mux_handle); if !status.success() { bail!("WEF backend exited with status: {}", status); @@ -371,11 +445,21 @@ async fn package_desktop_app( }; if is_darwin { - package_macos_app_bundle(dylib_path, desktop_flags, cli_options, wef_resolver) - .await + package_macos_app_bundle( + dylib_path, + desktop_flags, + cli_options, + wef_resolver, + ) + .await } else if is_windows { - package_windows_app_dir(dylib_path, desktop_flags, cli_options, wef_resolver) - .await + package_windows_app_dir( + dylib_path, + desktop_flags, + cli_options, + wef_resolver, + ) + .await } else { package_linux_app_dir(dylib_path, desktop_flags, cli_options, wef_resolver) .await @@ -633,11 +717,7 @@ struct WefBackendResolver { impl WefBackendResolver { fn new(factory: &CliFactory) -> Result { - let cache_root = factory - .deno_dir()? - .root - .join("wef") - .join(WEF_VERSION); + let cache_root = factory.deno_dir()?.root.join("wef").join(WEF_VERSION); Ok(Self { http_client_provider: factory.http_client_provider().clone(), cache_root, @@ -889,10 +969,7 @@ fn locate_app_bundle(dir: &Path, backend: &str) -> Option { /// `desktop_flags.target` (for cross-target packaging); otherwise defaults to /// the host triple this deno binary was built for. fn wef_target_for(desktop_flags: &DesktopFlags) -> &str { - desktop_flags - .target - .as_deref() - .unwrap_or(WEF_NATIVE_TARGET) + desktop_flags.target.as_deref().unwrap_or(WEF_NATIVE_TARGET) } /// Resolve `WEF_DEV_DIR` to a directory path if set and present on disk. @@ -923,9 +1000,11 @@ fn locate_dev_backend_binary(wef: &Path, backend: &str) -> Option { wef.join("target/debug/wef_winit"), ], _ => vec![ + wef.join( + "result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview", + ), wef - .join("result-1/Applications/wef_webview.app/Contents/MacOS/wef_webview"), - wef.join("result/Applications/wef_webview.app/Contents/MacOS/wef_webview"), + .join("result/Applications/wef_webview.app/Contents/MacOS/wef_webview"), wef.join("webview/build/wef_webview.app/Contents/MacOS/wef_webview"), wef.join("webview/build/wef_webview"), ], diff --git a/cli/tools/mod.rs b/cli/tools/mod.rs index 04abad0ef4f6e7..30d28282b49ca4 100644 --- a/cli/tools/mod.rs +++ b/cli/tools/mod.rs @@ -8,6 +8,7 @@ pub mod compile; pub mod coverage; pub mod deploy; pub mod desktop; +pub mod desktop_devtools; pub mod doc; pub mod fmt; pub mod framework; From c21c5c0993ecf4557691d80a5557e3fa7b52ff9b Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 17 Apr 2026 01:37:03 +0200 Subject: [PATCH 037/137] unified devtools --- cli/rt_desktop/lib.rs | 37 +++++++++++++++++++------------ cli/tools/desktop.rs | 24 ++++++++++++++------ cli/tsc/dts/lib.deno.desktop.d.ts | 14 +++++++++++- runtime/ops/desktop.rs | 27 ++++++++++++++++++---- 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 3594466364b017..a630676379bb1e 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -247,7 +247,24 @@ impl denort::desktop::DesktopApi for WefDesktopApi { wef::Window::from_id(window_id).focus(); } - fn open_devtools(&self, window_id: u32) { + fn open_devtools(&self, window_id: u32, renderer: bool, deno: bool) { + if let Ok(mux) = env::var("DENO_DESKTOP_MUX_WS") { + let (endpoint, frontend) = match (renderer, deno) { + (true, true) => ("/unified", "inspector.html"), + (true, false) => ("/cef", "inspector.html"), + (false, true) => ("/deno", "js_app.html"), + (false, false) => unreachable!(), + }; + let url = format!( + "http://{mux}/devtools/{frontend}?ws={mux}{endpoint}" + ); + log::info!("[desktop] openDevtools(renderer={renderer}, deno={deno}) → {url}"); + let window = wef::Window::new(1200, 800); + window.set_title("Deno Desktop DevTools"); + window.navigate(&url); + let _ = self.setup_window_events(window); + return; + } wef::Window::from_id(window_id).open_devtools(); } @@ -1101,19 +1118,13 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { // user-visible port and runs a multiplexer that fronts both this // inspector and the CEF renderer's debug port; we just listen on the // internal port that the parent allocated for us. - let inspect_internal_port_raw = - env::var("DENO_DESKTOP_INSPECT_INTERNAL_PORT").ok(); - let inspect_internal_port = inspect_internal_port_raw - .as_deref() + let inspect_internal_port = env::var("DENO_DESKTOP_INSPECT_INTERNAL_PORT") + .ok() .and_then(|s| s.parse::().ok()); let inspect_brk = env::var("DENO_DESKTOP_INSPECT_BRK").is_ok(); let inspect_wait = env::var("DENO_DESKTOP_INSPECT_WAIT").is_ok(); - eprintln!( - "[desktop] inspect env: DENO_DESKTOP_INSPECT_INTERNAL_PORT={:?} parsed={:?} brk={} wait={}", - inspect_internal_port_raw, inspect_internal_port, inspect_brk, inspect_wait, - ); if let Some(addr) = inspect_internal_port { - match deno_runtime::deno_inspector_server::create_inspector_server( + deno_runtime::deno_inspector_server::create_inspector_server( addr, "deno-desktop", // Don't print the ws:// URL ourselves — DevTools attaches via the @@ -1122,10 +1133,8 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { console: false, http: true, }, - ) { - Ok(_) => eprintln!("[desktop] inspector server bound on {addr}"), - Err(err) => eprintln!("[desktop] inspector server bind failed: {err:?}"), - } + )?; + log::debug!("[desktop] inspector server bound on {addr}"); } // Set DENO_SERVE_ADDRESS so Deno.serve() and Node http servers diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 1125bf7c06bb90..2a8893acc02fb4 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -408,11 +408,11 @@ async fn run_desktop_hmr( .await?; log::info!( - "{} unified DevTools at ws://{}/ (chrome://inspect)", - colors::green("Listening"), + "{} DevTools on ws://{} (open chrome://inspect)", + colors::green("Inspector"), handle.listen, ); - log::info!( + log::debug!( "[desktop] internal upstream ports: deno={} cef={}", deno_internal, cef_internal, @@ -423,6 +423,10 @@ async fn run_desktop_hmr( "DENO_DESKTOP_INSPECT_INTERNAL_PORT", deno_internal.to_string(), ) + // Exposed so rt_desktop's `openDevtools()` can launch a browser + // pointed at the unified DevTools frontend instead of CEF's + // renderer-only native window. + .env("DENO_DESKTOP_MUX_WS", handle.listen.to_string()) .env("WEF_REMOTE_DEBUGGING_PORT", cef_internal.port().to_string()); if flags.inspect_brk.is_some() { cmd.env("DENO_DESKTOP_INSPECT_BRK", "1"); @@ -435,9 +439,12 @@ async fn run_desktop_hmr( None }; - let mut child = tokio::process::Command::from(cmd).spawn().with_context( - || format!("Failed to launch WEF backend: {}", wef_backend.display()), - )?; + let mut child = + tokio::process::Command::from(cmd) + .spawn() + .with_context(|| { + format!("Failed to launch WEF backend: {}", wef_backend.display()) + })?; let status = child .wait() @@ -1353,7 +1360,10 @@ fn create_linux_appimage( // AppDir root. let staging = appimage_path.with_file_name(format!( ".{}.appimage-staging", - appimage_path.file_name().unwrap_or_default().to_string_lossy() + appimage_path + .file_name() + .unwrap_or_default() + .to_string_lossy() )); if staging.exists() { std::fs::remove_dir_all(&staging)?; diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 22463642acf2d0..797bb59f898013 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -96,6 +96,13 @@ declare class WheelEvent extends MouseEvent { declare namespace Deno { export {}; // stop default export type behavior + export interface OpenDevtoolsOptions { + /** Inspect the CEF renderer isolate. @default {true} */ + renderer?: boolean; + /** Inspect the Deno runtime isolate. @default {true} */ + deno?: boolean; + } + export interface BrowserWindowOptions { title?: string; /** @default {800} */ @@ -240,7 +247,12 @@ declare namespace Deno { hide(); focus(); navigate(url: string); - openDevtools(); + /** Open a DevTools window. + * + * By default both targets are shown. Pass an options object to + * select which targets to inspect. At least one must be `true`. + */ + openDevtools(options?: OpenDevtoolsOptions): void; reload(); setApplicationMenu(menu: MenuItem[]); diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 89b66b052ee54d..5068367aff9dbd 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -105,6 +105,13 @@ impl<'a> ToV8<'a> for ExecuteJsResult { } } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenDevtoolsOptions { + pub renderer: Option, + pub deno: Option, +} + /// A single event type that flows from the WEF backend to the Deno runtime. #[derive(Debug, serde::Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] @@ -300,7 +307,7 @@ pub trait DesktopApi: Send + Sync + 'static { raw_window_handle::RawDisplayHandle, ); - fn open_devtools(&self, window_id: u32); + fn open_devtools(&self, window_id: u32, renderer: bool, deno: bool); fn execute_js( &self, @@ -514,9 +521,21 @@ impl BrowserWindow { self.api.navigate(self.window_id, url); } - #[fast] - fn open_devtools(&self) { - self.api.open_devtools(self.window_id); + fn open_devtools( + &self, + #[serde] options: Option, + ) -> Result<(), deno_error::JsErrorBox> { + let (renderer, deno) = match options { + Some(opts) => (opts.renderer.unwrap_or(true), opts.deno.unwrap_or(true)), + None => (true, true), + }; + if !renderer && !deno { + return Err(deno_error::JsErrorBox::type_error( + "At least one of 'renderer' or 'deno' must be true", + )); + } + self.api.open_devtools(self.window_id, renderer, deno); + Ok(()) } #[fast] From c39dc4425e003a68816023eeea79bbaa97396b5e Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 17 Apr 2026 13:26:01 +0200 Subject: [PATCH 038/137] strip, even better debugging --- cli/rt/desktop.rs | 50 ++++++++++++++++++++++++++++++++++++++++++ cli/rt_desktop/lib.rs | 36 ++++++++++++++++++++++++++++++ cli/tools/desktop.rs | 51 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 7a93c2b0cd8e8c..4ce03c32e2a3ae 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -201,6 +201,12 @@ pub const DESKTOP_JS: &str = r#" // Per-window bind callback registry: windowId -> Map const windowBindCallbacks = new Map(); + // Binding-call correlation: when --inspect is active, both the Deno + // and renderer consoles emit matching console.debug messages so the + // developer can trace a binding call across isolates. + const bindingTrace = typeof Deno.env?.get === "function" + && Deno.env.get("DENO_DESKTOP_MUX_WS") != null; + const nativeBind = BrowserWindowPrototype.bind; BrowserWindowPrototype.bind = function(name, fn) { const windowId = this.windowId; @@ -209,6 +215,41 @@ pub const DESKTOP_JS: &str = r#" } windowBindCallbacks.get(windowId).set(name, fn.bind(this)); nativeBind.call(this, name); + + // Inject a renderer-side wrapper that emits console.debug around + // every binding call. The wrapper waits for the native binding to + // appear (CEF registers it asynchronously via IPC) and then + // replaces it with a logging shim. + if (bindingTrace) { + const escapedName = JSON.stringify(name); + this.executeJs(`(function() { + var n = ${escapedName}; + var seq = 0; + function tryWrap() { + if (typeof window.bindings === "undefined" || typeof window.bindings[n] !== "function") { + setTimeout(tryWrap, 10); + return; + } + var orig = window.bindings[n]; + if (orig.__bindTrace) return; + window.bindings[n] = async function() { + var id = ++seq; + var args = Array.prototype.slice.call(arguments); + console.debug("[binding:call]", n, ":" + id, args); + try { + var result = await orig.apply(this, arguments); + console.debug("[binding:return]", n, ":" + id, result); + return result; + } catch (e) { + console.debug("[binding:error]", n, ":" + id, e); + throw e; + } + }; + window.bindings[n].__bindTrace = true; + } + tryWrap(); + })();`); + } }; const nativeUnbind = BrowserWindowPrototype.unbind; @@ -294,9 +335,18 @@ pub const DESKTOP_JS: &str = r#" (async () => { try { const args = Array.isArray(ev.args) ? ev.args : []; + if (bindingTrace) { + console.debug("[binding:call]", ev.name, ":" + ev.callId, args); + } const result = await fn_(...args); + if (bindingTrace) { + console.debug("[binding:return]", ev.name, ":" + ev.callId, result); + } op_desktop_resolve_bind_call(ev.callId, result ?? null); } catch (e) { + if (bindingTrace) { + console.debug("[binding:error]", ev.name, ":" + ev.callId, String(e)); + } op_desktop_reject_bind_call(ev.callId, String(e)); } })(); diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index a630676379bb1e..3c6a02e6148f25 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -1258,9 +1258,45 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { // Wait for the server to be ready, then navigate the initial window. // Do a full HTTP request instead of just a TCP connect — frameworks // like Vite accept connections before they're ready to serve. + let wait_for_debugger = inspect_brk || inspect_wait; + let mux_addr = env::var("DENO_DESKTOP_MUX_WS").ok(); let navigate_fut = async move { use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; + + // When --inspect-wait or --inspect-brk: block until a DevTools + // client has connected to the mux. This prevents the renderer + // from racing ahead while the developer is still opening DevTools. + if wait_for_debugger { + if let Some(ref mux) = mux_addr { + eprintln!( + "[desktop] Waiting for debugger to attach on ws://{mux} …" + ); + loop { + if let Ok(mut stream) = + tokio::net::TcpStream::connect(mux.as_str()).await + { + let req = format!( + "GET /debugger-attached HTTP/1.1\r\nHost: {mux}\r\nConnection: close\r\n\r\n", + ); + if stream.write_all(req.as_bytes()).await.is_ok() { + let mut buf = vec![0u8; 128]; + if let Ok(n) = stream.read(&mut buf).await { + let resp = String::from_utf8_lossy(&buf[..n]); + if resp.starts_with("HTTP/1.1 200") + || resp.starts_with("HTTP/1.0 200") + { + eprintln!("[desktop] Debugger attached"); + break; + } + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + } + for i in 0..60 { if let Ok(mut stream) = tokio::net::TcpStream::connect(("127.0.0.1", desktop_serve_port)).await diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 2a8893acc02fb4..b85c2f39b7df4e 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -398,11 +398,15 @@ async fn run_desktop_hmr( .parse() .unwrap(), }; + let wait_for_debugger = + flags.inspect_brk.is_some() || flags.inspect_wait.is_some(); let handle = crate::tools::desktop_devtools::spawn_mux( crate::tools::desktop_devtools::MuxConfig { listen: user_addr, deno_internal, cef_internal, + inspect_brk: flags.inspect_brk.is_some(), + wait_for_debugger, }, ) .await?; @@ -556,7 +560,9 @@ async fn package_windows_app_dir( // Copy the compiled dylib (denort.dll) alongside the backend binary. let dylib_filename = dylib_path.file_name().unwrap(); - std::fs::copy(dylib_path, app_dir.join(dylib_filename))?; + let dest_dylib = app_dir.join(dylib_filename); + std::fs::copy(dylib_path, &dest_dylib)?; + strip_dylib(&dest_dylib); // Create a .bat launcher that invokes the backend with --runtime. let launcher_path = app_dir.join(format!("{}.bat", app_name)); @@ -669,7 +675,9 @@ async fn package_linux_app_dir( // Copy the compiled dylib alongside the backend binary. let dylib_filename = dylib_path.file_name().unwrap(); - std::fs::copy(dylib_path, app_dir.join(dylib_filename))?; + let dest_dylib = app_dir.join(dylib_filename); + std::fs::copy(dylib_path, &dest_dylib)?; + strip_dylib(&dest_dylib); // Create a shell launcher that invokes the backend with --runtime. // --ozone-platform=x11 forces CEF to create X11 windows (via XWayland on @@ -1161,9 +1169,11 @@ async fn package_macos_app_bundle( // Strip unnecessary bulk from the CEF framework. strip_cef_bloat(&contents_dir); - // Copy the compiled dylib. + // Copy the compiled dylib and strip symbols. let dylib_filename = dylib_path.file_name().unwrap(); - std::fs::copy(dylib_path, macos_dir.join(dylib_filename))?; + let dest_dylib = macos_dir.join(dylib_filename); + std::fs::copy(dylib_path, &dest_dylib)?; + strip_dylib(&dest_dylib); // Create launcher script as the main executable. let launcher_path = macos_dir.join(&app_name); @@ -1460,6 +1470,39 @@ fn create_linux_appimage( Ok(()) } +/// Strip local symbols from the compiled dylib to reduce size. +/// +/// On macOS uses `strip -x` (remove local symbols, keep global/debug-essential). +/// On Linux uses `strip --strip-unneeded` (remove symbols not needed for relocation). +fn strip_dylib(dylib_path: &Path) { + let strip_args: &[&str] = if cfg!(target_os = "macos") { + &["-x", dylib_path.to_str().unwrap_or_default()] + } else if cfg!(target_os = "linux") { + &[ + "--strip-unneeded", + dylib_path.to_str().unwrap_or_default(), + ] + } else { + return; + }; + + match std::process::Command::new("strip").args(strip_args).output() { + Ok(output) if output.status.success() => { + log::info!("Stripped symbols from {}", dylib_path.display()); + } + Ok(output) => { + log::warn!( + "strip exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + Err(e) => { + log::warn!("Failed to run strip on {}: {}", dylib_path.display(), e); + } + } +} + /// Recursively copy a directory tree, ensuring writable permissions on the /// destination. This is needed because source files from the Nix store are /// read-only. From 254ee1c636876bd0037312bd2c874099955201cb Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 21 Apr 2026 17:02:46 +0200 Subject: [PATCH 039/137] rework update js, implement isclosed & reload --- cli/rt/desktop.rs | 120 ++++++++++++++++-------------- cli/rt_desktop/lib.rs | 21 ++++-- cli/tools/desktop.rs | 10 +-- cli/tsc/dts/lib.deno.desktop.d.ts | 35 +++++++++ runtime/ops/desktop.rs | 9 ++- 5 files changed, 128 insertions(+), 67 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index 4ce03c32e2a3ae..f83087a4f862c9 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -478,74 +478,86 @@ pub fn desktop_auto_update_js( op_desktop_apply_patch, op_desktop_confirm_update, }} = Deno[Deno.internal].core.ops; + const {{ propReadOnly, propWritable }} = Deno[Deno.internal].core; const _version = {version}; const _rolledBack = {rolled_back}; - if (_rolledBack) {{ - queueMicrotask(() => {{ - dispatchEvent( - new CustomEvent("desktop-update-rollback", {{ - detail: {{ reason: "Update failed to start, rolled back." }}, - }}), - ); - }}); - }} else {{ + const ROLLBACK_REASON = "Update failed to start, rolled back."; + + if (!_rolledBack) {{ op_desktop_confirm_update(); }} let autoUpdateTimer = null; - Deno.desktop = {{ - get version() {{ return _version; }}, - - autoUpdate(urlOrOpts) {{ - const opts = typeof urlOrOpts === "string" - ? {{ url: urlOrOpts }} - : urlOrOpts; - const {{ url, interval }} = opts; - if (!_version) {{ - console.warn("Deno.desktop.autoUpdate: no version in deno.json, skipping"); - return; - }} + function autoUpdate(urlOrOpts) {{ + const opts = typeof urlOrOpts === "string" + ? {{ url: urlOrOpts }} + : (urlOrOpts ?? {{}}); + const {{ url, interval, onUpdateReady, onRollback }} = opts; + + if (_rolledBack && typeof onRollback === "function") {{ + queueMicrotask(() => {{ + try {{ onRollback(ROLLBACK_REASON); }} catch (e) {{ + console.error("Deno.autoUpdate onRollback threw:", e); + }} + }}); + }} + + if (!_version) {{ + console.warn("Deno.autoUpdate: no version in deno.json, skipping"); + return; + }} + if (typeof url !== "string" || url.length === 0) {{ + console.warn("Deno.autoUpdate: missing 'url' option, skipping"); + return; + }} - const check = async () => {{ - try {{ - const manifestUrl = url.replace(/\/$/, "") + "/latest.json"; - const resp = await fetch(manifestUrl); - if (!resp.ok) return; - const manifest = await resp.json(); - if (manifest.version === _version) return; - - const patchName = manifest.patches?.[_version]; - if (patchName) {{ - const patchUrl = url.replace(/\/$/, "") + "/" + patchName; - const patchResp = await fetch(patchUrl); - if (patchResp.ok) {{ - const patchBytes = new Uint8Array(await patchResp.arrayBuffer()); - op_desktop_apply_patch(patchBytes); - dispatchEvent( - new CustomEvent("desktop-update-ready", {{ - detail: {{ version: manifest.version }}, - }}), - ); - if (autoUpdateTimer) clearInterval(autoUpdateTimer); - return; + const base = url.replace(/\/$/, ""); + + const check = async () => {{ + try {{ + const resp = await fetch(base + "/latest.json"); + if (!resp.ok) return; + const manifest = await resp.json(); + if (manifest.version === _version) return; + + const patchName = manifest.patches?.[_version]; + if (patchName) {{ + const patchResp = await fetch(base + "/" + patchName); + if (patchResp.ok) {{ + const patchBytes = new Uint8Array(await patchResp.arrayBuffer()); + op_desktop_apply_patch(patchBytes); + if (typeof onUpdateReady === "function") {{ + try {{ onUpdateReady(manifest.version); }} catch (e) {{ + console.error("Deno.autoUpdate onUpdateReady threw:", e); + }} }} + if (autoUpdateTimer) {{ + clearInterval(autoUpdateTimer); + autoUpdateTimer = null; + }} + return; }} - console.warn("Deno.desktop.autoUpdate: no patch available for", - _version, "->", manifest.version); - }} catch (e) {{ - console.warn("Deno.desktop.autoUpdate: check failed:", e.message); }} - }}; - - setTimeout(check, 1000); - if (interval) {{ - autoUpdateTimer = setInterval(check, interval); + console.warn("Deno.autoUpdate: no patch available for", + _version, "->", manifest.version); + }} catch (e) {{ + console.warn("Deno.autoUpdate: check failed:", e.message); }} - }}, - }}; + }}; + + setTimeout(check, 1000); + if (interval) {{ + autoUpdateTimer = setInterval(check, interval); + }} + }} + + Object.defineProperties(Deno, {{ + desktopVersion: propReadOnly(_version), + autoUpdate: propWritable(autoUpdate), + }}); }})(); "#, version = match version { diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index 3c6a02e6148f25..a0f3d6582cef45 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -12,10 +12,12 @@ //! and navigates the webview to it. use std::borrow::Cow; +use std::collections::HashSet; use std::env; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; @@ -43,6 +45,7 @@ struct WefDesktopApi { deno_runtime::ops::desktop::DesktopEvent, >, pending_responses: deno_runtime::ops::desktop::PendingBindResponses, + closed_windows: Arc>>, } impl WefDesktopApi { @@ -58,6 +61,7 @@ impl WefDesktopApi { let resize_tx = self.event_tx.clone(); let move_tx = self.event_tx.clone(); let close_tx = self.event_tx.clone(); + let closed_windows = self.closed_windows.clone(); window .on_keyboard_event(move |ev| { @@ -175,6 +179,7 @@ impl WefDesktopApi { }); }) .on_close_requested(move |ev| { + closed_windows.lock().unwrap().insert(ev.window_id); let _ = close_tx.send( deno_runtime::ops::desktop::DesktopEvent::CloseRequested { window_id: ev.window_id, @@ -192,9 +197,14 @@ impl denort::desktop::DesktopApi for WefDesktopApi { } fn close_window(&self, window_id: u32) { + self.closed_windows.lock().unwrap().insert(window_id); wef::Window::from_id(window_id).close(); } + fn is_closed(&self, window_id: u32) -> bool { + self.closed_windows.lock().unwrap().contains(&window_id) + } + fn set_title(&self, window_id: u32, title: &str) { wef::Window::from_id(window_id).set_title(title); } @@ -255,10 +265,10 @@ impl denort::desktop::DesktopApi for WefDesktopApi { (false, true) => ("/deno", "js_app.html"), (false, false) => unreachable!(), }; - let url = format!( - "http://{mux}/devtools/{frontend}?ws={mux}{endpoint}" + let url = format!("http://{mux}/devtools/{frontend}?ws={mux}{endpoint}"); + log::info!( + "[desktop] openDevtools(renderer={renderer}, deno={deno}) → {url}" ); - log::info!("[desktop] openDevtools(renderer={renderer}, deno={deno}) → {url}"); let window = wef::Window::new(1200, 800); window.set_title("Deno Desktop DevTools"); window.navigate(&url); @@ -1219,6 +1229,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { let api = WefDesktopApi { event_tx: event_tx.0.clone(), pending_responses: pending_responses.clone(), + closed_windows: Arc::new(Mutex::new(HashSet::new())), }; // Create the initial window and wire up event handlers. @@ -1269,9 +1280,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { // from racing ahead while the developer is still opening DevTools. if wait_for_debugger { if let Some(ref mux) = mux_addr { - eprintln!( - "[desktop] Waiting for debugger to attach on ws://{mux} …" - ); + eprintln!("[desktop] Waiting for debugger to attach on ws://{mux} …"); loop { if let Ok(mut stream) = tokio::net::TcpStream::connect(mux.as_str()).await diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index b85c2f39b7df4e..697da268c02a96 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -1478,15 +1478,15 @@ fn strip_dylib(dylib_path: &Path) { let strip_args: &[&str] = if cfg!(target_os = "macos") { &["-x", dylib_path.to_str().unwrap_or_default()] } else if cfg!(target_os = "linux") { - &[ - "--strip-unneeded", - dylib_path.to_str().unwrap_or_default(), - ] + &["--strip-unneeded", dylib_path.to_str().unwrap_or_default()] } else { return; }; - match std::process::Command::new("strip").args(strip_args).output() { + match std::process::Command::new("strip") + .args(strip_args) + .output() + { Ok(output) if output.status.success() => { log::info!("Stripped symbols from {}", dylib_path.display()); } diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 797bb59f898013..00d43f717ad066 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -96,6 +96,41 @@ declare class WheelEvent extends MouseEvent { declare namespace Deno { export {}; // stop default export type behavior + /** The application version read from `deno.json` at compile time, or + * `null` if no version was configured. Only available in apps compiled + * with `deno desktop`. */ + export const desktopVersion: string | null; + + export interface AutoUpdateOptions { + /** Base URL of the release server hosting `latest.json` and patch + * files. Required unless a string URL is passed directly to + * {@linkcode Deno.autoUpdate}. */ + url?: string; + /** Poll interval in milliseconds. If omitted, only a single check is + * performed ~1s after the call. */ + interval?: number; + /** Called once an update has been downloaded and staged for the next + * launch. */ + onUpdateReady?: (version: string) => void; + /** Called if the previous launch's update failed to start and was + * rolled back. */ + onRollback?: (reason: string) => void; + } + + /** Start polling a release server for binary-diff updates. + * + * The manifest at `/latest.json` is fetched and compared against + * {@linkcode Deno.desktopVersion}. If a newer version is available and + * a patch from the current version exists, the patch is downloaded and + * staged for the next launch, and `onUpdateReady` is invoked. + * + * If the previous launch's update failed and was rolled back, + * `onRollback` is invoked shortly after this call. + * + * Only available in apps compiled with `deno desktop`. */ + export function autoUpdate(url: string): void; + export function autoUpdate(options: AutoUpdateOptions): void; + export interface OpenDevtoolsOptions { /** Inspect the CEF renderer isolate. @default {true} */ renderer?: boolean; diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 5068367aff9dbd..85e8643aa675bc 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -265,6 +265,9 @@ pub trait DesktopApi: Send + Sync + 'static { fn create_window(&self, width: i32, height: i32) -> u32; /// Close a specific window. fn close_window(&self, window_id: u32); + /// Returns true if the given window has been closed (either via + /// `close_window` or because the OS window was destroyed). + fn is_closed(&self, window_id: u32) -> bool; fn set_title(&self, window_id: u32, title: &str); @@ -488,7 +491,7 @@ impl BrowserWindow { #[fast] fn is_closed(&self) -> bool { - todo!("implement") + self.api.is_closed(self.window_id) } #[fast] @@ -540,7 +543,9 @@ impl BrowserWindow { #[fast] fn reload(&self) { - todo!("implement") + self + .api + .execute_js(self.window_id, "location.reload()", Box::new(|_| {})); } async fn execute_js( From bf62c36e62e15f33a1e4e317786690858a02089f Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 21 Apr 2026 17:07:39 +0200 Subject: [PATCH 040/137] fix --- cli/tools/desktop_devtools.rs | 1405 +++++++++++++++++++++++++++++++++ 1 file changed, 1405 insertions(+) create mode 100644 cli/tools/desktop_devtools.rs diff --git a/cli/tools/desktop_devtools.rs b/cli/tools/desktop_devtools.rs new file mode 100644 index 00000000000000..42b537aadd468e --- /dev/null +++ b/cli/tools/desktop_devtools.rs @@ -0,0 +1,1405 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! CDP multiplexer for `deno desktop --inspect`. +//! +//! Architecture: +//! +//! ```text +//! ┌──────────────────────────────────┐ +//! │ CDP Multiplexer (this file) │ +//! DevTools ◄──► │ - /json/version, /json/list │ +//! (single ws) │ - /unified (primary entry) │ +//! │ - /deno, /cef (direct bypass) │ +//! │ - /devtools/* (frontend proxy) │ +//! └──────┬───────────────────┬───────┘ +//! │ │ +//! Deno inspector CEF renderer +//! (internal port) (internal port) +//! ``` +//! +//! The `/unified` endpoint is the primary session. CEF is the default +//! (un-sessioned) target; the Deno runtime appears as an attached child +//! via a synthetic `Target.attachedToTarget` event with a stable +//! `sessionId`. The mux routes frames by `sessionId` — Deno-bound +//! frames have the sessionId stripped before forwarding, and responses +//! get it re-injected. Everything else goes to CEF verbatim. +//! +//! DevTools sees both isolates in one window: the Console dropdown +//! shows "Renderer" / "Deno", and the Sources panel Threads sidebar +//! lists both. +//! +//! `/deno` and `/cef` are direct passthrough endpoints for debugging +//! each isolate in isolation. `/devtools/*` proxies CEF's bundled +//! DevTools frontend assets so `openDevtools()` can pop a CEF window +//! without triggering the remote-debugging-port interception. + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use deno_core::anyhow::Context; +use deno_core::anyhow::anyhow; +use deno_core::anyhow::bail; +use deno_core::error::AnyError; +use deno_core::serde_json; +use deno_core::serde_json::Value; +use deno_core::serde_json::json; +use fastwebsockets::Frame; +use fastwebsockets::OpCode; +use fastwebsockets::WebSocket; +use fastwebsockets::WebSocketError; +use fastwebsockets::handshake; +use http_body_util::BodyExt; +use http_body_util::Empty; +use http_body_util::Full; +use hyper::body::Bytes; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use uuid::Uuid; + +/// Configuration for the CDP multiplexer. +#[derive(Clone, Debug)] +pub struct MuxConfig { + /// User-visible listen address (from `--inspect`). + pub listen: SocketAddr, + /// Internal listen address for Deno's native inspector. + pub deno_internal: SocketAddr, + /// Internal listen address for the CEF renderer debug port. + pub cef_internal: SocketAddr, + /// `true` when `--inspect-brk` was passed — the mux will inject + /// `Debugger.enable` + `Debugger.pause` into the CEF session so the + /// renderer breaks on the first JS statement after navigation. + pub inspect_brk: bool, + /// `true` when `--inspect-wait` or `--inspect-brk` was passed. + /// Not read by the mux itself — the child process uses env vars to + /// decide whether to poll `/debugger-attached` before navigating. + #[allow(dead_code)] + pub wait_for_debugger: bool, +} + +/// A running multiplexer. Dropping the handle shuts the server down. +pub struct MuxHandle { + pub listen: SocketAddr, + _shutdown_tx: oneshot::Sender<()>, +} + +/// Bind to an ephemeral TCP port and return its number. The listener is +/// dropped before the port is reused by the caller; on the loopback +/// interface the small race window is acceptable for a debugger-only port. +pub fn allocate_random_port() -> std::io::Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + Ok(listener.local_addr()?.port()) +} + +/// Spawn the mux on a background task. Returns once the listener is +/// bound — connection handling continues in the spawned task. The +/// upstream servers (Deno inspector, CEF) do not need to be up yet; +/// the mux polls them on demand when DevTools makes a request. +pub async fn spawn_mux(config: MuxConfig) -> Result { + let listener = TcpListener::bind(config.listen).await.with_context(|| { + format!("failed to bind CDP multiplexer to {}", config.listen) + })?; + let listen = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + + let state = Arc::new(MuxState::new(config.clone(), listen)); + + tokio::spawn(async move { + let mut shutdown_rx = shutdown_rx; + loop { + tokio::select! { + _ = &mut shutdown_rx => { + log::debug!("[devtools-mux] shutdown requested"); + break; + } + accept = listener.accept() => { + match accept { + Ok((stream, _)) => { + let state = state.clone(); + tokio::spawn(async move { + if let Err(err) = serve_connection(stream, state).await { + log::debug!("[devtools-mux] connection error: {err:?}"); + } + }); + } + Err(err) => { + log::error!("[devtools-mux] accept failed: {err:?}"); + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + } + } + } + }); + + Ok(MuxHandle { + listen, + _shutdown_tx: shutdown_tx, + }) +} + +/// Per-target identification. Kept small and stable so DevTools' +/// `webSocketDebuggerUrl` doesn't change across polls. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TargetKind { + /// Single CDP session fronting both isolates. CEF is the primary; + /// Deno appears as an attached child target via `Target.*`. + Unified, + /// Direct passthrough to the Deno inspector. + Deno, + /// Direct passthrough to the CEF renderer's debug port. + Cef, +} + +impl TargetKind { + fn path(self) -> &'static str { + match self { + TargetKind::Unified => "/unified", + TargetKind::Deno => "/deno", + TargetKind::Cef => "/cef", + } + } + + fn title(self) -> &'static str { + match self { + TargetKind::Unified => "Deno Desktop (unified)", + TargetKind::Deno => "Deno Runtime", + TargetKind::Cef => "CEF Renderer", + } + } +} + +/// Synthetic CDP target id for the Deno isolate inside the unified +/// session. Stable per process; DevTools uses it in `Target.attachToTarget`. +const DENO_CHILD_TARGET_ID: &str = "deno-runtime-isolate"; + +struct MuxState { + config: MuxConfig, + listen: SocketAddr, + // Stable UUIDs per target so repeated /json/list calls return the + // same IDs. DevTools caches these. + unified_id: Uuid, + deno_id: Uuid, + cef_id: Uuid, + // Stable session id we hand DevTools when it attaches to the Deno + // child target inside the unified session. + deno_session_id: String, + // Set to `true` when a DevTools client has connected to any session. + // The child process polls `/debugger-attached` to gate navigation + // when `--inspect-wait` or `--inspect-brk` is active. + debugger_attached: Arc, +} + +impl MuxState { + fn new(config: MuxConfig, listen: SocketAddr) -> Self { + Self { + config, + listen, + unified_id: Uuid::new_v4(), + deno_id: Uuid::new_v4(), + cef_id: Uuid::new_v4(), + deno_session_id: Uuid::new_v4().to_string(), + debugger_attached: Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + fn target_for_path(&self, path: &str) -> Option { + if path == TargetKind::Unified.path() { + Some(TargetKind::Unified) + } else if path == TargetKind::Deno.path() { + Some(TargetKind::Deno) + } else if path == TargetKind::Cef.path() { + Some(TargetKind::Cef) + } else { + None + } + } +} + +async fn serve_connection( + stream: TcpStream, + state: Arc, +) -> Result<(), AnyError> { + let io = TokioIo::new(stream); + let service = hyper::service::service_fn(move |req| { + let state = state.clone(); + async move { Ok::<_, Infallible>(handle_request(req, state).await) } + }); + + hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() + .await + .map_err(|e| anyhow!("hyper serve error: {e}"))?; + Ok(()) +} + +async fn handle_request( + req: hyper::Request, + state: Arc, +) -> hyper::Response> { + if req.method() != http::Method::GET { + return simple_response( + http::StatusCode::METHOD_NOT_ALLOWED, + "Not Allowed", + ); + } + let path = req.uri().path().to_string(); + match path.as_str() { + "/json/version" => json_version(&state), + "/json" | "/json/list" => json_list(&state).await, + "/json/protocol" => json_protocol(), + "/debugger-attached" => { + if state + .debugger_attached + .load(std::sync::atomic::Ordering::SeqCst) + { + simple_response(http::StatusCode::OK, "attached") + } else { + simple_response(http::StatusCode::SERVICE_UNAVAILABLE, "waiting") + } + } + other => { + if let Some(kind) = state.target_for_path(other) { + match handle_upgrade(req, kind, state.clone()).await { + Ok(resp) => resp, + Err(err) => { + log::error!("[devtools-mux] upgrade failed for {other}: {err:?}"); + simple_response(http::StatusCode::BAD_REQUEST, "upgrade failed") + } + } + } else if other.starts_with("/devtools/") { + // Proxy DevTools frontend assets from CEF's bundled HTTP server. + // Serving them through our port means the CEF window navigating + // to the DevTools URL sees only `127.0.0.1:`, so it + // doesn't special-case the remote-debugging port and steal the + // frontend for the new window's own renderer. + match proxy_devtools_asset(&req, state.config.cef_internal).await { + Ok(resp) => resp, + Err(err) => { + log::error!("[devtools-mux] devtools asset proxy failed: {err:?}"); + simple_response(http::StatusCode::BAD_GATEWAY, "proxy failed") + } + } + } else { + simple_response(http::StatusCode::NOT_FOUND, "Not Found") + } + } + } +} + +/// GET `http://` and return the response verbatim. +/// Used to proxy the bundled DevTools frontend (inspector.html + its +/// JS/CSS assets) through the mux's own port, bypassing CEF's +/// remote-debugging-port special-case that would otherwise wire the +/// frontend to the requesting window's renderer. +async fn proxy_devtools_asset( + req: &hyper::Request, + cef_internal: SocketAddr, +) -> Result>, AnyError> { + let path_and_query = req + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"); + + let stream = TcpStream::connect(cef_internal).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = hyper::client::conn::http1::handshake(io) + .await + .map_err(|e| anyhow!("devtools asset handshake failed: {e}"))?; + tokio::spawn(async move { + if let Err(err) = conn.await { + log::trace!("[devtools-mux] devtools asset conn closed: {err:?}"); + } + }); + + let upstream_req = hyper::Request::builder() + .method(http::Method::GET) + .uri(path_and_query) + .header(http::header::HOST, cef_internal.to_string()) + .body(Empty::::new())?; + let resp = sender.send_request(upstream_req).await?; + let (parts, body) = resp.into_parts(); + let bytes = body.collect().await?.to_bytes(); + + let mut builder = hyper::Response::builder().status(parts.status); + // Drop hop-by-hop headers that don't apply to our re-packaged body. + for (name, value) in parts.headers.iter() { + let skip = matches!( + name.as_str().to_ascii_lowercase().as_str(), + "transfer-encoding" + | "content-length" + | "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "upgrade" + ); + if !skip { + builder = builder.header(name, value); + } + } + Ok( + builder + .header(http::header::CONTENT_LENGTH, bytes.len()) + .body(Full::new(bytes)) + .unwrap(), + ) +} + +fn json_version(state: &MuxState) -> hyper::Response> { + let body = json!({ + "Browser": format!("deno-desktop/{}", env!("CARGO_PKG_VERSION")), + "Protocol-Version": "1.3", + "V8-Version": deno_core::v8::VERSION_STRING, + // Advertise one of the two upstream WS URLs as the "browser" URL. + // DevTools' `chrome://inspect` uses this to drive auto-attach; the + // CEF upstream exposes the richer Target.* domain. + "webSocketDebuggerUrl": format!( + "ws://{}{}", + state.listen, + TargetKind::Cef.path(), + ), + }); + json_response(body) +} + +async fn json_list(state: &MuxState) -> hyper::Response> { + let listen = state.listen.to_string(); + let unified_url = format!("ws://{listen}{}", TargetKind::Unified.path()); + let deno_url = format!("ws://{listen}{}", TargetKind::Deno.path()); + let cef_url = format!("ws://{listen}{}", TargetKind::Cef.path()); + + // Primary entry: the unified session. DevTools opens one window + // (inspector.html — full browser DevTools) and gets both isolates as + // attached targets in the Sources panel. + let unified_entry = json!({ + "id": state.unified_id.to_string(), + "type": "page", + "title": TargetKind::Unified.title(), + "description": "Unified DevTools (CEF page + Deno runtime)", + "url": "deno-desktop://unified", + "faviconUrl": "https://deno.land/favicon.ico", + "devtoolsFrontendUrl": format!( + "devtools://devtools/bundled/inspector.html?ws={}", + strip_scheme(&unified_url), + ), + "webSocketDebuggerUrl": unified_url, + }); + // Fallback entries: direct passthrough to each isolate. Useful when + // the unified session misbehaves and you want to debug each side in + // isolation. + let deno_entry = json!({ + "id": state.deno_id.to_string(), + "type": "node", + "title": TargetKind::Deno.title(), + "description": "Deno runtime V8 isolate (direct)", + "url": format!("deno://{}", state.config.deno_internal), + "faviconUrl": "https://deno.land/favicon.ico", + "devtoolsFrontendUrl": format!( + "devtools://devtools/bundled/js_app.html?ws={}&experiments=true&v8only=true", + strip_scheme(&deno_url), + ), + "webSocketDebuggerUrl": deno_url, + }); + let cef_entry = json!({ + "id": state.cef_id.to_string(), + "type": "page", + "title": TargetKind::Cef.title(), + "description": "CEF renderer V8 isolate (direct)", + "url": format!("cef://{}", state.config.cef_internal), + "faviconUrl": "https://deno.land/favicon.ico", + "devtoolsFrontendUrl": format!( + "devtools://devtools/bundled/inspector.html?ws={}", + strip_scheme(&cef_url), + ), + "webSocketDebuggerUrl": cef_url, + }); + + json_response(Value::Array(vec![unified_entry, deno_entry, cef_entry])) +} + +/// Return an empty protocol descriptor. DevTools tolerates this and +/// falls back to the built-in protocol. +fn json_protocol() -> hyper::Response> { + json_response(json!({ + "version": { "major": "1", "minor": "3" }, + "domains": [], + })) +} + +fn json_response(value: Value) -> hyper::Response> { + let body = Full::new(Bytes::from(serde_json::to_vec(&value).unwrap())); + hyper::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/json") + .body(body) + .unwrap() +} + +fn simple_response( + status: http::StatusCode, + msg: &'static str, +) -> hyper::Response> { + hyper::Response::builder() + .status(status) + .body(Full::new(Bytes::from(msg))) + .unwrap() +} + +fn strip_scheme(ws_url: &str) -> String { + ws_url + .strip_prefix("ws://") + .or_else(|| ws_url.strip_prefix("wss://")) + .unwrap_or(ws_url) + .to_string() +} + +/// Upgrade an incoming HTTP request to a WebSocket, open a matching +/// WebSocket to the upstream target, and shuttle frames in both +/// directions. +async fn handle_upgrade( + mut req: hyper::Request, + kind: TargetKind, + state: Arc, +) -> Result>, AnyError> { + let (resp, upgrade_fut) = fastwebsockets::upgrade::upgrade(&mut req) + .map_err(|e| anyhow!("not a valid websocket upgrade: {e}"))?; + + tokio::spawn(async move { + let client = match upgrade_fut.await { + Ok(ws) => ws, + Err(err) => { + log::error!("[devtools-mux] client upgrade failed: {err:?}"); + return; + } + }; + + // Signal that a debugger has connected — the child process polls + // `/debugger-attached` to gate navigation under --inspect-wait/brk. + state + .debugger_attached + .store(true, std::sync::atomic::Ordering::SeqCst); + + match kind { + TargetKind::Unified => { + if let Err(err) = run_unified_session(client, state).await { + log::debug!("[devtools-mux] unified session ended: {err:?}"); + } + } + TargetKind::Deno | TargetKind::Cef => { + match connect_upstream(&state, kind).await { + Ok(upstream) => { + if let Err(err) = proxy_frames(client, upstream).await { + log::debug!("[devtools-mux] proxy ended: {err:?}"); + } + } + Err(err) => { + log::error!( + "[devtools-mux] failed to connect upstream for {kind:?}: {err:?}" + ); + } + } + } + } + }); + + let (parts, _) = resp.into_parts(); + Ok(hyper::Response::from_parts(parts, Full::new(Bytes::new()))) +} + +/// Open a WebSocket to the right upstream target, performing target +/// discovery as needed. For CEF we hit `/json/list` to find the live +/// `webSocketDebuggerUrl`; for Deno we call the inspector server's +/// same endpoint. The upstream is retried briefly since the renderer +/// may not have finished booting. +async fn connect_upstream( + state: &MuxState, + kind: TargetKind, +) -> Result>, AnyError> { + let upstream_host = match kind { + TargetKind::Deno => state.config.deno_internal, + TargetKind::Cef => state.config.cef_internal, + TargetKind::Unified => { + bail!("connect_upstream cannot be called with the Unified target") + } + }; + + // Poll until the upstream has a live WebSocket debugger URL. + let mut last_err: Option = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + while tokio::time::Instant::now() < deadline { + match fetch_upstream_ws_url(upstream_host).await { + Ok(ws_url) => match connect_ws(&ws_url).await { + Ok(ws) => return Ok(ws), + Err(err) => { + last_err = Some(err); + } + }, + Err(err) => { + last_err = Some(err); + } + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + Err(last_err.unwrap_or_else(|| anyhow!("upstream connect timed out"))) +} + +/// GET `http:///json/list` and pick the first entry's +/// `webSocketDebuggerUrl`. Both Deno's inspector server and CEF's +/// remote-debugging endpoint implement this. +async fn fetch_upstream_ws_url(host: SocketAddr) -> Result { + let stream = TcpStream::connect(host).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = hyper::client::conn::http1::handshake(io) + .await + .map_err(|e| anyhow!("http handshake to {host} failed: {e}"))?; + tokio::spawn(async move { + if let Err(err) = conn.await { + log::trace!("[devtools-mux] upstream conn closed: {err:?}"); + } + }); + + let req = hyper::Request::builder() + .method(http::Method::GET) + .uri("/json/list") + .header(http::header::HOST, host.to_string()) + .body(Empty::::new())?; + let resp = sender.send_request(req).await?; + if !resp.status().is_success() { + bail!("upstream /json/list at {host} returned {}", resp.status()); + } + let body = resp.collect().await?.to_bytes(); + let value: Value = serde_json::from_slice(&body) + .with_context(|| format!("upstream /json/list at {host} not JSON"))?; + + let ws_url = value + .as_array() + .and_then(|arr| { + arr.iter().find_map(|v| { + // Skip targets that are our own DevTools frontend window — when + // openDevtools() creates a CEF window pointed at inspector.html, + // CEF registers it as a debuggable target. Connecting to it + // instead of the real app window would show "DevTools for + // DevTools". + let url = v.get("url").and_then(|u| u.as_str()).unwrap_or(""); + if url.contains("/devtools/") || url.contains("devtools://") { + return None; + } + v.get("webSocketDebuggerUrl") + }) + }) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow!("no webSocketDebuggerUrl in /json/list at {host}") + })?; + + // Upstream responds with its own listen host; some backends return + // `0.0.0.0` or `localhost`. Force to the target host so we connect + // to the right address. + let rewritten = rewrite_ws_host(ws_url, host); + Ok(rewritten) +} + +fn rewrite_ws_host(ws_url: &str, host: SocketAddr) -> String { + let rest = ws_url + .strip_prefix("ws://") + .or_else(|| ws_url.strip_prefix("wss://")) + .unwrap_or(ws_url); + let path = rest.find('/').map(|i| &rest[i..]).unwrap_or("/"); + format!("ws://{host}{path}") +} + +async fn connect_ws( + ws_url: &str, +) -> Result>, AnyError> { + let url: http::Uri = ws_url.parse()?; + let host = url + .host() + .ok_or_else(|| anyhow!("ws url missing host: {ws_url}"))?; + let port = url.port_u16().unwrap_or(80); + let authority = format!("{host}:{port}"); + + let stream = TcpStream::connect(&authority).await?; + let req = hyper::Request::builder() + .method(http::Method::GET) + .uri(url.path_and_query().map(|p| p.as_str()).unwrap_or("/")) + .header(http::header::HOST, &authority) + .header(http::header::UPGRADE, "websocket") + .header(http::header::CONNECTION, "upgrade") + .header("Sec-WebSocket-Key", handshake::generate_key()) + .header("Sec-WebSocket-Version", "13") + .body(Empty::::new())?; + + let (ws, _) = handshake::client(&TokioExec, req, stream).await?; + Ok(ws) +} + +struct TokioExec; +impl hyper::rt::Executor for TokioExec +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + fn execute(&self, fut: F) { + tokio::spawn(fut); + } +} + +/// Bidirectionally forward frames between the DevTools client and the +/// upstream inspector. The loop ends when either side closes or errors. +async fn proxy_frames( + mut client: WebSocket>, + mut upstream: WebSocket>, +) -> Result<(), AnyError> { + // We forward control frames (ping/pong/close) verbatim between the + // two peers, so disable fastwebsockets' built-in handling. + client.set_auto_close(false); + client.set_auto_pong(false); + upstream.set_auto_close(false); + upstream.set_auto_pong(false); + + // Split both sides so the two pump directions can run concurrently + // without holding a single mutex across `.await` points. + let (mut client_rx, mut client_tx) = client.split(tokio::io::split); + let (mut up_rx, mut up_tx) = upstream.split(tokio::io::split); + + let client_to_up = async { + // The send_fn is used by fastwebsockets to auto-respond to control + // frames; with auto_close/auto_pong disabled it is never called. + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match client_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] client read: {err:?}"); + return; + } + }; + let is_close = frame.opcode == OpCode::Close; + if let Err(err) = up_tx.write_frame(frame).await { + log::debug!("[devtools-mux] upstream write: {err:?}"); + return; + } + if is_close { + return; + } + } + }; + + let up_to_client = async { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match up_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] upstream read: {err:?}"); + return; + } + }; + let is_close = frame.opcode == OpCode::Close; + if let Err(err) = client_tx.write_frame(frame).await { + log::debug!("[devtools-mux] client write: {err:?}"); + return; + } + if is_close { + return; + } + } + }; + + tokio::join!(client_to_up, up_to_client); + Ok(()) +} + +// ─── Unified session (v2) ────────────────────────────────────────── +// +// One DevTools window, two isolates. CEF is the primary session; +// Deno appears as an "attached" child target via the standard +// `Target.attachedToTarget` event with a synthetic `sessionId`. +// Frames flow through CDP-aware routers that strip/inject `sessionId` +// on the Deno leg and pass everything else through to CEF. + +/// Owned representation of a WebSocket frame, suitable for sending +/// through an mpsc channel. `fastwebsockets::Frame` borrows from the +/// reader's internal buffer and so can't cross task boundaries. +struct OwnedFrame { + opcode: OpCode, + payload: Vec, +} + +impl OwnedFrame { + fn text(payload: Vec) -> Self { + Self { + opcode: OpCode::Text, + payload, + } + } + + fn into_frame(self) -> Frame<'static> { + Frame::new( + true, + self.opcode, + None, + fastwebsockets::Payload::Owned(self.payload), + ) + } +} + +/// Run the unified DevTools session: one client WebSocket fronting two +/// upstreams (CEF as the default session, Deno attached via a +/// synthetic `sessionId`). +async fn run_unified_session( + client: WebSocket>, + state: Arc, +) -> Result<(), AnyError> { + let mut client = client; + client.set_auto_close(false); + client.set_auto_pong(false); + + let (cef, deno) = tokio::try_join!( + connect_upstream(&state, TargetKind::Cef), + connect_upstream(&state, TargetKind::Deno), + )?; + let mut cef = cef; + let mut deno = deno; + cef.set_auto_close(false); + cef.set_auto_pong(false); + deno.set_auto_close(false); + deno.set_auto_pong(false); + + let session_id = state.deno_session_id.clone(); + + // When --inspect-brk is active, inject Debugger.enable + Debugger.pause + // into the CEF session BEFORE forwarding any client frames. This ensures + // the renderer pauses on the very first JS statement after navigation. + if state.config.inspect_brk { + let enable = json!({"id": -1, "method": "Debugger.enable"}); + let enable_bytes = serde_json::to_vec(&enable).unwrap(); + cef + .write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(enable_bytes), + )) + .await?; + + // Read the enable response before sending pause. + let _resp = cef.read_frame().await?; + + let pause = json!({"id": -2, "method": "Debugger.pause"}); + let pause_bytes = serde_json::to_vec(&pause).unwrap(); + cef + .write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(pause_bytes), + )) + .await?; + let _resp = cef.read_frame().await?; + + log::debug!( + "[devtools-mux] injected Debugger.enable + Debugger.pause into CEF" + ); + } + + let (mut client_rx, mut client_tx) = client.split(tokio::io::split); + let (mut cef_rx, mut cef_tx) = cef.split(tokio::io::split); + let (mut deno_rx, mut deno_tx) = deno.split(tokio::io::split); + + let (client_send, mut client_recv) = mpsc::unbounded_channel::(); + let (cef_send, mut cef_recv) = mpsc::unbounded_channel::(); + let (deno_send, mut deno_recv) = mpsc::unbounded_channel::(); + + // Deno is announced lazily, the first time DevTools asks about + // targets — firing too early causes the event to be dropped before + // the frontend's auto-attach manager has subscribed. See + // `route_client_text` for the trigger. + let deno_announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + // Writer tasks: pump owned frames from a channel into the WS half. + let client_writer = tokio::spawn(async move { + while let Some(owned) = client_recv.recv().await { + let close = owned.opcode == OpCode::Close; + if let Err(err) = client_tx.write_frame(owned.into_frame()).await { + log::debug!("[devtools-mux] unified client write: {err:?}"); + return; + } + if close { + return; + } + } + }); + let cef_writer = tokio::spawn(async move { + while let Some(owned) = cef_recv.recv().await { + let close = owned.opcode == OpCode::Close; + if let Err(err) = cef_tx.write_frame(owned.into_frame()).await { + log::debug!("[devtools-mux] unified cef write: {err:?}"); + return; + } + if close { + return; + } + } + }); + let deno_writer = tokio::spawn(async move { + while let Some(owned) = deno_recv.recv().await { + let close = owned.opcode == OpCode::Close; + if let Err(err) = deno_tx.write_frame(owned.into_frame()).await { + log::debug!("[devtools-mux] unified deno write: {err:?}"); + return; + } + if close { + return; + } + } + }); + + // Reader: client → CEF/Deno (with CDP-aware routing). + let mut client_reader = { + let client_send = client_send.clone(); + let cef_send = cef_send.clone(); + let deno_send = deno_send.clone(); + let session_id = session_id.clone(); + let deno_announced = deno_announced.clone(); + tokio::spawn(async move { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match client_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] unified client read: {err:?}"); + return; + } + }; + let opcode = frame.opcode; + let payload = frame.payload.to_vec(); + match opcode { + OpCode::Text => { + route_client_text( + &payload, + &session_id, + &client_send, + &cef_send, + &deno_send, + &deno_announced, + ); + } + OpCode::Close => { + let _ = cef_send.send(OwnedFrame { + opcode, + payload: payload.clone(), + }); + let _ = deno_send.send(OwnedFrame { opcode, payload }); + return; + } + _ => { + // Binary, Ping, Pong, Continuation: forward to CEF (the + // primary session). Ping/pong on the client connection is + // for keep-alive; CEF will reply. + let _ = cef_send.send(OwnedFrame { opcode, payload }); + } + } + } + }) + }; + + // Reader: CEF → client. No sessionId injection, but we do rewrite + // the execution-context name so the Console dropdown reads + // "Renderer" instead of V8's default "top". + let mut cef_reader = { + let client_send = client_send.clone(); + tokio::spawn(async move { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match cef_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] unified cef read: {err:?}"); + return; + } + }; + let opcode = frame.opcode; + let payload = frame.payload.to_vec(); + let owned = if opcode == OpCode::Text { + OwnedFrame::text(rewrite_text_from_upstream( + &payload, None, "Renderer", + )) + } else { + OwnedFrame { opcode, payload } + }; + if client_send.send(owned).is_err() { + return; + } + } + }) + }; + + // Reader: Deno → client. Inject sessionId so DevTools routes frames + // to the synthetic child target, and relabel the execution context + // to "Deno" instead of V8's "main realm". + let mut deno_reader = { + let client_send = client_send.clone(); + let session_id = session_id.clone(); + tokio::spawn(async move { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match deno_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] unified deno read: {err:?}"); + return; + } + }; + let opcode = frame.opcode; + let payload = frame.payload.to_vec(); + let owned = if opcode == OpCode::Text { + OwnedFrame::text(rewrite_text_from_upstream( + &payload, + Some(&session_id), + "Deno", + )) + } else { + OwnedFrame { opcode, payload } + }; + if client_send.send(owned).is_err() { + return; + } + } + }) + }; + + // Drop the originals so writers exit once readers finish. + drop(client_send); + drop(cef_send); + drop(deno_send); + + // Wait for any reader to exit, then tear down everything else. + tokio::select! { + _ = &mut client_reader => {}, + _ = &mut cef_reader => {}, + _ = &mut deno_reader => {}, + } + + client_reader.abort(); + cef_reader.abort(); + deno_reader.abort(); + client_writer.abort(); + cef_writer.abort(); + deno_writer.abort(); + + Ok(()) +} + +/// CDP-aware routing for a text frame coming from the DevTools client. +/// +/// - `sessionId == deno_session_id` → strip and send to Deno. +/// - `Target.setAutoAttach` / `Target.setDiscoverTargets(true)` → +/// forward to CEF AND lazily emit our synthetic +/// `Target.attachedToTarget` for Deno (once). We piggyback on these +/// calls because they are the frontend's signal that it's ready to +/// process target events; firing earlier causes the event to be +/// silently dropped. +/// - `Target.attachToTarget(deno-id)` → reply locally with the +/// synthetic sessionId; never reaches CEF (which doesn't know it). +/// - `Target.detachFromTarget(deno-session)` → reply locally and +/// synthesize the corresponding `Target.detachedFromTarget` event. +/// - everything else → forward to CEF. +fn route_client_text( + payload: &[u8], + session_id: &str, + client_send: &mpsc::UnboundedSender, + cef_send: &mpsc::UnboundedSender, + deno_send: &mpsc::UnboundedSender, + deno_announced: &Arc, +) { + let mut value: Value = match serde_json::from_slice(payload) { + Ok(v) => v, + Err(_) => { + let _ = cef_send.send(OwnedFrame::text(payload.to_vec())); + return; + } + }; + + let session = value.get("sessionId").and_then(|v| v.as_str()); + if session == Some(session_id) { + if let Some(obj) = value.as_object_mut() { + obj.remove("sessionId"); + } + let bytes = serde_json::to_vec(&value).unwrap_or_else(|_| payload.to_vec()); + let _ = deno_send.send(OwnedFrame::text(bytes)); + return; + } + + let id = value.get("id").and_then(|v| v.as_i64()); + let method = value + .get("method") + .and_then(|v| v.as_str()) + .map(str::to_owned); + + // The frontend is now listening for target events — announce Deno. + if matches!( + method.as_deref(), + Some("Target.setAutoAttach") | Some("Target.setDiscoverTargets") + ) && !deno_announced.swap(true, std::sync::atomic::Ordering::SeqCst) + { + let event = attached_to_target_event(session_id); + let _ = + client_send.send(OwnedFrame::text(serde_json::to_vec(&event).unwrap())); + } + + if method.as_deref() == Some("Target.attachToTarget") { + let target_id = value + .get("params") + .and_then(|p| p.get("targetId")) + .and_then(|v| v.as_str()); + if target_id == Some(DENO_CHILD_TARGET_ID) { + if let Some(rid) = id { + let reply = json!({ + "id": rid, + "result": { "sessionId": session_id }, + }); + let _ = client_send + .send(OwnedFrame::text(serde_json::to_vec(&reply).unwrap())); + } + return; + } + } + + if method.as_deref() == Some("Target.detachFromTarget") { + let detach_session = value + .get("params") + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()); + if detach_session == Some(session_id) { + if let Some(rid) = id { + let reply = json!({ "id": rid, "result": {} }); + let _ = client_send + .send(OwnedFrame::text(serde_json::to_vec(&reply).unwrap())); + } + let event = json!({ + "method": "Target.detachedFromTarget", + "params": { + "sessionId": session_id, + "targetId": DENO_CHILD_TARGET_ID, + }, + }); + let _ = + client_send.send(OwnedFrame::text(serde_json::to_vec(&event).unwrap())); + return; + } + } + + let _ = cef_send.send(OwnedFrame::text(payload.to_vec())); +} + +/// Rewrite a JSON CDP frame on its way from an upstream to the +/// DevTools client: +/// +/// - Optionally inject `sessionId = session_id` so DevTools attributes +/// the frame to the synthetic Deno child target. +/// - Rename `Runtime.executionContextCreated.params.context.name` to +/// `context_name` so the Console "execution context" dropdown shows +/// a meaningful label instead of V8's defaults (`"top"`, `"main +/// realm"`). +/// +/// If the payload isn't valid JSON, return it unchanged. +fn rewrite_text_from_upstream( + payload: &[u8], + inject_session_id: Option<&str>, + context_name: &str, +) -> Vec { + let mut value: Value = match serde_json::from_slice(payload) { + Ok(v) => v, + Err(_) => return payload.to_vec(), + }; + let Some(obj) = value.as_object_mut() else { + return payload.to_vec(); + }; + if let Some(sid) = inject_session_id { + obj.insert("sessionId".to_string(), Value::String(sid.to_string())); + } + if obj.get("method").and_then(|v| v.as_str()) + == Some("Runtime.executionContextCreated") + { + if let Some(ctx) = obj + .get_mut("params") + .and_then(|v| v.get_mut("context")) + .and_then(|v| v.as_object_mut()) + { + ctx.insert("name".to_string(), Value::String(context_name.to_string())); + } + } + serde_json::to_vec(&value).unwrap_or_else(|_| payload.to_vec()) +} + +/// Build a `Target.attachedToTarget` event advertising the Deno +/// runtime isolate as a child of the unified session. +fn attached_to_target_event(session_id: &str) -> Value { + // `inspector.html` only renders attached child targets in the Sources + // panel "Threads" sidebar when their type matches a known + // worker-style kind (`worker`, `shared_worker`, `service_worker`). + // We pick `worker` so the Deno runtime isolate shows up alongside + // the CEF renderer's main thread. + json!({ + "method": "Target.attachedToTarget", + "params": { + "sessionId": session_id, + "targetInfo": { + "targetId": DENO_CHILD_TARGET_ID, + "type": "worker", + "title": "Deno Runtime", + "url": "deno://runtime", + "attached": true, + "canAccessOpener": false, + }, + "waitingForDebugger": false, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_ws_host_forces_host() { + let host: SocketAddr = "127.0.0.1:9230".parse().unwrap(); + assert_eq!( + rewrite_ws_host("ws://0.0.0.0:9230/devtools/browser/abc", host), + "ws://127.0.0.1:9230/devtools/browser/abc" + ); + assert_eq!( + rewrite_ws_host("ws://localhost/ws/deadbeef", host), + "ws://127.0.0.1:9230/ws/deadbeef" + ); + } + + fn test_config() -> MuxConfig { + MuxConfig { + listen: "127.0.0.1:9229".parse().unwrap(), + deno_internal: "127.0.0.1:9230".parse().unwrap(), + cef_internal: "127.0.0.1:9231".parse().unwrap(), + inspect_brk: false, + wait_for_debugger: false, + } + } + + #[test] + fn target_path_round_trip() { + let state = MuxState::new(test_config(), "127.0.0.1:9229".parse().unwrap()); + assert_eq!(state.target_for_path("/unified"), Some(TargetKind::Unified)); + assert_eq!(state.target_for_path("/deno"), Some(TargetKind::Deno)); + assert_eq!(state.target_for_path("/cef"), Some(TargetKind::Cef)); + assert_eq!(state.target_for_path("/bogus"), None); + } + + // ── sessionId dispatch ──────────────────────────────────────────── + + #[test] + fn route_client_text_strips_session_and_forwards_to_deno() { + let (client_tx, _client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, _cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, mut deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let session_id = "test-session-123"; + let msg = json!({ + "id": 1, + "method": "Debugger.enable", + "sessionId": session_id, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, session_id, &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // Should arrive at Deno with sessionId stripped. + let frame = deno_rx.try_recv().expect("expected frame on deno channel"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value.get("id").unwrap(), 1); + assert_eq!(value.get("method").unwrap(), "Debugger.enable"); + assert!( + value.get("sessionId").is_none(), + "sessionId should be stripped" + ); + } + + #[test] + fn route_client_text_forwards_non_session_to_cef() { + let (client_tx, _client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let msg = json!({"id": 5, "method": "DOM.getDocument"}); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, + "some-session", + &client_tx, + &cef_tx, + &deno_tx, + &announced, + ); + + let frame = cef_rx.try_recv().expect("expected frame on cef channel"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value.get("id").unwrap(), 5); + } + + #[test] + fn route_client_text_attach_to_deno_target_replies_locally() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let session_id = "deno-sess"; + let msg = json!({ + "id": 10, + "method": "Target.attachToTarget", + "params": { "targetId": DENO_CHILD_TARGET_ID }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, session_id, &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // Should reply to client with the synthetic sessionId. + let frame = client_rx.try_recv().expect("expected reply on client"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value["id"], 10); + assert_eq!(value["result"]["sessionId"], session_id); + + // Should NOT have forwarded to CEF. + assert!(cef_rx.try_recv().is_err()); + } + + #[test] + fn route_client_text_lazily_announces_deno() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, _cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let msg = json!({ + "id": 1, + "method": "Target.setAutoAttach", + "params": { "autoAttach": true, "waitForDebuggerOnStart": false }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // Should have emitted Target.attachedToTarget event. + let frame = client_rx.try_recv().expect("expected announce event"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value["method"], "Target.attachedToTarget"); + assert_eq!(value["params"]["targetInfo"]["type"], "worker"); + assert!(announced.load(std::sync::atomic::Ordering::SeqCst)); + + // Calling again should NOT emit a second event. + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + // Only the forwarded-to-cef frame, no second announce. + assert!(client_rx.try_recv().is_err()); + } + + // ── context name rewriting ──────────────────────────────────────── + + #[test] + fn rewrite_injects_session_id() { + let input = json!({"id": 1, "result": {}}); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, Some("my-sess"), "Deno"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(value["sessionId"], "my-sess"); + } + + #[test] + fn rewrite_renames_execution_context() { + let input = json!({ + "method": "Runtime.executionContextCreated", + "params": { + "context": { + "id": 1, + "origin": "", + "name": "top", + }, + }, + }); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, None, "Renderer"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(value["params"]["context"]["name"], "Renderer"); + } + + #[test] + fn rewrite_no_session_leaves_field_absent() { + let input = json!({"method": "Console.messageAdded"}); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, None, "Renderer"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert!(value.get("sessionId").is_none()); + } + + // ── /json/list shape ────────────────────────────────────────────── + + #[tokio::test] + async fn json_list_returns_three_targets() { + let state = MuxState::new(test_config(), "127.0.0.1:9229".parse().unwrap()); + let resp = json_list(&state).await; + let body = resp.into_body(); + let bytes = body.collect().await.unwrap().to_bytes(); + let list: Vec = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(list.len(), 3); + + // Unified target. + assert_eq!(list[0]["type"], "page"); + assert_eq!(list[0]["title"], "Deno Desktop (unified)"); + assert!( + list[0]["webSocketDebuggerUrl"] + .as_str() + .unwrap() + .ends_with("/unified") + ); + + // Deno direct target. + assert_eq!(list[1]["type"], "node"); + assert_eq!(list[1]["title"], "Deno Runtime"); + assert!( + list[1]["webSocketDebuggerUrl"] + .as_str() + .unwrap() + .ends_with("/deno") + ); + + // CEF direct target. + assert_eq!(list[2]["type"], "page"); + assert_eq!(list[2]["title"], "CEF Renderer"); + assert!( + list[2]["webSocketDebuggerUrl"] + .as_str() + .unwrap() + .ends_with("/cef") + ); + } +} From b2aa135c4809fd5e8f153fb3e3082f0f6f357a67 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 21 Apr 2026 23:13:16 +0200 Subject: [PATCH 041/137] remove shelling out --- Cargo.lock | 130 ++++++++++++++- Cargo.toml | 1 + cli/Cargo.toml | 1 + cli/tools/desktop.rs | 381 +++++++++++++++++++++++++------------------ 4 files changed, 343 insertions(+), 170 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e22963a905b24e..a4353bf8e1a8eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,6 +485,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" +[[package]] +name = "backhand" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e7812fe72262dd26c2b4309c8e8f2c028db1ffbeb21054f009f847f09ca92c" +dependencies = [ + "deku", + "liblzma", + "no_std_io2", + "solana-nohash-hasher", + "thiserror 2.0.12", + "tracing", + "xxhash-rust", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -1719,8 +1734,18 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1737,13 +1762,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.117", ] @@ -1832,6 +1882,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "deku" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf55291257a2a5c90cf50ae17b6bbaabc3fd13642cf3895a71c412513c19630" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec2a42b511fc5efd9183f4f71c17885d627b17e7fd9a61a92089406caa4397e" +dependencies = [ + "darling 0.21.3", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deno" version = "2.7.12" @@ -1839,6 +1914,7 @@ dependencies = [ "anstream", "async-trait", "aws-lc-rs", + "backhand", "base64 0.22.1", "bincode", "boxed_error", @@ -3797,7 +3873,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -6569,6 +6645,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", + "num_cpus", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.8" @@ -7069,6 +7166,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no_std_io2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +dependencies = [ + "memchr", +] + [[package]] name = "node_compat_tests" version = "0.0.0" @@ -8928,9 +9034,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.15" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" @@ -9590,6 +9696,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "solana-nohash-hasher" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8a731ed60e89177c8a7ab05fe0f1511cedd3e70e773f288f9de33a9cfdc21e" + [[package]] name = "sourcemap" version = "9.2.0" @@ -11332,9 +11444,9 @@ dependencies = [ [[package]] name = "v8" -version = "147.1.0" +version = "147.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b40cc5c1de4d30b3d96f9d4ef83683123c4b324e295a213ae7c70e28ccc047" +checksum = "628be10f644833fcd285a48a27a6bf0d53a05c850059b5f60cd17b7ff5a2eed5" dependencies = [ "bindgen 0.72.1", "bitflags 2.9.3", @@ -11699,7 +11811,7 @@ checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" name = "wef" version = "0.1.0" dependencies = [ - "bindgen 0.71.1", + "bindgen 0.72.1", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index a0bea73d997049..36ffaaa2451b9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,6 +181,7 @@ async-stream = "0.3" async-trait = "0.1.73" aws-lc-rs = { version = "1.16.1" } aws-lc-sys = { version = "0.39.0", features = ["prebuilt-nasm"] } +backhand = { version = "0.25.1", default-features = false, features = ["xz"] } base32 = "=0.5.1" base64 = "0.22.1" base64-simd = "0.8" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ed410518624944..e352c1b8bd3ce2 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -94,6 +94,7 @@ node_shim.workspace = true anstream.workspace = true async-trait.workspace = true aws-lc-rs.workspace = true +backhand.workspace = true base64.workspace = true bincode.workspace = true boxed_error.workspace = true diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs index 697da268c02a96..65e074b821b151 100644 --- a/cli/tools/desktop.rs +++ b/cli/tools/desktop.rs @@ -131,6 +131,13 @@ async fn compile_desktop( .filter(|o| o.to_lowercase().ends_with(".dmg")) .cloned(); if let Some(ref dmg) = dmg_output { + if !cfg!(target_os = "macos") { + bail!( + "Building a .dmg requires a macOS build host (uses hdiutil). \ + Requested output: {dmg}. Build on macOS, or choose a different output \ + format.", + ); + } let stem = Path::new(dmg) .file_stem() .map(|s| s.to_string_lossy().into_owned()) @@ -285,7 +292,11 @@ async fn compile_desktop( dmg_abs } else if let Some(appimage) = appimage_output.as_deref() { let appimage_abs = cli_options.initial_cwd().join(appimage); - create_linux_appimage(&bundle_path, &appimage_abs)?; + create_linux_appimage( + &bundle_path, + &appimage_abs, + desktop_flags.target.as_deref(), + )?; appimage_abs } else { bundle_path @@ -562,7 +573,6 @@ async fn package_windows_app_dir( let dylib_filename = dylib_path.file_name().unwrap(); let dest_dylib = app_dir.join(dylib_filename); std::fs::copy(dylib_path, &dest_dylib)?; - strip_dylib(&dest_dylib); // Create a .bat launcher that invokes the backend with --runtime. let launcher_path = app_dir.join(format!("{}.bat", app_name)); @@ -677,7 +687,6 @@ async fn package_linux_app_dir( let dylib_filename = dylib_path.file_name().unwrap(); let dest_dylib = app_dir.join(dylib_filename); std::fs::copy(dylib_path, &dest_dylib)?; - strip_dylib(&dest_dylib); // Create a shell launcher that invokes the backend with --runtime. // --ozone-platform=x11 forces CEF to create X11 windows (via XWayland on @@ -1169,11 +1178,10 @@ async fn package_macos_app_bundle( // Strip unnecessary bulk from the CEF framework. strip_cef_bloat(&contents_dir); - // Copy the compiled dylib and strip symbols. + // Copy the compiled dylib. let dylib_filename = dylib_path.file_name().unwrap(); let dest_dylib = macos_dir.join(dylib_filename); std::fs::copy(dylib_path, &dest_dylib)?; - strip_dylib(&dest_dylib); // Create launcher script as the main executable. let launcher_path = macos_dir.join(&app_name); @@ -1352,55 +1360,141 @@ fn create_macos_dmg( Ok(()) } +/// AppImage Type-2 runtime ELF stubs, vendored from +/// github.com/AppImage/type2-runtime at tag `20251108`. Prepended verbatim to +/// the SquashFS payload to form the final AppImage. +const APPIMAGE_RUNTIME_X86_64: &[u8] = + include_bytes!("appimage_runtime/runtime-x86_64"); +const APPIMAGE_RUNTIME_AARCH64: &[u8] = + include_bytes!("appimage_runtime/runtime-aarch64"); + +/// 1×1 transparent PNG, used when the caller didn't supply an icon. +/// appimagetool-built AppImages expect a top-level `.png` to exist. +const STUB_ICON_PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// Pick the Type-2 runtime stub for the requested target. Falls back to the +/// host arch when `target` is None. The triple's leading component is the +/// arch (e.g. `x86_64-unknown-linux-gnu` → `x86_64`). +fn appimage_runtime_for_target( + target: Option<&str>, +) -> Result<&'static [u8], AnyError> { + let arch = target + .and_then(|t| t.split('-').next()) + .unwrap_or(std::env::consts::ARCH); + match arch { + "x86_64" => Ok(APPIMAGE_RUNTIME_X86_64), + "aarch64" => Ok(APPIMAGE_RUNTIME_AARCH64), + other => bail!( + "No bundled AppImage runtime for arch '{other}'; supported: x86_64, aarch64" + ), + } +} + +/// Unix mode bits for a filesystem entry. On non-Unix hosts (cross-compiling +/// a Linux AppImage from Windows/macOS) we don't have real mode bits, so fall +/// back to a reasonable default: 0o755 for dirs, 0o644 for files. +fn unix_mode_of(meta: &std::fs::Metadata) -> u16 { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + (meta.permissions().mode() & 0o7777) as u16 + } + #[cfg(not(unix))] + { + if meta.is_dir() { 0o755 } else { 0o644 } + } +} + +fn node_header(mode: u16) -> backhand::NodeHeader { + backhand::NodeHeader { + permissions: mode, + uid: 0, + gid: 0, + mtime: 0, + } +} + +/// Walk `fs_root` and push every entry into `writer` at the SquashFS root. +/// Directories are pushed before their contents (required by backhand). +fn push_dir_contents_to_squashfs( + writer: &mut backhand::FilesystemWriter<'_, '_, '_>, + fs_root: &Path, +) -> Result<(), AnyError> { + let mut stack: Vec = vec![fs_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let mut entries: Vec<_> = + std::fs::read_dir(&dir)?.collect::>()?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + let path = entry.path(); + let rel = path.strip_prefix(fs_root)?; + let arc_path = Path::new("/").join(rel); + let meta = std::fs::symlink_metadata(&path)?; + let mode = unix_mode_of(&meta); + let ft = meta.file_type(); + if ft.is_symlink() { + let target = std::fs::read_link(&path)?; + writer.push_symlink(target, &arc_path, node_header(mode))?; + } else if ft.is_dir() { + writer.push_dir(&arc_path, node_header(mode))?; + stack.push(path); + } else { + let f = std::fs::File::open(&path)?; + writer.push_file(f, &arc_path, node_header(mode))?; + } + } + } + Ok(()) +} + /// Wrap a Linux app directory in an `.AppImage` single-file executable. /// -/// Stages the app dir as an AppDir (adding `AppRun`, a `.desktop` entry, and -/// a top-level icon) and invokes `appimagetool` to build the AppImage. The -/// user must have `appimagetool` on `PATH`. +/// Packs the app dir into a SquashFS image via the `backhand` crate, adds the +/// AppDir-required entries (`AppRun`, `.desktop`, top-level icon), then +/// prepends the vendored AppImage Type-2 runtime ELF for the target arch. +/// Pure Rust; works on any build host. fn create_linux_appimage( app_dir: &Path, appimage_path: &Path, + target: Option<&str>, ) -> Result<(), AnyError> { + use std::io::Cursor; + use std::io::Write as _; + let app_name = app_dir .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "App".to_string()); - // Stage in a sibling directory. appimagetool treats the staging dir as the - // AppDir root. - let staging = appimage_path.with_file_name(format!( - ".{}.appimage-staging", - appimage_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - )); - if staging.exists() { - std::fs::remove_dir_all(&staging)?; - } - std::fs::create_dir_all(&staging)?; + let runtime_elf = appimage_runtime_for_target(target)?; - crate::tools::compile::copy_dir_all(app_dir, &staging)?; + let mut writer = backhand::FilesystemWriter::default(); - // AppRun is what the AppImage invokes on launch. Use a thin shell script - // so we don't have to deal with $ORIGIN-relative rpath in AppRun itself — - // the existing launcher already sets $DIR and execs the backend. - let apprun = staging.join("AppRun"); - std::fs::write( - &apprun, - format!( - "#!/bin/bash\n\ - DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ - exec \"$DIR/{app_name}\" \"$@\"\n", - ), + // Pack everything from the staged app dir into the SquashFS root. + push_dir_contents_to_squashfs(&mut writer, app_dir)?; + + // AppRun is what the AppImage invokes on launch. Thin shell shim that + // delegates to the existing launcher (which already sets $DIR and execs + // the backend with the right args). + let apprun = format!( + "#!/bin/bash\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + exec \"$DIR/{app_name}\" \"$@\"\n", + ); + writer.push_file( + Cursor::new(apprun.into_bytes()), + "/AppRun", + node_header(0o755), )?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&apprun, std::fs::Permissions::from_mode(0o755))?; - } - // .desktop entry. appimagetool requires one at the AppDir root. + // .desktop entry at the AppDir root. let desktop_entry = format!( "[Desktop Entry]\n\ Type=Application\n\ @@ -1409,98 +1503,56 @@ fn create_linux_appimage( Icon={app_name}\n\ Categories=Utility;\n", ); - std::fs::write(staging.join(format!("{app_name}.desktop")), desktop_entry)?; - - // Icon: appimagetool looks for .png (or .svg) at the AppDir root. - // package_linux_app_dir writes the user icon as AppIcon.png — rename it. - // Fall back to a 1x1 transparent PNG if no icon was provided. - let icon_target = staging.join(format!("{app_name}.png")); - if !icon_target.exists() { - let app_icon = staging.join("AppIcon.png"); - if app_icon.exists() { - std::fs::rename(&app_icon, &icon_target)?; - } else { - const STUB_PNG: &[u8] = &[ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, - 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, - 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, - 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, - 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]; - std::fs::write(&icon_target, STUB_PNG)?; - } - } + writer.push_file( + Cursor::new(desktop_entry.into_bytes()), + format!("/{app_name}.desktop"), + node_header(0o644), + )?; - if appimage_path.exists() { - std::fs::remove_file(appimage_path)?; - } + // Icon at AppDir root named after the app. package_linux_app_dir writes the + // user icon as AppIcon.png; if absent, fall back to a 1×1 transparent PNG. + let icon_src = app_dir.join("AppIcon.png"); + let icon_bytes = if icon_src.exists() { + std::fs::read(&icon_src)? + } else { + STUB_ICON_PNG.to_vec() + }; + writer.push_file( + Cursor::new(icon_bytes), + format!("/{app_name}.png"), + node_header(0o644), + )?; + + // Serialize the SquashFS to memory. + let mut squashfs = Cursor::new(Vec::::new()); + writer + .write(&mut squashfs) + .context("Failed to write SquashFS image")?; + drop(writer); + + // Assemble: runtime ELF + SquashFS, then mark executable. if let Some(parent) = appimage_path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } + let mut out = std::fs::File::create(appimage_path).with_context(|| { + format!("Failed to create AppImage at {}", appimage_path.display()) + })?; + out.write_all(runtime_elf)?; + out.write_all(&squashfs.into_inner())?; + drop(out); - // ARCH env var is required by appimagetool to name the runtime correctly. - let arch = match std::env::consts::ARCH { - "x86_64" => "x86_64", - "aarch64" => "aarch64", - other => other, - }; - - let status = std::process::Command::new("appimagetool") - .env("ARCH", arch) - .arg(&staging) - .arg(appimage_path) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::inherit()) - .status() - .context( - "Failed to run appimagetool (install from https://appimage.github.io/appimagetool/)", + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + appimage_path, + std::fs::Permissions::from_mode(0o755), )?; - - let _ = std::fs::remove_dir_all(&staging); - - if !status.success() { - bail!( - "appimagetool failed to create AppImage at {}", - appimage_path.display() - ); } - Ok(()) -} -/// Strip local symbols from the compiled dylib to reduce size. -/// -/// On macOS uses `strip -x` (remove local symbols, keep global/debug-essential). -/// On Linux uses `strip --strip-unneeded` (remove symbols not needed for relocation). -fn strip_dylib(dylib_path: &Path) { - let strip_args: &[&str] = if cfg!(target_os = "macos") { - &["-x", dylib_path.to_str().unwrap_or_default()] - } else if cfg!(target_os = "linux") { - &["--strip-unneeded", dylib_path.to_str().unwrap_or_default()] - } else { - return; - }; - - match std::process::Command::new("strip") - .args(strip_args) - .output() - { - Ok(output) if output.status.success() => { - log::info!("Stripped symbols from {}", dylib_path.display()); - } - Ok(output) => { - log::warn!( - "strip exited with {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - } - Err(e) => { - log::warn!("Failed to run strip on {}: {}", dylib_path.display(), e); - } - } + Ok(()) } /// Recursively copy a directory tree, ensuring writable permissions on the @@ -1544,73 +1596,80 @@ fn strip_cef_bloat(contents_dir: &Path) { /// Build a macOS `.icns` from an icon set (multiple PNGs at specified sizes). /// -/// Maps each provided size to the correct `.iconset` filename. The standard -/// macOS iconset uses 1x and 2x variants: -/// 16px → icon_16x16.png -/// 32px → icon_16x16@2x.png AND icon_32x32.png -/// 64px → icon_32x32@2x.png -/// 128px → icon_128x128.png -/// 256px → icon_128x128@2x.png AND icon_256x256.png -/// 512px → icon_256x256@2x.png AND icon_512x512.png -/// 1024px→ icon_512x512@2x.png +/// Writes the ICNS container format directly (macOS 10.7+ accepts PNG bytes +/// as the payload for every modern OSType code, so no re-encoding needed). +/// Each pixel size maps to one or two OSType codes — the 1x and 2x slots +/// that share that pixel dimension: +/// 16px → icp4 +/// 32px → ic11 (16×16@2x) + icp5 (32×32) +/// 64px → ic12 (32×32@2x) +/// 128px → ic07 +/// 256px → ic13 (128×128@2x) + ic08 (256×256) +/// 512px → ic14 (256×256@2x) + ic09 (512×512) +/// 1024px→ ic10 (512×512@2x) fn convert_icon_set_to_icns( cwd: &Path, entries: &[crate::args::IconSetEntry], icns_path: &Path, ) -> Result<(), deno_core::error::AnyError> { - let iconset_dir = icns_path.with_extension("iconset"); - std::fs::create_dir_all(&iconset_dir)?; + use std::io::Write; + let mut icons: Vec<(&'static [u8; 4], Vec)> = Vec::new(); for entry in entries { let src = cwd.join(&entry.path); if !src.exists() { log::warn!("Icon '{}' not found, skipping", src.display()); continue; } - - let names = iconset_names_for_size(entry.size); - for name in names { - std::fs::copy(&src, iconset_dir.join(name))?; + let data = std::fs::read(&src)?; + for code in icns_ostypes_for_size(entry.size) { + icons.push((*code, data.clone())); } } - let status = std::process::Command::new("iconutil") - .args([ - "-c", - "icns", - &iconset_dir.display().to_string(), - "-o", - &icns_path.display().to_string(), - ]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status()?; - - let _ = std::fs::remove_dir_all(&iconset_dir); + if icons.is_empty() { + deno_core::anyhow::bail!("No valid icon images found for .icns"); + } - if !status.success() { - deno_core::anyhow::bail!("iconutil failed to create .icns from icon set"); + // File header (8 bytes) + for each icon: 4-byte OSType + 4-byte length + payload. + let total_size: u32 = icons + .iter() + .map(|(_, data)| 8 + data.len() as u32) + .sum::() + + 8; + + let mut buf = Vec::with_capacity(total_size as usize); + buf.write_all(b"icns")?; + buf.write_all(&total_size.to_be_bytes())?; + for (code, data) in &icons { + buf.write_all(*code)?; + let entry_len = 8 + data.len() as u32; + buf.write_all(&entry_len.to_be_bytes())?; + buf.write_all(data)?; } + std::fs::write(icns_path, &buf)?; Ok(()) } -/// Returns the `.iconset` filenames a given pixel size maps to. -fn iconset_names_for_size(size: u32) -> Vec<&'static str> { +/// Returns the ICNS OSType codes a given pixel size maps to. Multiple codes +/// mean the same PNG is embedded under each (matching how the staged +/// `.iconset` approach duplicated files across 1x/2x filename slots). +fn icns_ostypes_for_size(size: u32) -> &'static [&'static [u8; 4]] { match size { - 16 => vec!["icon_16x16.png"], - 32 => vec!["icon_16x16@2x.png", "icon_32x32.png"], - 64 => vec!["icon_32x32@2x.png"], - 128 => vec!["icon_128x128.png"], - 256 => vec!["icon_128x128@2x.png", "icon_256x256.png"], - 512 => vec!["icon_256x256@2x.png", "icon_512x512.png"], - 1024 => vec!["icon_512x512@2x.png"], + 16 => &[b"icp4"], + 32 => &[b"ic11", b"icp5"], + 64 => &[b"ic12"], + 128 => &[b"ic07"], + 256 => &[b"ic13", b"ic08"], + 512 => &[b"ic14", b"ic09"], + 1024 => &[b"ic10"], _ => { log::warn!( "Icon size {}px doesn't map to a standard macOS iconset slot, skipping", size ); - vec![] + &[] } } } From 0c33bcc53ff6e6757a68fbfec51e069ebb5abbbc Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 23 Apr 2026 13:53:25 +0200 Subject: [PATCH 042/137] dock API --- Cargo.lock | 2 +- cli/rt/desktop.rs | 37 ++ cli/rt_desktop/lib.rs | 48 ++ cli/tools/desktop_devtools.rs | 911 ++++++++++++++++++++++++++++-- cli/tsc/dts/lib.deno.desktop.d.ts | 80 +++ runtime/js/99_main.js | 1 + runtime/ops/desktop.rs | 90 ++- 7 files changed, 1109 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e22963a905b24e..3152f57cdf4a5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11699,7 +11699,7 @@ checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" name = "wef" version = "0.1.0" dependencies = [ - "bindgen 0.71.1", + "bindgen 0.72.1", "tokio", ] diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index f83087a4f862c9..a90c1d2c5e8452 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -20,6 +20,7 @@ pub const DESKTOP_JS: &str = r#" const internals = Deno[Deno.internal]; const { BrowserWindow, + Dock, op_desktop_init, op_desktop_recv_event, op_desktop_resolve_bind_call, @@ -284,6 +285,26 @@ pub const DESKTOP_JS: &str = r#" WheelEvent: internals.core.propNonEnumerable(WheelEvent), }); + const DockPrototype = Dock.prototype; + Object.setPrototypeOf(DockPrototype, EventTarget.prototype); + + const docks = new Set(); + const nativeDockConstructor = Dock; + const OrigDock = function(...args) { + const instance = new nativeDockConstructor(...args); + docks.add(instance); + return instance; + }; + Object.setPrototypeOf(OrigDock, nativeDockConstructor); + Object.setPrototypeOf(OrigDock.prototype, nativeDockConstructor.prototype); + Deno.Dock = OrigDock; + + internals.defineEventHandler(DockPrototype, "menuclick"); + internals.defineEventHandler(DockPrototype, "reopen"); + + const dock = new OrigDock(); + Object.defineProperty(Deno, "dock", internals.core.propReadOnly(dock)); + // Start polling loops immediately. Use core.unrefOpPromise so these // pending ops don't block event loop completion (e.g. the pre-module // tick used by HMR, or module evaluation with top-level await). @@ -457,6 +478,22 @@ pub const DESKTOP_JS: &str = r#" })); break; } + case "dockMenuClick": { + for (const d of docks) { + d.dispatchEvent(new CustomEvent("menuclick", { + detail: { id: ev.id }, + })); + } + break; + } + case "dockReopen": { + for (const d of docks) { + d.dispatchEvent(new CustomEvent("reopen", { + detail: { hasVisibleWindows: ev.hasVisibleWindows }, + })); + } + break; + } } } catch (e) { console.error("Desktop event loop error:", e?.stack ?? e); diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index a0f3d6582cef45..bda9a6b4893312 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -479,6 +479,40 @@ impl denort::desktop::DesktopApi for WefDesktopApi { ) { wef::prompt(title, message, default_value, callback); } + + fn set_dock_badge(&self, text: &str) { + wef::set_dock_badge(if text.is_empty() { None } else { Some(text) }); + } + + fn bounce_dock(&self, critical: bool) { + wef::bounce_dock(if critical { + wef::DockBounceType::Critical + } else { + wef::DockBounceType::Informational + }); + } + + fn set_dock_menu(&self, menu: Vec) { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_wef_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + wef::set_dock_menu(&menu, move |id: &str| { + let _ = + tx.send(deno_runtime::ops::desktop::DesktopEvent::DockMenuClick { + id: id.to_string(), + }); + }); + } + + fn clear_dock_menu(&self) { + wef::clear_dock_menu(); + } + + fn set_dock_visible(&self, visible: bool) { + wef::set_dock_visible(visible); + } } fn desktop_menu_item_to_wef_menu_item( @@ -1232,6 +1266,20 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { closed_windows: Arc::new(Mutex::new(HashSet::new())), }; + // Forward macOS dock-reopen callbacks (clicking the dock icon while + // no windows are visible) into the shared event channel so JS can + // observe them as `Deno.dock` "reopen" events. + { + let reopen_tx = event_tx.0.clone(); + wef::on_dock_reopen(move |has_visible_windows| { + let _ = reopen_tx.send( + deno_runtime::ops::desktop::DesktopEvent::DockReopen { + has_visible_windows, + }, + ); + }); + } + // Create the initial window and wire up event handlers. let window_id = api.create_window(800, 600); initial_window_id.store(window_id, Ordering::Release); diff --git a/cli/tools/desktop_devtools.rs b/cli/tools/desktop_devtools.rs index 42b537aadd468e..6b7684f71a72fa 100644 --- a/cli/tools/desktop_devtools.rs +++ b/cli/tools/desktop_devtools.rs @@ -5,33 +5,62 @@ //! Architecture: //! //! ```text -//! ┌──────────────────────────────────┐ -//! │ CDP Multiplexer (this file) │ -//! DevTools ◄──► │ - /json/version, /json/list │ -//! (single ws) │ - /unified (primary entry) │ -//! │ - /deno, /cef (direct bypass) │ -//! │ - /devtools/* (frontend proxy) │ -//! └──────┬───────────────────┬───────┘ -//! │ │ -//! Deno inspector CEF renderer -//! (internal port) (internal port) +//! ┌──────────────────────────────────────────┐ +//! │ CDP Multiplexer (this file) │ +//! DevTools ◄──► │ HTTP: /json/{version,list,protocol} │ +//! (single ws) │ /debugger-attached (gate) │ +//! │ WS: /unified (primary entry) │ +//! │ /deno, /cef (direct bypass) │ +//! │ HTTP: /devtools/* (frontend proxy) │ +//! └──────┬─────────────────────────┬─────────┘ +//! │ │ +//! Deno inspector CEF renderer +//! (internal port) (internal port) //! ``` //! -//! The `/unified` endpoint is the primary session. CEF is the default -//! (un-sessioned) target; the Deno runtime appears as an attached child -//! via a synthetic `Target.attachedToTarget` event with a stable -//! `sessionId`. The mux routes frames by `sessionId` — Deno-bound -//! frames have the sessionId stripped before forwarding, and responses -//! get it re-injected. Everything else goes to CEF verbatim. +//! ## Unified session +//! +//! `/unified` is the primary session. CEF is the default (un-sessioned) +//! target; the Deno runtime appears as an attached child via a +//! synthetic `Target.attachedToTarget` event with a stable `sessionId` +//! the mux invents. Frame routing: +//! +//! - Client → mux frames with `sessionId == deno_session_id` have the +//! field stripped and are forwarded to Deno. +//! - Deno → client frames get the `sessionId` re-injected. +//! - `Target.attachToTarget`/`detachFromTarget` for the Deno child are +//! answered locally — CEF never learns of the synthetic session. +//! - `Runtime.executionContextCreated` gets its `context.name` +//! rewritten ("Renderer" on the CEF leg, "Deno" on the Deno leg) so +//! the Console dropdown shows meaningful labels instead of V8's +//! defaults. +//! - The synthetic `Target.attachedToTarget` event is emitted lazily, +//! on the first `Target.setAutoAttach`/`setDiscoverTargets` from the +//! client — firing earlier loses the event to the frontend's +//! not-yet-ready auto-attach manager. //! //! DevTools sees both isolates in one window: the Console dropdown //! shows "Renderer" / "Deno", and the Sources panel Threads sidebar //! lists both. //! -//! `/deno` and `/cef` are direct passthrough endpoints for debugging -//! each isolate in isolation. `/devtools/*` proxies CEF's bundled -//! DevTools frontend assets so `openDevtools()` can pop a CEF window -//! without triggering the remote-debugging-port interception. +//! ## `--inspect-brk` / `--inspect-wait` +//! +//! The child process blocks on `GET /debugger-attached` before +//! navigating CEF. The mux flips that endpoint to 200 only after the +//! DevTools client has connected AND — under `--inspect-brk` — the +//! `Debugger.enable` + `Debugger.pause` injection against CEF has +//! acked both responses. This guarantees the renderer pauses on the +//! first JS statement of the loaded page instead of racing past it. +//! Deno's own `--inspect-brk` handles the Deno isolate separately. +//! +//! ## Direct / frontend endpoints +//! +//! `/deno` and `/cef` are direct passthrough WebSockets for debugging +//! each isolate in isolation when the unified session misbehaves. +//! `/devtools/*` proxies CEF's bundled DevTools frontend assets +//! through the mux port so `openDevtools()` can pop a CEF window +//! without triggering CEF's own remote-debugging-port frontend +//! interception. use std::convert::Infallible; use std::net::SocketAddr; @@ -483,11 +512,12 @@ async fn handle_upgrade( } }; - // Signal that a debugger has connected — the child process polls - // `/debugger-attached` to gate navigation under --inspect-wait/brk. - state - .debugger_attached - .store(true, std::sync::atomic::Ordering::SeqCst); + // `debugger_attached` is what releases the child process from its + // `/debugger-attached` poll so it can navigate CEF. Under + // `--inspect-brk` we MUST inject `Debugger.enable` + `Debugger.pause` + // into the CEF isolate BEFORE the child navigates, otherwise the + // page's JS executes before the pause request arrives. So each + // target-specific handler signals attachment at its own right moment. match kind { TargetKind::Unified => { @@ -495,7 +525,10 @@ async fn handle_upgrade( log::debug!("[devtools-mux] unified session ended: {err:?}"); } } - TargetKind::Deno | TargetKind::Cef => { + TargetKind::Deno => { + // Deno has its own `--inspect-brk` mechanism that blocks the + // isolate until a client attaches — nothing to inject here. + mark_debugger_attached(&state); match connect_upstream(&state, kind).await { Ok(upstream) => { if let Err(err) = proxy_frames(client, upstream).await { @@ -504,11 +537,31 @@ async fn handle_upgrade( } Err(err) => { log::error!( - "[devtools-mux] failed to connect upstream for {kind:?}: {err:?}" + "[devtools-mux] failed to connect upstream for Deno: {err:?}" ); } } } + TargetKind::Cef => match connect_upstream(&state, kind).await { + Ok(mut upstream) => { + if state.config.inspect_brk + && let Err(err) = inject_cef_pause(&mut upstream).await + { + log::error!( + "[devtools-mux] failed to inject pause into CEF: {err:?}" + ); + } + mark_debugger_attached(&state); + if let Err(err) = proxy_frames(client, upstream).await { + log::debug!("[devtools-mux] proxy ended: {err:?}"); + } + } + Err(err) => { + log::error!( + "[devtools-mux] failed to connect upstream for CEF: {err:?}" + ); + } + }, } }); @@ -754,6 +807,77 @@ impl OwnedFrame { } } +/// Set `debugger_attached` so the child process's `/debugger-attached` +/// poll returns 200 and it can proceed with navigation. Only call this +/// once the CEF pause injection (if any) has completed. +fn mark_debugger_attached(state: &MuxState) { + state + .debugger_attached + .store(true, std::sync::atomic::Ordering::SeqCst); +} + +/// Send `Debugger.enable` + `Debugger.pause` to the CEF upstream, +/// swallowing their responses. Called before the child is released to +/// navigate, so the renderer stops on the first JS statement of the +/// loaded page. +/// +/// Any CEF → client frames that arrive during the injection window are +/// discarded. This is acceptable because the renderer has not yet +/// navigated to a user page (the child is blocked on +/// `/debugger-attached`), so the only traffic is CEF's own protocol +/// setup (e.g. unsolicited `Target.targetCreated` for about:blank), +/// which DevTools will re-observe via `Target.setDiscoverTargets` once +/// the session opens. +async fn inject_cef_pause(cef: &mut WebSocket) -> Result<(), AnyError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let enable = json!({"id": -1, "method": "Debugger.enable"}); + cef + .write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(serde_json::to_vec(&enable).unwrap()), + )) + .await?; + + let pause = json!({"id": -2, "method": "Debugger.pause"}); + cef + .write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(serde_json::to_vec(&pause).unwrap()), + )) + .await?; + + // Read frames until we've observed both responses. CEF may interleave + // unsolicited events, which we drop. + let mut saw_enable = false; + let mut saw_pause = false; + while !(saw_enable && saw_pause) { + let frame = cef.read_frame().await?; + if frame.opcode != OpCode::Text { + continue; + } + let value: Value = match serde_json::from_slice(&frame.payload) { + Ok(v) => v, + Err(_) => continue, + }; + match value.get("id").and_then(|v| v.as_i64()) { + Some(-1) => saw_enable = true, + Some(-2) => saw_pause = true, + _ => {} + } + } + + log::debug!( + "[devtools-mux] injected Debugger.enable + Debugger.pause into CEF" + ); + Ok(()) +} + /// Run the unified DevTools session: one client WebSocket fronting two /// upstreams (CEF as the default session, Deno attached via a /// synthetic `sessionId`). @@ -779,39 +903,15 @@ async fn run_unified_session( let session_id = state.deno_session_id.clone(); // When --inspect-brk is active, inject Debugger.enable + Debugger.pause - // into the CEF session BEFORE forwarding any client frames. This ensures - // the renderer pauses on the very first JS statement after navigation. + // into the CEF session BEFORE the child is allowed to navigate. The + // child polls `/debugger-attached` and will only navigate once we + // signal attachment, so doing the injection first — and releasing + // attachment afterwards — guarantees the renderer pauses on the very + // first JS statement of the loaded page. if state.config.inspect_brk { - let enable = json!({"id": -1, "method": "Debugger.enable"}); - let enable_bytes = serde_json::to_vec(&enable).unwrap(); - cef - .write_frame(Frame::new( - true, - OpCode::Text, - None, - fastwebsockets::Payload::Owned(enable_bytes), - )) - .await?; - - // Read the enable response before sending pause. - let _resp = cef.read_frame().await?; - - let pause = json!({"id": -2, "method": "Debugger.pause"}); - let pause_bytes = serde_json::to_vec(&pause).unwrap(); - cef - .write_frame(Frame::new( - true, - OpCode::Text, - None, - fastwebsockets::Payload::Owned(pause_bytes), - )) - .await?; - let _resp = cef.read_frame().await?; - - log::debug!( - "[devtools-mux] injected Debugger.enable + Debugger.pause into CEF" - ); + inject_cef_pause(&mut cef).await?; } + mark_debugger_attached(&state); let (mut client_rx, mut client_tx) = client.split(tokio::io::split); let (mut cef_rx, mut cef_tx) = cef.split(tokio::io::split); @@ -1322,6 +1422,68 @@ mod tests { assert!(client_rx.try_recv().is_err()); } + #[test] + fn route_client_text_set_discover_targets_also_triggers_announce() { + // The lazy-announce trigger is either setAutoAttach OR + // setDiscoverTargets — DevTools sometimes uses one, sometimes the + // other, depending on which panel initialised first. + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, _cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let msg = json!({ + "id": 7, + "method": "Target.setDiscoverTargets", + "params": { "discover": true }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + + let frame = client_rx + .try_recv() + .expect("setDiscoverTargets should also trigger announce"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value["method"], "Target.attachedToTarget"); + assert!(announced.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[test] + fn route_client_text_attach_to_cef_target_forwards_to_cef() { + // `Target.attachToTarget` for any target ID that isn't ours (e.g. + // a real CEF subframe or worker) must pass through — only the + // synthetic Deno target is answered locally. + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let msg = json!({ + "id": 11, + "method": "Target.attachToTarget", + "params": { "targetId": "some-cef-subframe-target" }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, + "deno-sess", + &client_tx, + &cef_tx, + &deno_tx, + &announced, + ); + + let forwarded = cef_rx.try_recv().expect("expected frame on cef"); + let value: Value = serde_json::from_slice(&forwarded.payload).unwrap(); + assert_eq!(value["method"], "Target.attachToTarget"); + assert_eq!(value["params"]["targetId"], "some-cef-subframe-target"); + assert!(client_rx.try_recv().is_err()); + } + // ── context name rewriting ──────────────────────────────────────── #[test] @@ -1360,6 +1522,37 @@ mod tests { assert!(value.get("sessionId").is_none()); } + #[test] + fn rewrite_leaves_non_execution_context_messages_unchanged() { + // Any `name` field on other methods must not be touched — only + // `Runtime.executionContextCreated.params.context.name` is rewritten. + let input = json!({ + "method": "Target.targetInfoChanged", + "params": { "targetInfo": { "name": "something" } }, + }); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, None, "Renderer"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(value["params"]["targetInfo"]["name"], "something"); + } + + #[test] + fn rewrite_invalid_json_returned_verbatim() { + let payload = b"totally not json".to_vec(); + let out = rewrite_text_from_upstream(&payload, Some("sid"), "Renderer"); + assert_eq!(out, payload); + } + + #[test] + fn rewrite_non_object_json_returned_verbatim() { + // Arrays / scalars have no "sessionId" slot to inject into — + // they must pass through untouched rather than being wrapped. + let input = json!([1, 2, 3]); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, Some("sid"), "Renderer"); + assert_eq!(out, payload); + } + // ── /json/list shape ────────────────────────────────────────────── #[tokio::test] @@ -1401,5 +1594,607 @@ mod tests { .unwrap() .ends_with("/cef") ); + + // Every entry must expose a devtoolsFrontendUrl that points at + // its own ws:// endpoint — DevTools uses it to launch the right + // frontend (inspector.html vs js_app.html) against the right mux + // route. Every entry must also advertise a stable UUID id and + // include description + faviconUrl, which DevTools renders in the + // target picker. + for entry in &list { + let frontend = entry["devtoolsFrontendUrl"].as_str().unwrap(); + let ws = entry["webSocketDebuggerUrl"].as_str().unwrap(); + let ws_host_path = ws.strip_prefix("ws://").unwrap(); + assert!( + frontend.contains(ws_host_path), + "frontend {frontend} must embed ws host+path {ws_host_path}" + ); + + let id = entry["id"].as_str().unwrap(); + Uuid::parse_str(id).unwrap_or_else(|_| panic!("id {id} is not a UUID")); + assert!(entry["description"].is_string()); + assert!(entry["faviconUrl"].is_string()); + } + + // Deno direct entry uses `js_app.html` (DevTools' node variant) + // while CEF and unified use the full `inspector.html`. + assert!( + list[1]["devtoolsFrontendUrl"] + .as_str() + .unwrap() + .contains("js_app.html"), + "deno direct entry should launch js_app.html" + ); + for page_entry in [&list[0], &list[2]] { + assert!( + page_entry["devtoolsFrontendUrl"] + .as_str() + .unwrap() + .contains("inspector.html"), + ); + } + + // IDs must be stable across calls — DevTools caches them. + let resp2 = json_list(&state).await; + let bytes2 = resp2.into_body().collect().await.unwrap().to_bytes(); + let list2: Vec = serde_json::from_slice(&bytes2).unwrap(); + for (a, b) in list.iter().zip(list2.iter()) { + assert_eq!( + a["id"], b["id"], + "target id changed across /json/list calls" + ); + } + } + + #[tokio::test] + async fn json_version_shape() { + let state = MuxState::new(test_config(), "127.0.0.1:9229".parse().unwrap()); + let resp = json_version(&state); + assert_eq!(resp.status(), http::StatusCode::OK); + let ct = resp.headers().get(http::header::CONTENT_TYPE).unwrap(); + assert_eq!(ct, "application/json"); + + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["Protocol-Version"], "1.3"); + assert!( + value["Browser"] + .as_str() + .unwrap() + .starts_with("deno-desktop/") + ); + assert!(value["V8-Version"].is_string()); + // The browser-level webSocketDebuggerUrl must point at the CEF + // target — DevTools' chrome://inspect auto-attach needs the + // richer Target.* domain that CEF implements. + let ws = value["webSocketDebuggerUrl"].as_str().unwrap(); + assert!(ws.ends_with("/cef"), "got {ws}"); + } + + // ── Target.detachFromTarget dispatch ────────────────────────────── + + #[test] + fn route_client_text_detach_from_deno_session_synthesizes_reply_and_event() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let session_id = "deno-sess-xyz"; + let msg = json!({ + "id": 42, + "method": "Target.detachFromTarget", + "params": { "sessionId": session_id }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, session_id, &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // First frame: the id=42 reply. + let reply = client_rx.try_recv().expect("expected detach reply"); + let reply_val: Value = serde_json::from_slice(&reply.payload).unwrap(); + assert_eq!(reply_val["id"], 42); + assert!(reply_val["result"].is_object()); + + // Second frame: the synthesized Target.detachedFromTarget event. + let event = client_rx.try_recv().expect("expected detached event"); + let event_val: Value = serde_json::from_slice(&event.payload).unwrap(); + assert_eq!(event_val["method"], "Target.detachedFromTarget"); + assert_eq!(event_val["params"]["sessionId"], session_id); + assert_eq!(event_val["params"]["targetId"], DENO_CHILD_TARGET_ID); + + // Must NOT be forwarded to CEF — CEF has no knowledge of our + // synthetic Deno session. + assert!(cef_rx.try_recv().is_err()); + } + + #[test] + fn route_client_text_detach_from_unknown_session_forwards_to_cef() { + // A detach for a session CEF owns (not our synthetic Deno one) + // must flow through to CEF unchanged. + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let msg = json!({ + "id": 99, + "method": "Target.detachFromTarget", + "params": { "sessionId": "some-cef-session" }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, + "deno-sess", + &client_tx, + &cef_tx, + &deno_tx, + &announced, + ); + + let forwarded = cef_rx.try_recv().expect("expected frame forwarded"); + let value: Value = serde_json::from_slice(&forwarded.payload).unwrap(); + assert_eq!(value["id"], 99); + assert!(client_rx.try_recv().is_err()); + } + + // ── Malformed / edge-case dispatch ──────────────────────────────── + + #[test] + fn route_client_text_invalid_json_forwards_to_cef() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let payload = b"not-json".to_vec(); + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + + let frame = cef_rx.try_recv().expect("expected frame on cef"); + assert_eq!(frame.payload, b"not-json"); + assert!(client_rx.try_recv().is_err()); + } + + // ── End-to-end integration ──────────────────────────────────────── + // + // Spin up mock upstream HTTP+WS servers for CEF and Deno, start the + // mux in front of them, then drive a real WebSocket client through + // the `/unified` endpoint to exercise routing, context rewriting, + // and the `--inspect-brk` pause injection. + + /// Simple mock upstream: serves `/json/list` pointing at its own + /// `/ws` path, accepts a WebSocket upgrade there, and exposes + /// unbounded channels so tests can pump frames in/out. + struct MockUpstream { + listen: SocketAddr, + /// Frames received from the mux. + from_mux: tokio::sync::Mutex>, + /// Frames to send to the mux. + to_mux: mpsc::UnboundedSender, + } + + async fn spawn_mock_upstream() -> Arc { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listen = listener.local_addr().unwrap(); + let (in_tx, in_rx) = mpsc::unbounded_channel::(); + let (out_tx, out_rx) = mpsc::unbounded_channel::(); + + let upstream = Arc::new(MockUpstream { + listen, + from_mux: tokio::sync::Mutex::new(in_rx), + to_mux: out_tx, + }); + + // Shared across every connection the mux makes to this upstream — + // the mux opens one TCP connection for `/json/list` and a separate + // one for the WS upgrade, so the out_rx must only be consumed when + // the upgrade actually happens. + let shared_out_rx = Arc::new(std::sync::Mutex::new(Some(out_rx))); + + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => return, + }; + let in_tx = in_tx.clone(); + let shared_out_rx = shared_out_rx.clone(); + let listen_str = listen.to_string(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let service = hyper::service::service_fn(move |mut req| { + let in_tx = in_tx.clone(); + let shared_out_rx = shared_out_rx.clone(); + let listen_str = listen_str.clone(); + async move { + let path = req.uri().path().to_string(); + if path == "/json/list" { + let body = serde_json::to_vec(&json!([{ + "id": "mock-target", + "type": "page", + "webSocketDebuggerUrl": format!("ws://{listen_str}/ws"), + }])) + .unwrap(); + return Ok::<_, Infallible>( + hyper::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body))) + .unwrap(), + ); + } + if path == "/ws" { + let Ok((resp, upgrade_fut)) = + fastwebsockets::upgrade::upgrade(&mut req) + else { + return Ok(simple_response( + http::StatusCode::BAD_REQUEST, + "bad upgrade", + )); + }; + let in_tx = in_tx.clone(); + let out_rx = shared_out_rx.lock().unwrap().take(); + tokio::spawn(async move { + let mut ws = match upgrade_fut.await { + Ok(w) => w, + Err(_) => return, + }; + ws.set_auto_close(false); + ws.set_auto_pong(false); + let (mut rx, mut tx) = ws.split(tokio::io::split); + let reader = async move { + let mut noop = + |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(_) => return, + }; + let owned = OwnedFrame { + opcode: frame.opcode, + payload: frame.payload.to_vec(), + }; + let is_close = owned.opcode == OpCode::Close; + if in_tx.send(owned).is_err() || is_close { + return; + } + } + }; + let writer = async move { + let Some(mut out_rx) = out_rx else { return }; + while let Some(owned) = out_rx.recv().await { + let close = owned.opcode == OpCode::Close; + if tx.write_frame(owned.into_frame()).await.is_err() { + return; + } + if close { + return; + } + } + }; + tokio::join!(reader, writer); + }); + let (parts, _) = resp.into_parts(); + return Ok(hyper::Response::from_parts( + parts, + Full::new(Bytes::new()), + )); + } + Ok(simple_response(http::StatusCode::NOT_FOUND, "Not Found")) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() + .await; + }); + } + }); + + upstream + } + + /// Open a WebSocket from the test to `ws://`. + async fn test_connect_ws( + ws_url: &str, + ) -> WebSocket> { + connect_ws(ws_url).await.unwrap() + } + + async fn read_text_value(ws: &mut WebSocket) -> Value + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + loop { + let frame = ws.read_frame().await.unwrap(); + if frame.opcode != OpCode::Text { + continue; + } + return serde_json::from_slice(&frame.payload).unwrap(); + } + } + + async fn write_json(ws: &mut WebSocket, v: &Value) + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + let payload = serde_json::to_vec(v).unwrap(); + ws.write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(payload), + )) + .await + .unwrap(); + } + + async fn spawn_test_mux( + cef: &MockUpstream, + deno: &MockUpstream, + inspect_brk: bool, + ) -> MuxHandle { + let listen_port = allocate_random_port().unwrap(); + spawn_mux(MuxConfig { + listen: format!("127.0.0.1:{listen_port}").parse().unwrap(), + deno_internal: deno.listen, + cef_internal: cef.listen, + inspect_brk, + wait_for_debugger: inspect_brk, + }) + .await + .unwrap() + } + + /// GET a path from the mux over raw HTTP and return the body bytes. + async fn http_get(addr: SocketAddr, path: &str) -> (http::StatusCode, Bytes) { + let stream = TcpStream::connect(addr).await.unwrap(); + let io = TokioIo::new(stream); + let (mut sender, conn) = + hyper::client::conn::http1::handshake(io).await.unwrap(); + tokio::spawn(async move { + let _ = conn.await; + }); + let req = hyper::Request::builder() + .method(http::Method::GET) + .uri(path) + .header(http::header::HOST, addr.to_string()) + .body(Empty::::new()) + .unwrap(); + let resp = sender.send_request(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.collect().await.unwrap().to_bytes(); + (status, bytes) + } + + #[tokio::test] + async fn integration_http_endpoints() { + let cef = spawn_mock_upstream().await; + let deno = spawn_mock_upstream().await; + let mux = spawn_test_mux(&cef, &deno, false).await; + + // /json/list — the mux advertises three targets; the HTTP path + // does not need upstream WS to be live. + let (status, body) = http_get(mux.listen, "/json/list").await; + assert_eq!(status, http::StatusCode::OK); + let list: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(list.len(), 3); + + // /json/version. + let (status, body) = http_get(mux.listen, "/json/version").await; + assert_eq!(status, http::StatusCode::OK); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["Protocol-Version"], "1.3"); + + // /debugger-attached returns 503 before any client connects. + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + assert_eq!(status, http::StatusCode::SERVICE_UNAVAILABLE); + + // Unknown path → 404. + let (status, _) = http_get(mux.listen, "/nope").await; + assert_eq!(status, http::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn integration_unified_session_routes_and_rewrites() { + let cef = spawn_mock_upstream().await; + let deno = spawn_mock_upstream().await; + let mux = spawn_test_mux(&cef, &deno, false).await; + + let ws_url = format!("ws://{}/unified", mux.listen); + let mut client = test_connect_ws(&ws_url).await; + client.set_auto_close(false); + client.set_auto_pong(false); + + // Before any client activity, /debugger-attached should flip to 200. + // Give the spawned upgrade task a moment to run. + for _ in 0..50 { + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + if status == http::StatusCode::OK { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // 1) Client sends Target.setAutoAttach. Mux should forward it to + // CEF *and* synthesize Target.attachedToTarget to the client. + write_json( + &mut client, + &json!({ + "id": 1, + "method": "Target.setAutoAttach", + "params": { "autoAttach": true, "waitForDebuggerOnStart": false }, + }), + ) + .await; + + let attached = read_text_value(&mut client).await; + assert_eq!(attached["method"], "Target.attachedToTarget"); + let session_id = attached["params"]["sessionId"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(attached["params"]["targetInfo"]["type"], "worker"); + + // CEF upstream should have received the setAutoAttach verbatim. + let received = { + let mut rx = cef.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap() + }; + let received_val: Value = + serde_json::from_slice(&received.payload).unwrap(); + assert_eq!(received_val["method"], "Target.setAutoAttach"); + + // 2) Client sends a frame with sessionId=deno. Mux strips it and + // forwards to Deno upstream. + write_json( + &mut client, + &json!({ + "id": 2, + "method": "Debugger.enable", + "sessionId": session_id, + }), + ) + .await; + let deno_received = { + let mut rx = deno.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap() + }; + let deno_val: Value = + serde_json::from_slice(&deno_received.payload).unwrap(); + assert_eq!(deno_val["id"], 2); + assert_eq!(deno_val["method"], "Debugger.enable"); + assert!(deno_val["sessionId"].is_null()); + + // 3) Upstream CEF emits Runtime.executionContextCreated. The mux + // rewrites context.name to "Renderer" and forwards to client + // WITHOUT a sessionId. + cef + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({ + "method": "Runtime.executionContextCreated", + "params": { "context": { "id": 1, "origin": "", "name": "top" } }, + })) + .unwrap(), + )) + .unwrap(); + let from_cef = read_text_value(&mut client).await; + assert_eq!(from_cef["method"], "Runtime.executionContextCreated"); + assert_eq!(from_cef["params"]["context"]["name"], "Renderer"); + assert!(from_cef["sessionId"].is_null()); + + // 4) Upstream Deno emits the same. Mux rewrites to "Deno" AND + // injects the synthetic sessionId. + deno + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({ + "method": "Runtime.executionContextCreated", + "params": { "context": { "id": 1, "origin": "", "name": "main realm" } }, + })) + .unwrap(), + )) + .unwrap(); + let from_deno = read_text_value(&mut client).await; + assert_eq!(from_deno["method"], "Runtime.executionContextCreated"); + assert_eq!(from_deno["params"]["context"]["name"], "Deno"); + assert_eq!(from_deno["sessionId"], session_id); + + drop(client); + drop(mux); + } + + #[tokio::test] + async fn integration_inspect_brk_injects_before_marking_attached() { + // The core contract: under --inspect-brk the mux must send + // Debugger.enable + Debugger.pause to the CEF upstream BEFORE + // /debugger-attached flips to 200. If that order is reversed the + // child process navigates CEF before the pause is in flight and + // the renderer races past the first JS statement. + let cef = spawn_mock_upstream().await; + let deno = spawn_mock_upstream().await; + let mux = spawn_test_mux(&cef, &deno, true).await; + + let ws_url = format!("ws://{}/unified", mux.listen); + let client_connect = + tokio::spawn(async move { test_connect_ws(&ws_url).await }); + + // Observe the first two frames CEF receives. With inspect_brk=true + // they must be Debugger.enable then Debugger.pause. + let enable_frame = { + let mut rx = cef.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("no frame received on CEF upstream") + .unwrap() + }; + let enable_val: Value = + serde_json::from_slice(&enable_frame.payload).unwrap(); + assert_eq!(enable_val["method"], "Debugger.enable"); + + // /debugger-attached MUST still be 503 until we ack enable+pause. + // We haven't replied yet, so the mux is still blocked waiting. + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + assert_eq!( + status, + http::StatusCode::SERVICE_UNAVAILABLE, + "debugger_attached must not be signalled until pause injection completes" + ); + + let pause_frame = { + let mut rx = cef.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("no pause frame received") + .unwrap() + }; + let pause_val: Value = + serde_json::from_slice(&pause_frame.payload).unwrap(); + assert_eq!(pause_val["method"], "Debugger.pause"); + + // Now ack both from the upstream side, simulating CEF's responses. + cef + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({"id": -1, "result": {}})).unwrap(), + )) + .unwrap(); + cef + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({"id": -2, "result": {}})).unwrap(), + )) + .unwrap(); + + // With both injection acks in flight, the mux should mark + // attached. Give it a moment to run. + let mut saw_attached = false; + for _ in 0..100 { + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + if status == http::StatusCode::OK { + saw_attached = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + saw_attached, + "debugger_attached never flipped to 200 after injection completed" + ); + + let _client = client_connect.await.unwrap(); + drop(mux); } } diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index 00d43f717ad066..af90ca8308384c 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -316,4 +316,84 @@ declare namespace Deno { options?: boolean | EventListenerOptions, ): void; } + + interface DockReopenDetail { + hasVisibleWindows: boolean; + } + + interface DockEventMap { + menuclick: CustomEvent; + reopen: CustomEvent; + } + + type DockEventHandlers = { + [K in keyof DockEventMap as `on${K}`]: + | ((this: Dock, ev: DockEventMap[K]) => any) + | null; + }; + + export interface Dock extends DockEventHandlers {} + + /** App-level dock / taskbar handle. + * + * A `"reopen"` event fires on macOS when the user clicks the dock icon; + * the default behavior of showing the last hidden window is swallowed, + * so listeners decide what (if anything) to do. + * + * Only available in apps compiled with `deno desktop`. */ + export class Dock extends EventTarget { + constructor(); + + /** Set a short text badge on the app's dock icon (macOS) or taskbar + * icon (Windows), or prefix the focused window's title on Linux. + * Pass `null` or an empty string to clear the badge. */ + setBadge(text: string | null): void; + + /** Bounce the dock icon (macOS), flash the focused window's taskbar + * button (Windows), or set the urgency hint on the focused window + * (Linux). + * + * `"informational"` (the default) triggers a single bounce; + * `"critical"` bounces continuously until the app is focused. */ + bounce(type?: "informational" | "critical"): void; + + /** Set a custom right-click menu on the app's dock icon. + * + * macOS only. Click events are delivered as `"menuclick"` events on + * {@linkcode Deno.dock}. No-op on Windows and Linux. */ + setMenu(menu: MenuItem[]): void; + + /** Remove the custom dock menu set by {@linkcode Dock.setMenu}. */ + clearMenu(): void; + + /** Show or hide the app's dock icon. + * + * macOS only — controls the app's activation policy. No-op on + * Windows and Linux. */ + setVisible(visible: boolean): void; + + addEventListener( + type: K, + listener: (this: Dock, ev: DockEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Dock, ev: DockEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; + } + + /** App-level dock / taskbar singleton. */ + export const dock: Dock; } diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 8b219bf8438bd2..b624c8031a0ac5 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -544,6 +544,7 @@ const NOT_IMPORTED_OPS = [ // Related to `Deno.desktop` API (deno compile --desktop) "BrowserWindow", + "Dock", "op_desktop_apply_patch", "op_desktop_confirm_update", "op_desktop_init", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 85e8643aa675bc..27173770ff27d4 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -203,6 +203,10 @@ pub enum DesktopEvent { message: String, stack: Option, }, + #[serde(rename_all = "camelCase")] + DockMenuClick { id: String }, + #[serde(rename_all = "camelCase")] + DockReopen { has_visible_windows: bool }, } pub struct DesktopEventReceiver( @@ -335,6 +339,19 @@ pub trait DesktopApi: Send + Sync + 'static { default_value: &str, callback: Box) + Send + 'static>, ); + + /// Set a short text badge on the app's dock / taskbar icon. An empty + /// string clears the badge. + fn set_dock_badge(&self, text: &str); + /// Bounce the dock icon (macOS) or the closest native analog. `critical` + /// maps to a continuous bounce; otherwise a single bounce. + fn bounce_dock(&self, critical: bool); + /// Set a custom right-click menu on the app's dock icon (macOS only). + fn set_dock_menu(&self, menu: Vec); + /// Clear any custom dock menu previously set. + fn clear_dock_menu(&self); + /// Show or hide the app's dock icon (macOS activation policy). + fn set_dock_visible(&self, visible: bool); } /// Stores the window ID of the initial window created during runtime init. @@ -961,6 +978,77 @@ fn op_desktop_prompt( } } +struct Dock { + api: Arc, +} + +// SAFETY: we're sure this can be GCed +unsafe impl deno_core::GarbageCollected for Dock { + fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} + + fn get_name(&self) -> &'static std::ffi::CStr { + c"Dock" + } +} + +impl deno_core::Resource for Dock { + fn name(&self) -> Cow<'_, str> { + "Dock".into() + } +} + +#[op2] +impl Dock { + #[constructor] + fn new( + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + ) -> v8::Global { + let api = state + .try_borrow::>() + .expect("desktop mode enabled") + .clone(); + + let dock = Dock { api }; + let dock = deno_core::cppgc::make_cppgc_object(scope, dock); + let event_target_setup = state.borrow::(); + let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); + dock.set(scope, webidl_brand, webidl_brand); + let set_event_target_data = + v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) + .cast::(); + let null = v8::null(scope); + set_event_target_data.call(scope, null.into(), &[dock.into()]); + let dock = dock.cast::(); + + v8::Global::new(scope, dock) + } + + #[fast] + fn set_badge(&self, #[string] text: &str) { + self.api.set_dock_badge(text); + } + + #[fast] + fn bounce(&self, critical: bool) { + self.api.bounce_dock(critical); + } + + fn set_menu(&self, #[serde] menu: Vec) { + self.api.set_dock_menu(menu); + } + + #[fast] + fn clear_menu(&self) { + self.api.clear_dock_menu(); + } + + #[fast] + fn set_visible(&self, visible: bool) { + self.api.set_dock_visible(visible); + } +} + deno_core::extension!( deno_desktop, ops = [ @@ -975,5 +1063,5 @@ deno_core::extension!( op_desktop_prompt, op_desktop_send_error_report, ], - objects = [BrowserWindow,], + objects = [BrowserWindow, Dock,], ); From 75838e7ce47906572020887bc6e2f0041f378c59 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 23 Apr 2026 14:26:13 +0200 Subject: [PATCH 043/137] tray API --- cli/rt/desktop.rs | 48 +++++++++++++ cli/rt_desktop/lib.rs | 104 ++++++++++++++++++++++++---- cli/tsc/dts/lib.deno.desktop.d.ts | 78 +++++++++++++++++++-- runtime/js/99_main.js | 1 + runtime/ops/desktop.rs | 109 +++++++++++++++++++++++++++--- 5 files changed, 311 insertions(+), 29 deletions(-) diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs index a90c1d2c5e8452..eb717ee517b761 100644 --- a/cli/rt/desktop.rs +++ b/cli/rt/desktop.rs @@ -21,6 +21,7 @@ pub const DESKTOP_JS: &str = r#" const { BrowserWindow, Dock, + Tray, op_desktop_init, op_desktop_recv_event, op_desktop_resolve_bind_call, @@ -305,6 +306,33 @@ pub const DESKTOP_JS: &str = r#" const dock = new OrigDock(); Object.defineProperty(Deno, "dock", internals.core.propReadOnly(dock)); + const TrayPrototype = Tray.prototype; + Object.setPrototypeOf(TrayPrototype, EventTarget.prototype); + + const trays = new Map(); + const nativeTrayConstructor = Tray; + const OrigTray = function(...args) { + const instance = new nativeTrayConstructor(...args); + trays.set(instance.trayId, instance); + return instance; + }; + Object.setPrototypeOf(OrigTray, nativeTrayConstructor); + Object.setPrototypeOf(OrigTray.prototype, nativeTrayConstructor.prototype); + Deno.Tray = OrigTray; + + const nativeTrayDestroy = TrayPrototype.destroy; + TrayPrototype.destroy = function() { + trays.delete(this.trayId); + nativeTrayDestroy.call(this); + }; + TrayPrototype[Symbol.dispose] = function() { + this.destroy(); + }; + + internals.defineEventHandler(TrayPrototype, "click"); + internals.defineEventHandler(TrayPrototype, "dblclick"); + internals.defineEventHandler(TrayPrototype, "menuclick"); + // Start polling loops immediately. Use core.unrefOpPromise so these // pending ops don't block event loop completion (e.g. the pre-module // tick used by HMR, or module evaluation with top-level await). @@ -494,6 +522,26 @@ pub const DESKTOP_JS: &str = r#" } break; } + case "trayClick": { + const target = trays.get(ev.trayId); + if (!target) break; + target.dispatchEvent(new MouseEvent("click")); + break; + } + case "trayDoubleClick": { + const target = trays.get(ev.trayId); + if (!target) break; + target.dispatchEvent(new MouseEvent("dblclick")); + break; + } + case "trayMenuClick": { + const target = trays.get(ev.trayId); + if (!target) break; + target.dispatchEvent(new CustomEvent("menuclick", { + detail: { id: ev.id }, + })); + break; + } } } catch (e) { console.error("Desktop event loop error:", e?.stack ?? e); diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs index bda9a6b4893312..c3ce292afad80c 100644 --- a/cli/rt_desktop/lib.rs +++ b/cli/rt_desktop/lib.rs @@ -12,6 +12,7 @@ //! and navigates the webview to it. use std::borrow::Cow; +use std::collections::HashMap; use std::collections::HashSet; use std::env; use std::path::Path; @@ -46,6 +47,7 @@ struct WefDesktopApi { >, pending_responses: deno_runtime::ops::desktop::PendingBindResponses, closed_windows: Arc>>, + trays: Arc>>, } impl WefDesktopApi { @@ -492,26 +494,99 @@ impl denort::desktop::DesktopApi for WefDesktopApi { }); } - fn set_dock_menu(&self, menu: Vec) { - let menu = menu - .into_iter() - .map(desktop_menu_item_to_wef_menu_item) - .collect::>(); - let tx = self.event_tx.clone(); - wef::set_dock_menu(&menu, move |id: &str| { - let _ = - tx.send(deno_runtime::ops::desktop::DesktopEvent::DockMenuClick { - id: id.to_string(), + fn set_dock_menu(&self, menu: Option>) { + match menu { + Some(menu) => { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_wef_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + wef::set_dock_menu(&menu, move |id: &str| { + let _ = + tx.send(deno_runtime::ops::desktop::DesktopEvent::DockMenuClick { + id: id.to_string(), + }); }); + } + None => wef::clear_dock_menu(), + } + } + + fn set_dock_visible(&self, visible: bool) { + wef::set_dock_visible(visible); + } + + fn create_tray(&self) -> u32 { + let tray = wef::TrayIcon::new(); + let tray_id = tray.id(); + if tray_id == 0 { + return 0; + } + let click_tx = self.event_tx.clone(); + let tray = tray.on_click(move || { + let _ = click_tx + .send(deno_runtime::ops::desktop::DesktopEvent::TrayClick { tray_id }); + }); + let dblclick_tx = self.event_tx.clone(); + tray.set_double_click_handler(move || { + let _ = dblclick_tx.send( + deno_runtime::ops::desktop::DesktopEvent::TrayDoubleClick { tray_id }, + ); }); + self.trays.lock().unwrap().insert(tray_id, tray); + tray_id } - fn clear_dock_menu(&self) { - wef::clear_dock_menu(); + fn destroy_tray(&self, tray_id: u32) { + self.trays.lock().unwrap().remove(&tray_id); } - fn set_dock_visible(&self, visible: bool) { - wef::set_dock_visible(visible); + fn set_tray_icon(&self, tray_id: u32, png_bytes: &[u8]) { + if let Some(tray) = self.trays.lock().unwrap().get(&tray_id) { + tray.set_icon(png_bytes); + } + } + + fn set_tray_icon_dark(&self, tray_id: u32, png_bytes: Option<&[u8]>) { + if let Some(tray) = self.trays.lock().unwrap().get(&tray_id) { + tray.set_icon_dark(png_bytes.unwrap_or(&[])); + } + } + + fn set_tray_tooltip(&self, tray_id: u32, text: Option<&str>) { + if let Some(tray) = self.trays.lock().unwrap().get(&tray_id) { + tray.set_tooltip(text); + } + } + + fn set_tray_menu( + &self, + tray_id: u32, + menu: Option>, + ) { + let trays = self.trays.lock().unwrap(); + let Some(tray) = trays.get(&tray_id) else { + return; + }; + match menu { + Some(menu) => { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_wef_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + tray.set_menu(&menu, move |id: &str| { + let _ = tx.send( + deno_runtime::ops::desktop::DesktopEvent::TrayMenuClick { + tray_id, + id: id.to_string(), + }, + ); + }); + } + None => tray.clear_menu(), + } } } @@ -1264,6 +1339,7 @@ async fn run_desktop(update_rolled_back: bool) -> Result<(), AnyError> { event_tx: event_tx.0.clone(), pending_responses: pending_responses.clone(), closed_windows: Arc::new(Mutex::new(HashSet::new())), + trays: Arc::new(Mutex::new(HashMap::new())), }; // Forward macOS dock-reopen callbacks (clicking the dock icon while diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts index af90ca8308384c..d42824b49e0ff4 100644 --- a/cli/tsc/dts/lib.deno.desktop.d.ts +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -357,14 +357,12 @@ declare namespace Deno { * `"critical"` bounces continuously until the app is focused. */ bounce(type?: "informational" | "critical"): void; - /** Set a custom right-click menu on the app's dock icon. + /** Set a custom right-click menu on the app's dock icon. Pass + * `null` to remove any menu previously set. * * macOS only. Click events are delivered as `"menuclick"` events on * {@linkcode Deno.dock}. No-op on Windows and Linux. */ - setMenu(menu: MenuItem[]): void; - - /** Remove the custom dock menu set by {@linkcode Dock.setMenu}. */ - clearMenu(): void; + setMenu(menu: MenuItem[] | null): void; /** Show or hide the app's dock icon. * @@ -396,4 +394,74 @@ declare namespace Deno { /** App-level dock / taskbar singleton. */ export const dock: Dock; + + interface TrayEventMap { + click: MouseEvent; + dblclick: MouseEvent; + menuclick: CustomEvent; + } + + type TrayEventHandlers = { + [K in keyof TrayEventMap as `on${K}`]: + | ((this: Tray, ev: TrayEventMap[K]) => any) + | null; + }; + + export interface Tray extends TrayEventHandlers {} + + /** A persistent icon in the OS status area (macOS menu bar extras, + * Windows system tray, Linux AppIndicator). + * + * The icon is removed from the OS when {@linkcode Tray.destroy} is + * called. Multiple trays may be created. + * + * Only available in apps compiled with `deno desktop`. */ + export class Tray extends EventTarget implements Disposable { + constructor(); + + readonly trayId: number; + + /** Set the tray icon image from PNG-encoded bytes. */ + setIcon(pngBytes: Uint8Array): void; + + /** Set the tray icon used in OS dark mode. Pass `null` to clear + * it. */ + setIconDark(pngBytes: Uint8Array | null): void; + + /** Set the tooltip shown on hover. Pass `null` or an empty string + * to clear the tooltip. */ + setTooltip(text: string | null): void; + + /** Set the right-click context menu. Click events are delivered as + * `"menuclick"` events on the tray. Pass `null` to remove any + * menu previously set. */ + setMenu(menu: MenuItem[] | null): void; + + /** Remove the tray icon from the OS status area. The instance must + * not be used after this call. */ + destroy(): void; + + [Symbol.dispose](): void; + + addEventListener( + type: K, + listener: (this: Tray, ev: TrayEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Tray, ev: TrayEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; + } } diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index b624c8031a0ac5..6e39a00f039421 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -545,6 +545,7 @@ const NOT_IMPORTED_OPS = [ // Related to `Deno.desktop` API (deno compile --desktop) "BrowserWindow", "Dock", + "Tray", "op_desktop_apply_patch", "op_desktop_confirm_update", "op_desktop_init", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs index 27173770ff27d4..fcefdb6f933006 100644 --- a/runtime/ops/desktop.rs +++ b/runtime/ops/desktop.rs @@ -207,6 +207,12 @@ pub enum DesktopEvent { DockMenuClick { id: String }, #[serde(rename_all = "camelCase")] DockReopen { has_visible_windows: bool }, + #[serde(rename_all = "camelCase")] + TrayClick { tray_id: u32 }, + #[serde(rename_all = "camelCase")] + TrayDoubleClick { tray_id: u32 }, + #[serde(rename_all = "camelCase")] + TrayMenuClick { tray_id: u32, id: String }, } pub struct DesktopEventReceiver( @@ -305,7 +311,6 @@ pub trait DesktopApi: Send + Sync + 'static { menu: Vec, ); - /// Returns the raw window and display handles for the given window. fn get_raw_window_handle( &self, window_id: u32, @@ -347,11 +352,24 @@ pub trait DesktopApi: Send + Sync + 'static { /// maps to a continuous bounce; otherwise a single bounce. fn bounce_dock(&self, critical: bool); /// Set a custom right-click menu on the app's dock icon (macOS only). - fn set_dock_menu(&self, menu: Vec); - /// Clear any custom dock menu previously set. - fn clear_dock_menu(&self); + /// `None` clears any menu previously set. + fn set_dock_menu(&self, menu: Option>); /// Show or hide the app's dock icon (macOS activation policy). fn set_dock_visible(&self, visible: bool); + + /// Returns `0` if the backend doesn't support tray icons. + fn create_tray(&self) -> u32; + /// Destroy a tray icon previously created with `create_tray`. + fn destroy_tray(&self, tray_id: u32); + /// Set the tray icon image from PNG-encoded bytes. + fn set_tray_icon(&self, tray_id: u32, png_bytes: &[u8]); + /// Set the tray icon used in OS dark mode. `None` clears it. + fn set_tray_icon_dark(&self, tray_id: u32, png_bytes: Option<&[u8]>); + /// Set the tooltip shown on hover. `None` clears it. + fn set_tray_tooltip(&self, tray_id: u32, text: Option<&str>); + /// Set the right-click context menu on the tray icon. `None` clears + /// any menu previously set. + fn set_tray_menu(&self, tray_id: u32, menu: Option>); } /// Stores the window ID of the initial window created during runtime init. @@ -1034,18 +1052,89 @@ impl Dock { self.api.bounce_dock(critical); } - fn set_menu(&self, #[serde] menu: Vec) { + fn set_menu(&self, #[serde] menu: Option>) { self.api.set_dock_menu(menu); } #[fast] - fn clear_menu(&self) { - self.api.clear_dock_menu(); + fn set_visible(&self, visible: bool) { + self.api.set_dock_visible(visible); + } +} + +struct Tray { + api: Arc, + tray_id: u32, +} + +// SAFETY: we're sure this can be GCed +unsafe impl deno_core::GarbageCollected for Tray { + fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} + + fn get_name(&self) -> &'static std::ffi::CStr { + c"Tray" + } +} + +impl deno_core::Resource for Tray { + fn name(&self) -> Cow<'_, str> { + "Tray".into() + } +} + +#[op2] +impl Tray { + #[constructor] + fn new( + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + ) -> v8::Global { + let api = state + .try_borrow::>() + .expect("desktop mode enabled") + .clone(); + + let tray_id = api.create_tray(); + let tray = Tray { api, tray_id }; + let tray = deno_core::cppgc::make_cppgc_object(scope, tray); + let event_target_setup = state.borrow::(); + let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); + tray.set(scope, webidl_brand, webidl_brand); + let set_event_target_data = + v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) + .cast::(); + let null = v8::null(scope); + set_event_target_data.call(scope, null.into(), &[tray.into()]); + let tray = tray.cast::(); + + v8::Global::new(scope, tray) + } + + #[getter] + fn tray_id(&self) -> u32 { + self.tray_id } #[fast] - fn set_visible(&self, visible: bool) { - self.api.set_dock_visible(visible); + fn set_icon(&self, #[buffer] png_bytes: &[u8]) { + self.api.set_tray_icon(self.tray_id, png_bytes); + } + + fn set_icon_dark(&self, #[buffer] png_bytes: Option<&[u8]>) { + self.api.set_tray_icon_dark(self.tray_id, png_bytes); + } + + fn set_tooltip(&self, #[string] text: Option) { + self.api.set_tray_tooltip(self.tray_id, text.as_deref()); + } + + fn set_menu(&self, #[serde] menu: Option>) { + self.api.set_tray_menu(self.tray_id, menu); + } + + #[fast] + fn destroy(&self) { + self.api.destroy_tray(self.tray_id); } } @@ -1063,5 +1152,5 @@ deno_core::extension!( op_desktop_prompt, op_desktop_send_error_report, ], - objects = [BrowserWindow, Dock,], + objects = [BrowserWindow, Dock, Tray], ); From 3d7db3f5dae279076352e7be068d9d37325666d0 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 23 Apr 2026 14:33:12 +0200 Subject: [PATCH 044/137] fi lockfile --- Cargo.lock | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 213 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d9ad19bbdeeb8..c55bdb5d5ad980 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,6 +485,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" +[[package]] +name = "backhand" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e7812fe72262dd26c2b4309c8e8f2c028db1ffbeb21054f009f847f09ca92c" +dependencies = [ + "deku", + "liblzma", + "no_std_io2", + "solana-nohash-hasher", + "thiserror 2.0.12", + "tracing", + "xxhash-rust", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -843,6 +858,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cache_control" version = "0.2.0" @@ -927,6 +951,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cdivsufsort" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edefce019197609da416762da75bb000bbd2224b2d89a7e722c2296cbff79b8c" +dependencies = [ + "cc", + "sacabase", +] + [[package]] name = "cexpr" version = "0.6.0" @@ -1700,8 +1734,18 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1718,13 +1762,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.117", ] @@ -1813,6 +1882,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "deku" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf55291257a2a5c90cf50ae17b6bbaabc3fd13642cf3895a71c412513c19630" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec2a42b511fc5efd9183f4f71c17885d627b17e7fd9a61a92089406caa4397e" +dependencies = [ + "darling 0.21.3", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deno" version = "2.7.13" @@ -1820,6 +1914,7 @@ dependencies = [ "anstream", "async-trait", "aws-lc-rs", + "backhand", "base64 0.22.1", "bincode", "boxed_error", @@ -1876,11 +1971,14 @@ dependencies = [ "eszip", "fancy-regex 0.14.0", "faster-hex", + "fastwebsockets 0.8.1", "flate2", "fluent-uri", "http 1.4.0", "http-body 1.0.0", "http-body-util", + "hyper 1.6.0", + "hyper-util", "imara-diff", "import_map", "indexmap 2.12.0", @@ -3231,6 +3329,8 @@ dependencies = [ "notify", "ntapi", "once_cell", + "qbsdiff", + "raw-window-handle", "regex", "rustyline", "same-file", @@ -3605,6 +3705,7 @@ version = "2.7.13" dependencies = [ "async-trait", "bincode", + "deno_ast", "deno_cache_dir", "deno_config", "deno_core", @@ -3628,6 +3729,7 @@ dependencies = [ "log", "memmap2", "node_resolver", + "notify", "rustls", "serde", "serde_json", @@ -3638,6 +3740,26 @@ dependencies = [ "url", ] +[[package]] +name = "denort_desktop" +version = "2.7.4" +dependencies = [ + "deno_core", + "deno_error", + "deno_lib", + "deno_runtime", + "deno_snapshots", + "deno_terminal", + "denort", + "libsui", + "log", + "raw-window-handle", + "rustls", + "serde_json", + "tokio", + "wef", +] + [[package]] name = "denort_helper" version = "0.44.0" @@ -3751,7 +3873,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -6444,6 +6566,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" + [[package]] name = "libc" version = "0.2.172" @@ -6517,6 +6645,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", + "num_cpus", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.8" @@ -6558,13 +6707,12 @@ dependencies = [ [[package]] name = "libsui" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b6d6bbf43ba95540d1681826c8d7acb9744708398463ccbcd3c3a5d04c2fdc" +version = "0.13.0" dependencies = [ "editpe", "image", "libc", + "object", "sha2", "windows-sys 0.48.0", "zerocopy 0.7.32", @@ -7018,6 +7166,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no_std_io2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +dependencies = [ + "memchr", +] + [[package]] name = "node_compat_tests" version = "0.0.0" @@ -7252,6 +7409,9 @@ version = "0.36.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" dependencies = [ + "crc32fast", + "hashbrown 0.14.5", + "indexmap 2.12.0", "memchr", ] @@ -8099,6 +8259,18 @@ dependencies = [ "unicase", ] +[[package]] +name = "qbsdiff" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc7f24528be166f08f2c7becaca5618865499b6ded2565d5afcd795cc0d7596" +dependencies = [ + "byteorder", + "bzip2", + "rayon", + "suffix_array", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -8862,9 +9034,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.15" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" @@ -8929,6 +9101,15 @@ version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16c7f49c9d5caa3bf4b3106900484b447b9253fe99670ceb81cb6cb5027855e1" +[[package]] +name = "sacabase" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9883fc3d6ce3d78bb54d908602f8bc1f7b5f983afe601dabe083009d86267a84" +dependencies = [ + "num-traits", +] + [[package]] name = "saffron" version = "0.1.0" @@ -9515,6 +9696,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "solana-nohash-hasher" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8a731ed60e89177c8a7ab05fe0f1511cedd3e70e773f288f9de33a9cfdc21e" + [[package]] name = "sourcemap" version = "9.2.0" @@ -9689,6 +9876,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "suffix_array" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907d9ca9637a22e3a7d7c7818f6105a7898857359e187ad3325d986684b9ec3f" +dependencies = [ + "cdivsufsort", +] + [[package]] name = "swc_allocator" version = "4.0.1" @@ -11611,6 +11807,14 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +[[package]] +name = "wef" +version = "0.1.0" +dependencies = [ + "bindgen 0.72.1", + "tokio", +] + [[package]] name = "wgpu-core" version = "28.0.0" From 4dc5a9f251f18fc685246b6350e7320656795489 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 23 Apr 2026 14:54:10 +0200 Subject: [PATCH 045/137] add appimage runtimes --- cli/tools/appimage_runtime/README.md | 20 ++++++++++++++++++++ cli/tools/appimage_runtime/runtime-aarch64 | Bin 0 -> 936456 bytes cli/tools/appimage_runtime/runtime-x86_64 | Bin 0 -> 944632 bytes 3 files changed, 20 insertions(+) create mode 100644 cli/tools/appimage_runtime/README.md create mode 100644 cli/tools/appimage_runtime/runtime-aarch64 create mode 100644 cli/tools/appimage_runtime/runtime-x86_64 diff --git a/cli/tools/appimage_runtime/README.md b/cli/tools/appimage_runtime/README.md new file mode 100644 index 00000000000000..37abdba6fdc2a7 --- /dev/null +++ b/cli/tools/appimage_runtime/README.md @@ -0,0 +1,20 @@ +# AppImage Type-2 Runtime + +Vendored prebuilt ELF runtime stubs from the AppImage project. These are +prepended to the SquashFS payload to form a Type-2 AppImage — when the +resulting AppImage is executed, this runtime mounts the embedded SquashFS +(via FUSE / squashfuse) and execs `AppRun` inside it. + +Source: +Release tag: `20251108` (downloaded 2026-04-21). + +| File | Arch | SHA-256 | +| ---------------- | -------- | ---------------------------------------------------------------- | +| runtime-x86_64 | x86_64 | 2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d | +| runtime-aarch64 | aarch64 | 00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 | + +License: MIT (see upstream `LICENSE` at +). + +To refresh, download the matching assets from the same release tag, replace +the files in this directory, and update the SHA-256 table above. diff --git a/cli/tools/appimage_runtime/runtime-aarch64 b/cli/tools/appimage_runtime/runtime-aarch64 new file mode 100644 index 0000000000000000000000000000000000000000..3cbe957458e9381afc94f3ff24e63b0e3ead9473 GIT binary patch literal 936456 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

XfoS;_Y;AQ9riLHq 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%0Aphx^u0 zo^o!zYh#7qe`O!-^!t-2>(|uf)-fDidiFm&bY+=%M;3mljjiekF#X%+DEk>DzEo*NYiPKqvi^hIsz`?X_gZB3$>cy(R}9FvTJ(W;ZcsYmVQ3MSw${8e zzrmgV>!Hsx#WjUbZ+z*~%>T84PaB)y7Hl%OEo}BXKGrI*X~3rX*?>)RwrR}A9vBXr zM~A@XH%ElcqEo`4QmJ6aTdKxk`_J z>Upn^b|0}%uY4Q@CjW1ik3Rx$C(_;5I+%Pny8Be*<9#1^Z{_3nSWB5nKK`9k&!0m+ z{$qG#@(~BVN$XM1^w)Q|_P*Ak_SaszoqdCouH2L#DcjMKm7{X4eCi6;rD`AXu1O}g zYJuHnovfj{5qaZBcWce+0ek}a1-l-yVnMe3HR}37dl%PX^QeC6Y?KYHb)@cERBOJ4 z@hrLUAa%(8ZadzOj8?e?i7;ybWv8o-S!dXLo&F1L3g9;c@Ec}&{D$bf+M|jE8Ol<* z-hI3Ch>i4d7DJo025iPlLF{au+jo4?R%=~dl=WBg8$*$6V$5leJ3ifd<5k^?9 zlUsX{HD)Sp*pSJ_=e)zJnSS~CG z2RdtK(?rvB|FKci)`2?51d%?f-FeXCK`K{hv87YTDD3_4J&kAJHi!Y%j;${}RTrSiNEBPZNB8F%eWl`|KZj?Y8*3tCU;os$th?{WNv2=T+A z@mXm33(+)RHad(i8)aN+ZP1^-Y{|!r*bSv}2;aGm(2a=|GM+Q$VKKhE{$-FjTPBPJ z;E`fxl8fM7#Nb@Hb?^joOM4$#F*wHL$mNzD z=NL+E%?b=&)5u%_y3LMrv@f;ylN?GmbY+rVX+ERk7h2D(bxD#B>3Z^)X)-K6gbeGL zU}J16Wq4)S(o1X^cCc?~8MeleVeXn~?Rz1=L-BaU0)N0hCc5Y1?F*DCdS#F9qrQ`s zY0Iu2kZJ4E@)JtPx+I3wko*(VN2GMes@3i(?0> z@1!#oW9vN2H0BXwlTCLb?6p~QI;^KXDeRrP&MX0Y!{KhYJsPAp!{`j@O<^w`bDE#> z>dm$xFn5K6xn~^ADGuynRjA?bO_vC>tL=2Tif2F*t@3E&Yh8r^TySbKNux;9%Mdd3iC0{&ssTKuH5tH4<5m9 zlD+22J$J38{E2v)KXK5p*V5(Q-5Kn)EgpZu&Y`j8UOdfSb8~1i$-THE_Zk|8mwRhG zd90~CniI&qc$$Ck2gZ>XgAsBj25h&1yj3Pho8*f~?$t4Ny>e<6Sds7kt6^kcxs!`w z`>Tc4nj$aF|8Q)u?7NIIg-w+!3|^u=fH*r;_M?0Rt?5i*2zxElT;;lx^i!_!;I=1i z55pRMH&CZ!TgzunlYBJ!XY$i@@1yk3m0&hy{|%16ZOEK|XsmfMz5hjGZ{Aqk0G8Xp za{g-LU|BL$IF2Gi#fQV-*egSYZ_b}HZ{7Qz}_#KdSUoW-sd&tJ`@{GQ{2ftRV5E(4|N_V?5bPIba zd1dnwr(EH8F=uFmJ^20Ep8>yLy>dYAX2S26sq>9nIlEeWSUcGL7y9KZ;5p4-S^ocm zKm0x$ml^r}=kSNs@7|cp4EjEkKfM3qLH=-N`W#y_J$Ia#P|9D~?UZvW{_tBL9>CJ1 zQ}l;F3D#2aPbA(M_3>u_uaO zoC|4=pCqh-C=(5Z4h^c`8{ht{=yv*5dNWDis%$T9tD$XKhW-_82g&i#^Ewmu>%?k(!Fb0U zBdlgJ6I z)gF12kxky8p4;m8H)`MH$E^MEvaS8_BF=tz%Kuwx<^N?h2BwG0YEAKn0@F=dN>l!wb&HyTSFh)@B35l3y+#((es}PvHzx?jfVF>LR;9!edlh!*+(q8N_ixz6aB`% zf6uJ%gg(8i_io(BIibAwYo0m2=%*eW%P#j@c|V--PCjU$mBgmR)3i%Oz%pOCt=Mw{0 z+n9eTQp{eqxXKW}((B(#zbhB+WuEUwW?T4TZ>#>!aH^FbqX}NJk(z8-SAdC7A^k;9m0^SpH%nc#q5(y-Hefqw^8>JlfAE< z`dX;FX8Fv%^<39ccRFt`pzdd>dpFOk-a=2k?%uj;yXyTR^$I_3Uzal%%voQ|Gty4* zYtYLEyRlKW16{*ftD5UiRQXIJ_IW zT>NW)XU@j4us;UcgaP5-OG{vJV0AmRJO(XahL#raT%JbD60i|*Fu4v|su>S8X|gVT zJk0jcVawToMTcUtf0&`!>4F!$H!rlQqt%&qsJhx`43eTua%K@Ue(+|oCRI&VV{*Pw?V zM-M;l&u(1f=;1JR7&Fe&!(EmhE=c?f`cW~QuQP^R-MYnJ*xOCrTB9(aR}0Z61&Nvm zrB@3RAL86%+qd!Rv}Y`x+KMhs*Q@l;>{||C)AFv?JvWM?~#>mO8!v zxoyxGAJEOutbYJr?u9q6^3E&pP5gWw9!%z}rKL*)tJi?fY~ESVb81_Vw%u;ECC8HS zZd-)$q1w_z8Mcf^?^5=a(C%W~mhsupKZ<_Owc7B>G<>yMImAC$wvTRoH4UcSh4X{)mKZML*Xl`++0&+;VT3V)wem|OSbuo z#8#I0$Ms%Je-uw*e{f&4BQU9C|ypG$anMhnG5gpa%@9A6y*n zAx^6PP<%=M=^5Fhw$EK)$r2aSvg`kwxa)~BWOs-wQS}S6da~+wt6q%P5U~f%F<^`$4m_iGa?O<1SdNpG} zvS1T3R5DMPY;o2@MLp}Gu-&ZnP>+JO0ohoTl8yb~%Jw;I9-nJrvMABZS>mo-$%M(y zG@s)TeV{Wz^{gGQPgC{gr3dkAk<7n?sfD_DvV^SL0QEa}lyM;`EK7Z%n{6 z`dod0EOq-vcBycudc8D%<2B(}@>KF)K8`Ik4rU)k9t*RFz$|-WZ=4Qhhq4p8*vs0* zY^TRgaAk621$F}SU|yO0jDJ8TllO_-DX{GXOC}d4j!~adDfwso_xU1-T0VO@Ipu{VTmWc^8ETXC$x^4IQ%0c;mq z_JCW@)D*TgR&@@dEq^k&9>c8jRcGQiJ zDE~n5`%JdT1IWNrwM8N;KG_$*7V+B0*?|H7E0Zm94Yd-Y$NhoE+rjfl zY>}f6*tmag&!5K@+4HB9vqjW5lJi6Au@TrJ3&BPvTjXrU!pYbo?3-2O;zk%!EJO0W z4UD*PycWl9tp_V##x7Yq0=wjVXIyAZe2e~ZeYteIB)@$a~^&TbY@rvB=lgu&g3!%s4gH1%=78|o)eW~@~KJL>8vxxyYYdP$76e>qLOt~4X zw}7XTui}9}ak`_&Qgu5t|I;}-i%ie4p2VTk@ND{X^{MBA37uK{8Z-oY z$5t&I-QFB|(6`zx=a2A>`nMaCx6Hlw8uxT99=hv4y#4E?`S(2QrQ@@2*z+fG>`O1b zYV*TYn%~!%U}kgrf8O%l@|cI)c);lcL%!ZD%C@jd*{QyD`a|V^f$~M0XjeU=owk)? z-)`3bcR2sI!vo#d|L%X8>Q~*b{Bh{^+&oNsg6ML$9k%UZ613s|PR`}?mTW)PclbjdDZ=?#1|?KRy-`PB^z#b_S?XI+XAnmU|;jmD=jP(Bu+n#^VqnjH9+E@=+#+z zsvr9!sqvuyG&VE`?AFhCt#a$&UeX;y*AV~I`Gj8Db$xu%Pg>8+S!%84a@TQfbJk&7 zIeA56?DH^K>w0wBnB+Ll-#ajAO!DA`d}!m5%ei;9@QXkAzA@IBgdylX{DCn^?IrV> zb6NjX8NNSymYG7VXlHjVwmauoY5mMwm1C0TSbW*B>&7G}7KM{L*vlfTD1850l)GvY zF{LcCAkJPdeKW=+`_?H}Z36SPOFI+`BCfqTdF7bo_uyv$4DShqH#dNR%i+^)=G^W6 z)s?M}=l*YVHDe);KO5p4vd42d!;JI2CYziWuA3h@dRxiQn>ouoF>8MCXmNmjVCcIU zCdWEMOmxfzf8#5CF|8-d1|SRn%ET7tnAnnv$-VXPwVHKL5zg2wpI?16m|Ol6at5kG zliQlRSeq5(%+>lYupbi`-Ge*lE+QvNXfk`*fr#B@Jz3-;v z;Sb!DJaYL>$)nS5N+z$oDcLvUrsP|dHzkiM6Qu&{+oB4^zCf*T!kg zK5OA6f?TWPlR85zEmx57SLLVw>pW*kFFV`jh0X?YX>)1X?YWmu?|<{(IQq=ces9~m z1Ls=pS6S+VOl@`BsI@7Kk^RbP1p6JDpXNNy?cEI{Ka2Tlu#`HBS-Gp$IC9QnCRbH@ z^4?+Ek>2k*v&zN2a4vic`x+C%p)e{u3(Jqr&V1(aU>T=e3}0dr=Lky1-L@j!8aFdu z0)tbl{o%4Y>0N8g+w!S;S(uoMn?JC1{aEPH_}vN?D&}67)cDUDQ_&a4KXd2uE#IxE zWdeP<;OMB9365V^%GfJ)=NFRmvszn%rs=6H){ViJ@y3je=a^|ra?FCmyt`yH`54+4 z3BO?zW7&>Tjw}5z@5F1Ex7U0C&qnf0)#mQJ*iZbI#k?}+a&Yh?vGmx&_X`)} zN^d3?I?7t9Ao5)8>gHY-^WzttXPSP*x-gPLVsirHdws}bI%w=#a(S(D>7k<{YGPaA zZ}LoY#TxG8@06;qLcz0R@`vQ-B|@_2y&q)0 zU<>!7PCFMl?bKK^PCGmCMW24KBKafQ_+w8SS0Quc-^q78LfiDtMA{~Lqr@O3>vT^v zOV&N}LA%}TWoWfqepm_r@0)0veyz2GH6~UZ$m@-Q(JxYlbdc50)Uz?IZuRp@o;l#y z58!CF`nQ_z1bM8iL)_G4m6zY!PCtrwGd*p4o_#`oOqnWwGi_=+*uOC!etG%V1n=B+ zxlK;{_E2^WytCIp<|T-KPPqp9UxaVt;MnqEmtOX3wcY6Ok-qZRm;o;kS9}X0i9sxJEa9z%OSMwbL!&ma{ zw!vLHuQl_Q>?J?B#r^#9*N%NHLClto5kH-PPXCYa6l)(6V&?gl^Dv&3(!OsCdW=O<>I zuW_57_zd5+ZOl25Cj4aeSd*;cUZha=Nq*uJTvx-ZBb?W@$#y>!W$1^o z&b?}$-ModK3QC0<%E{r3Ekej%BtabXPjq$DVf(=`mMLXp;-cIgNCR$XV>|4cKg}=^96PuhLZmk{{-n^&C*NQL3 z+3qI!S?F->N#TS{`(gDblii?k&NqSerApADD;5-Yg-$L`RmC)0VkHd1g?D0 zQXKGE<83Rn(1zYz>^R-Gc*uYLhHzi40QsMyg`0KezZWhw-3ig4Q#pSGKrU_S7JE z`u*%32*163Y51L~4>ey^PCJ;lU)9BR=igKIT_*XjT+c^Ws4nGWtFPM+qFeR<873(^ zQNB=(^Nh7d%94*ivGmMa3$t5qg|`#YH4}+P3Kw^f;~bg4*c$r=`XvL#!SDZOUPkuW zZ&^F_H+Nh|Klo^~ztuEUmK~;!abKX$EHibte?o63?NHqJJo=&C=?C?_+FL`}yJ@@j zQE=P-jBvznZ`*ZG_|bb&hbLQi_pkcqvHnfsf%;o^mHPgzg|p~;+rMb52`7I8Pc^;| zfuA;P6&Ifyq2*wj4dIUMIxuF9YsPo_*nXV-PVQ!GZ?eXAL2o-_+a1%w*ZaXq7!1$g z%x?={)Gd6?L*AEjKMb~KFrG^o&)clN8E@h1XZH*q&p$rYze)L*Z4Q6chhL`6+qkzM z8Lhtj5!dRII@S(Z&%x)-+#6!tZ*a$boIVtPHP-EMW)jn^F<;ahoZHEc@~sh2W09)|ix?6=F zLG@V|ZO=3&bMYDC)Twc)cD8+}yl?Md@H4tZ%K5*ZcJeD5TyPfz^=)bBEIwL8GF{aV5hputXlh?fp_+JtB#lqlg3D{RXq5+@T zikr%JmTnMEiRo;prCp+{+oG$|qAN;2&&UnG8K!-Dw-QW({f*b5C$7cz>ZWXB+8bvr zuV}5z58q#L31=sc$zDCf1bTy)&Ft%$)-oXgPl%0`dh32Af=(uuq8xOUp?-GdvG&<% zhaTB_>Z5vO^Bm}!Ii}pANxWa;p{X32+M%g@aYfRl%at!{pzVBUD}lBUv{gXc684IT zKwEc;Hh)Iig3#9G(AMAJ-)Zqu=pVDbV^#9%tyik8wtx54OAU>^Zvusa}j&4M#mEC!#<7t9hfH;@K~4_5YH*ExPEP+7*kADA#I( zOTSA;1UjmrqlWq>Sajqiic|IGCFGZ#>-`NKMf{dOl3u1_Jw4Q03y&?f-Z^dCdMsVo)avFSY0^1$SZp41!`Z1&Z-r^nO&?~pl zM_vBO==BM)_?`D16`hQgDrnr0X)SALtj4g`hshR}UB+It+w8q)g%jD5apcn$bb5q5 zp%6eZqs}oaK*!3m^Ts10LLsZ0m&17RnX>>bl1hbMW={ z;HQZG7VLWQu)aRrSIIp;ywmvlTi#vyps%&M`bjuJG>rns?iiy@8`aJ@|DVNwZM08)YoeoKKOt5G-*$xH zZ3y0ifsQiT^aSHr&u9#4pC*m9GS*M2{A-XSwk)v6;D^1x8G}>#tufe08R|=(jWziL zF~bH4t+$d#$^LiTADI2V5k&FC^@_6Cg^Y)5R$kCjRG8=sl>RQH)> z-FaxYn2ma{X6^ALFWej4s~iQ`eOUJ7sQ|W z1-$I!+3TP?nuQ*r4Kt-D&?y75$DSK=d8@VX;_X)Ax8m(Dz>$}?+q}Q&N7j5>y!{{g zMCI4hF4;^lgsGaI`r-gQ_$N6ZHoO3^r`LN zDV7n3cYfrF?6k?)w8pG#4$hl;^fLGwMRw%#pL`F4ZLQyVXHX9ER&85|ZzWj~#ZKG^ zUKN+#M0q;nAi`cLk`2qXu9f#B*BiudXv_FKvGrF$uY3TFtEG%9;#{#F zo>dvO=sM{s>8z#K-jIB`IIDFgv9A9Db86QM^sT$jmp%-K78I@2P%Wiw5~I zw(fp*{hSXeH^SzF`dvH@GPbdG-e{u#o9KV(!l2#n8(f*AewV*~F7==XHhASz2lD{3 zcchPINY^3fy>;5QbiBi$7i2eFPAq68V_80pc=>C}>l9DGpPy#}^glWi-c-VyCU{eF z^!aJDk2WdRx&`{$6kFzZI&Y*O#G8*fyg5R-Td*-ZE#4F)E`~RHPyI3x-l!eoO}@jM zNQyW4Dc-Q2#^TL3i#G*>c=MQtH}f3csO{MJ4u9BB2>!I+fR1$dlioL?$K{n5uLnP9 z^1b87l|#0#89KU@b4b1aw@J6gE6VKtw*4mW|0(+pUl19$K{8D8s}sMYo8J}4M_0FZ zI=X!&bVs>%ZIgdx-rc=cJ(=vlZ!#C7vGQ&D+AF8DwmtP5IrVjZO9yq)SEBQF?2QUy zwn5G~d4;^s+}!r&)9QSy&p*$MCGNk!wauLK`2U)7F6Q^+Jok9?(FrYPl$FcAH{VRZ z!x+ZC>3x;4uk|CEuhDnzY3cAMkbw141 zrtRv#;`48T;=leqpYyU>uDT|x?kaHI8-k7&?8P=>6;XU+AN8^C!hV7)Rj&li&i|QL zbLA|!9citVxzQy4}>VtmRr?4UT${bF9t^m1}aw#|!7 z|HZuE^lkG>rng|jJkJXS^f>glLqBWUB@acAV+ky{%vE*eA*mmyeS@r+3Cc81n+R;fST(*_Zwu8mfOL#_n zujU;+*U3D&pRxE|aAClYf2r>izNqK!T4%)_>XC^#lyj}mTyZXTTGj`b zPUuE9w3xvBF4~ix6CsO9^tx{dwcoRUiQ$0 zT(GPAOON+ofn1o4&ACam=~~}G$^lC^>KfXuJe(|Yamwi{-BUgAZo$lP<$aQ~%F}ko zcoyw_47w^!_I5op$pjkb@?1IXFE+-K3z^Eyrp!v(5#V{PvHY?zZ0}|Azf4(=GhSGC z*1Lu>mN;dw&j3DER-%G&F^8PNPUhoXx}fy|#l=L|T!)^ZLyzITEQcPI^#}N%_vKTm z48>K#$Rc8lWj^{;bCrfZmd$W*f#!JH*Xy^_U(Cr2_Fq*_oc9gy)7}O2X~$&x4q0Z! z-}G(`Sf9gx!7<^!M;u(}Ih8f%7&_;XaG(0Sns>Nw@nkJ$N~(|LKh1zA#8#GT@1*04 ze67bTbykcYJ?c-)oe=J;m(MiHSa}dCr|qwZf6=#xZVj|rIYzadK|*XRIX}B~JmdJS zg*mOojK|J^NiJeu>w!qPRdKz~O*B>5sw>FjjZMrV2Wj`O-y8!^{NTwq*@M~pd<%k) zlt1t&^d2Cmt9Xi8P{(z&&`j5F>Ljk2-}~Bl%P%U6k2h0l$@?j8G7HvV4@PprWrlmj z-!luc#(egHHgX>pj+Sp5oqY+P;*IoGzNtDL8j9Pc{KEq(+Oh|lKYjfzYB|KN| z!;dU6o4TO=Bb0qQW1RS8hl_1}?;$>_+#}W{qenEimQQ|q{&vkr&+!F%pC``%T@kJauQE|DK@m!t98NWaWMHU=B2EdICn%mVdMFt4hw>%x`^OO3B|>EZq>h!Zs` z=6*5rGjoy2)$p+8BJ;!blsEHg6I*Hm+gs2D7xQ}t-=F)Ssd_oDa{F!QgPG-KXEyTa zSz?@7$RxM^E%UmrYMHm@su0guxmn=vw$~T0SOZ;4&IrG$d5XD=m4~QnH~QcP#`h@R zzcyrc{#^I1+^?L*{^*=z#q-WT)`AY`M8@BajGg84ZJ$gzIrDCxo`W1kPFZ_-+|C%C z>C4)FKJwrGZ}Rjl{C5$u|8~lp<;&ha8QJfB?|S6E-kZgHlac%0|K=h4Qe`6Zy=7|M zk?2>MA>@75yt&h}pk)U2t%t9*&{+OPz2dCr_wUNW7JhRu?U&O`Zn%@4ZYW_nePw92u6+ZQx@F<)Lqq#gcy3Kq- z<$UBz;g!hFDL#Jdxk)^?WU^WC>T7mAA31GwV`y=BWog#v#!hluu=mRj-Kz6(W-@0q zi}|8!TicKURvcqgIElPUqLX{Y=dO_A80Fa86{+||due+!=W9$8os2oF4Kx13w;)bm zPGX!$KWGdXcsb)m-vZeMGamCTXgDJ}8yon6F6@q<=D%~?1oMtQZI1~P)A;!?{rJ-} z2S4YBwvw}m6*)3dIKP20F5j1NJw1$!m#hrtm0ROM<4NO6V+va>sd07M@B6DtDSJkz zZvk?u>ZgT+>(Ch1Si2bhH{kQu22YFm7{7IlwQ9;oW>UUA-sYTU_G`SA(^uu8y5`bc zvmaSs)wLx2q~b0YpF`Wj;ob)1%re^K$^jqs-}?LhsnwIrIV$ff%n3_|%tV&F1zwsN z8?GD?Jum#Oe`6hVONQvYui_291#6H~;(v61{}lZWVn2!h#qc7`IG4;479_VK9VmYT*iOiU-dUU+h3Wrvo|X`wiUnq;#t=vUvCJ+jxWl_9`VKY zogE$9v+^wE&cybvKD+gmhfT6`RN#s3v$LMqQs9>?+pcMcKL<=%VtFhfQ+`Nvsv2X*+^hx)(Pq1aF>bPxF1cC1ZtkZRhhoS&dImq6^6bG?gs zem(Z=R$>5Mlov9J?-%rL@*CDp_!^(8|3>o?Vh*1JH$J2D(R{tgK-MnW>(&N*{cY=a zeBRFAECG+ztnt>E;w(Qa&r0ox5(D<;ScxXZT-4?@S>}p^PJ5zGTSQx?w#fH>e_C61 z5|0~kTNoFqws4)PE#IasqUYDdP}Ij>njYc&1m#tWMnw;SrgYQt4` z^Zz{Jt8wDCDZE}+K1eq!r<}5_c`Wh8&3h8w4^Q&Pgn4Vd;f!d9#*SjpZwI1R_m7HR z?W11C-KvlCF~>*gyp84TD-!bsS-){?_j7r~rHs+NY~||h&m*Szbt`75HRAb+2>J5* zR{6SSf2vh4(MOF*<`t8prX8vECC}yh$RuA(KA6@vYQDb)0qo0-c2q=sG3KUMrMLNg z+>0=Vj?|VXwGZw{+VY>o#xCT&iG0FuXKst1wp7p-E9Zdri1wRlQ+hiJC_{Cxqi(g~ zIAbQr{;JlP{RiwW#)4VAXxCONzI0}6rpCE3jpg%#N9X3+>vK|LnCCA_dtT4h=5qdO zu2~Rf-0D4*9hw+kQGAl+yYGy&4wdgd+YbF9b7pKZSS*32O4>0QS}MJ5@X+HvujlkU zaxC?1Dh}u19y@TOEnCJ}b1gyQyg_i4Po5rZ=;$W4j?F+^eR+p`7h$hrd0jL6%Q0`m zx*^7UF>w<+#=CXB@fW|8!Y2iUqFFH?Wg4eU_c@D4$Q`%;C401|ko;|pN#%HIZFDE} zNfyPW2g27`IbF)j4I&$?d(RBqi^4yRd=~S@AxXP>hzX(7dlpLRQn#M4Ufxrr;YvFa(8|Q8S`?HFPQ^hdx#xq z-;o28e5@H{O~Zvg)&;TF|9w7d&*#G*@FmgTt2&V}2PT!X#-*G!F6FGBC{J4bK)KyT zX)*_!kb2O~wx0Qy#o;Fouahl^en8G>O|fK+D`#F|?j}qf_MCN|)i&i8W9zL-uT%K| zqE&Q?M$va3^F6J)RKMbGbDmDx@pwmM=v;UK!G ze_Phh_3$DW+r_n84&0p6s<`y${Ab3x8qc=kB|R(0w3glw-n@79Ijyg75>e;F*fC$j zj#-5r(~|Rqfxb@YGtgI>W2QuSHxLXb1FT*3Avemixwp1F83n^CJJQT`;OJ9b+XAuf zZCSBS*-AOlvE7Y+@><9-#|9FfdIGr@N07aer>4zk=?0faHwm*E3*vX0O;pX8kWF;p zW}QuK+ePl!4n<5X2%UedWWEGCr2l`3u2dZCG$uNv4}v~lEQjm=Ed^%E>sr^&mFiLTEj7>lv*JOZ>=$yu^|DCQ0llacGi>g~52|$L&5UL6^vX zV6Eq>F6L}>t$Te|Y~J1*L%*-q93u0Roo6w>$+%FN?z6wuv)p%ovo@Z1%)qu*0of8EHu|3}H;30SsdcTRb$`rkzSv3tOl#(?zT z<@8?;<;zZsLYKFH)^NUh@QpdsABCQJu6HwsC;u+|M)C9zHdo7?Q>S-UmX`&I>4qpL zjBM`mn`Gx`vsvr1;LU7ZzsR-f77k225c|#NO;eQo&D6clK;!_)x`Dd<(Dv ztBNP$XOqWJe3R{mRvbHD)&9@ zf9$PSG{qg7y1+8J%cdzeT&DTw{{N}y4MArGbIp5*@hYaE_UpYM<*Izqp=aFx)P}6S z{wub7Xz;NYY>2Y;%n{wEe7#$)I*DId{cuLOOt}O7KXU5)-d$Fml9Os5ZRqHvKiqci zW*m!m@>Tx}9oygNytlN*dhb`<_u}i%BD08jEktHTO6Y%NR5x;|2f5LOOtNIt*Zk;Y z6H{EF8`&g#unXBF+f1^_N1J4~8opimbnP(mNwJo6`6QY18t?p`_I^eE@Mn}x@v=6Y4W4r^7Ei@X z@lEB!Z*yOIA zNB-CHI@*klR~j__M(NEE@zEyXa^Ip|(K{nYy*8t0la2OS>d;v9mj9ep{=c3}saLWVHJMTFv zJJPV$QS|tbVL=mZc4>0^VFGpUr{3e_FY_zgby_Uq#;8ybn*W_J5|#H_BY0-+H$w9PRiGbblI*+{=7(CAx+6gl}9PI5SpZ za%1DLEnixjtuq;C-^2e!W8!ZHD5nH_|19QE@X`VrT(ur_dodKuBv#mKvH&+!tS^nq-%0EQ;_@ZxA;Jb&%m}Etq_#A$g=<09C z-uZfVbS!r14%wxDU_2dPG^+LZHeak4JMqBHxw1)1)%HWTp4KWm^)UW=_t|;Y{^GJz z_Y~!~zFb_;+WBxcc4`iG>L~2gT-#2aX4_z$4fxd!{?pGHu9yM-D!LEmRFon9oe212>lN9imMkViMRO zrD4OF0IuwqHrW1JL%jL;qU+$#_1IoFV0&d-wwE>jnQM{VtojY-&K|kh%F|hX1fJ_& zCufy*nxju0xtX;+^l8h7&4PAxw4VDH*|(yJu@GuAu@8awE}j?Gzd}4n@9g58kHU{v z_?G^;fosJt^#1Ft=S#^HkNvxjcGNb>U+~2E7(>dxjYNGhdo24C-@i!tyyTBDAFJHh zAhMtho~^_tH2+YU^bsF!@{>csJ}zzGtZ8&x^FlsN51Q2r_3`Ix|JnUd@E>iM9ru$j zPrti1@b@pV_PZzzV`FV$A9vN^^*4njc(I)Yqs#KxU&Sr&d&Ix=oXWK2sV&>~Xq-vE z6FbI^E)}m$+c~kP(Q&WRCvIP;4W{!}=E3H?WL>{%Ul019_ND%ho*VO-u(cM1I-7Oh z+jqk8!uoS#U^)?jKiIR@+9(5ku8eEr+U{3hVgho4HczeQSv^zFo-!3Xe5*_Fwc=c> zP3A@4YPHcCgY=7API~|7x7L)pwClU;MtcruJvyx&+CsFYI}l3DF{bx5QSJ%zcG-4l%qQ(MZQIb1rgtmtijHRg&>id_3Qy&W zh&Oi3)5e^|E5@hyo*_POoE$x^7r%7B{5q2egxiQy6edE;Kd~#qUNoWb9lJvG?-|tX z(!oNCCgI4fmvbmKYHWM?J9z(8?V-pyBFs}&=Z80!oMAp8pG0$L-QZveYy9SMjjUc? z0S@*AO04r~yW#IlaIlcQ_2#f2a(s;P6}C1ne9*u8ZWB&UVVxmrPiHsmU#MJVQ+*fnyYt~1-ZPDxkZGH~ab0rZ+kdx9 zzUoVSWG6gb?~C1s9N6Ud#a2F$*ElYa$5>u(`R>n_TJgYA`*%^Ip1FYq7b*^4*l795 zJokBiE00raTBU<^#*{0+o^syr#1E$aX)X==KTdt#zPa^6lfReow2(cuyBSYvtNh$O zV5*1bl{;+7E0Y&%L>IlD`p*YGwJ)W=^vm40X)7XzxxV9CXmjbTovy%~<5Rv751|d-8L;1ODy0)?Ddu_$vo@KUZ*@GYvE{|wX__xT z1`7R z=2mmvW8o;!*kj=+u#9*n^UVQp6j-LYXFVI^yFcJp3~q-VgUceHUE?L&nZMF}T~6zv zNvr{Az^;TpiYNMzv;Db=D#w&z^MMe;NU zKRaa8P}ZNvI)aOB`GFj~q7ywFXWsG1m5O`jC7z~jN9g}f`g;4aIZyq02eeeH&&dg*Z>)}xlx%g4~@3+w-@KZSRFC9WS_8V{~;&=4kBVEjU zH$FXdjyp=22jUfPpdELA*O0iVcHS#4igge28-MnT3t_jCACn8SnVPGUbX-8y$$X@)?@xa zbA0KrCgNUVCzfS4y)3x0CpIA3$oJ^e$DTmZXP{{SeC5oO_+D^z9x?O5iDol!Km28M z7VRx9)V*GG?`!JrHFM$z%$%-1#s@6L^Y#|e7s}YcQhRg%C1^2hehn4E+_Njf9%*P>Q zrgXM1Api#{;7-|3%XA`byCy z+pwYTKrc3ni7m@Cvvo-}Dz7x6-x2ag9I0-rSBnvV!=9MZY~a}pfu&Pf;g zt744QmdgHQnp;MFh-R5f_A(zYqieK*bKmDokbVBFj;a2XH5T~yJ%0c?a^*ueun=8k zb3CAJjqjLWbl6YY^q7EZZpHyOqUHsO#Z%u16vM(yEj^?Ypzl`Wbb}> zwbl?Ed|PG3R#E@0{NE4Gs-1n#yeOx$qgOI7*|GQf2cB3&8PU##ss|apA6~nc`#Vky zIItF+ecmbdt4d@#7lgy5KCB$Tu(u5A^K+03L$72g9#Z*O&R<%UA?IudFBAB-fte z8{wK@t#U)lu)gcazgm@JOp=ohY?vd0`yD(jah}nCo_&F5+V3adqE{EWWMgai{uQ3P zx|pl~JM_i7RlK{+sRP`ij(OB^3;)GSzsjCj#n&HXJ)D@fXB5+RYQ<^&%F(OPshss_ zbSkY!50v|7N{`wHuBF=boo|i8?{L3Cbu&#F87AHYuPGKkW^GM*%2x5KBt}d;G6=mK zKtH`vu^HsZtw%;GNA5Cm2F! z&E%^bi(l;-`PI6Q5nsW3^<&rE z)A|E=;O05eo)P+joCC%`m7F?Ug1<+8n*ehzA50YdkzcF~{E?1U2M)n27L}#HTze6} zd&q&Y>;ZlWU*ZVC(C4nrUj=;Y!>+D2oqOrm!jESXFo}3DLElzCT$|DJ6$U!)LC;u+ zugR+$Kk8Ziit>A%6$a5sv3g)I)ITG9c?$Rs-P@uV80{rde5!m_UxbdLNqE!6n#Ao| z>$mz}h*Y}cI!rDIhd)*NV#73E=42SUbr-QSt3hkBVlzeX;T}RO7Rhp78ODF@I25mQ0C{%WCP~L%;EQ3KGY=WqK*O4}ek7bbR+iax zY?T%3Cw@M@bU3>1h^75z*JJG8xp{17e23N7Wb|A0i)oq^5^Q=UVKH;FQktT9-E5P@+GG`tPvVcK1v_`DS<_Itd z3MNjD!f59pAId*d=2XzP ziCE8jtN}CTFT5>1pO`Y*KZ+je*kJfI@I5~!f4rX8<5yYFxaAYq|L=3Jy)5c~7xvJ@ ziF=JL5+B%7rT^fAM{_D?{^X8VG`NKK><{tu<;VQgDV_PJ^oce73IBe@nP<~yr93`Hc4A73;7af%OgLspd!ylKS`$f1!^*qL0!`3X=MzHGAQAhH|zcw}wrWuOOrPD%SpH&a~Dquu`k@t#s$S zgkFCJXIEMsDGwJmvET6Nr?Ty}d;NiHBgnB*}##7;P&bL8PKKV6Z@YB>tQ`=a2@kh(z)aEzdP|B z*!cs6qj%xMbj}k%zjF2tyJw*KnoCb!wYvh?cBlEkxoGEx$>iD}ZML)vrx|O{^!8fT z#W!EX|7E%Dwb16ShVk~jY3084@8_{EIxpRcNe>O5xPHgTK=mqqb%W+@DzR6do%zC(ojx;Z%_C24I2v4T?`-6R<==iflH)izXwZa*-0WaQ(EO|`!i=jQOK^u!atMKtI zbm*S6Fmrs`&(%O5zzy=&y0P!xdW4sIY(I9oe9`f=XlE9&ua)W_a(Q5twaaTKIggik zfO2?^hhHd%SCF>4M!B}#|MkfiF}~SKv)5`(=3mKmqkO&WMXwF9mX$Ng9C-xUTUuGz zH1XI2Yrh4KDKD#q%y#lOGM;S4CmqjOS7aVPCKpgU*?5J*=l{{Zsj=wSJ{@?TG)KFb@1_`{47tt&zkS!aoi5nM8g$ z*++$20lpH6JEe!TFJ=P}$TzeGjkE)&q@V^&(tAu>trRv+|W==bK zW-j`)i9ZJ}=a|t)9$^iptrQr+!`e!X_L8`G?dZm*`9%nyw!4NR9pKD06v6f$#wPe) zfF2bAzT=(!LvFs43f@P3ZEu>_f)XO?G{JI>%ADva_3&ck(~M5%x33UTO5l ztd*QAd$MqMJ^5kFfkhWMhV9f@NBr(v;4Wuv9N7e19txN-2PWo>-B4NF-%x6HmGONU z-<2|+a>_me+;@;q^ey;UIp5sIHxa%ud^2Fo(hz(wUp{;0AvapwABvDymO2Wlqxee7 z!*k127rw93$Es?f-a}|H+qOZBYirAkHVs@?%O+ya| zn6Wd`P38e&1`gl@d6}~1qwOQ-5rfWHciEH=TBd_Ma@U8ay;h5^GS;TNp}h8p%P)Ef zI;!AnF}57WCfJal;K;*_o%+WFfrq?`Ty&IN&P`FzaZB|GISN5kBtmFJ>~}b5uzVH`eEC_YUPd!(ig>Mdi_~O zPR{G$uNLF<)+L1#@M2=BPM=Svaj{028JV0j_7Jet`ga6Bx$5*{J(F{rDL&%F`J4-( z^=)Em!~>^*zly; ze=P`%4r> z_+ypOuVh=j_{ob`$E(1(ZR97Gep?J3)Dw5&PtddW(~6!S;aOz@-wGZ4_jmXmeBSQh z^L+4aBlxU5N5NIa{kdiEblNl2?H5fZctbjQz(wyo8&d{O=csPxG>y7*z-#%;d#OK< zTrGD{|AA>cYtiAx_NMzY%cvh7GPc$D9o|rE(jDGV2yY0(8^m8Cv{w%Oq-#&H!wUj^ zes}|Yk42pSzP|a$$&K&^@eLn&{vM|N2)v;Y-jF{$y~vC*)xOcb>Kyn&u_5<8w3b3X z-;MBvGIYiE<$3+d^HswfB>#Vm|En3_M#iW36UDbFmz4Z%!Z*$W$2V^^WuuP?$8_fP z7mc}LA~0VEZV9hS!LN{+pfjPrCVFf2`Ig%Bvv|1qns~B&W;tg39S!gY;ay$=-z@M= zwwBi)FQ!O(oOsDZ@KAWOpML2o-F9gI1TBXPdF|FRc12^~)U2G>~pt2W|MF zvqAlm|CQ*gs$2R$bmN?H_HD+=Ja+z;|0~GX7(I)gnVXG11O0lkUF-DH2{y5gkBlGV z^G9zGzX)*VO6lmv^^1`kY3Nj|?7Zsfm)Uc!Mz8V@C$AZ@YzaBkmCNq{G;;3Al&1aY zSJN*ubKp;#8{oGkD}B5lePjVVO*}EJ-XB{9Ph1V}=;eHxeC8yAtstGXReB1#dn$WHHx_tCg$-J}iy2VMnPIkTW>Oc6{>e>z5Z{t_YI2N1Kjw*Ov zD|%FzyqV&4+u(Jp61>hg+*e&V8h^i&gV5*jx+>N&tKfC4DOPtcpWbgpkR#M*MO;6J zbQ{G&GB3OKGR7s0LGyzh-I2olJP6+|o3^8t_hT2Q2QsVR+pFMzrP+S}pP1{J3U_K@!}TB1qaeD zmA_Vf{0()kt|TuMc5?678thxGosVmlzX2bb<8M%WWEnmx$)64IgC9A4oY1Geydik1 z+JtA{@XqmpEuv}lxhnyOp?aMU`@EQT#Se*3c%ptFZFaJ*WMP*TnCJ`As{*zadk`LK z`9@!AL8Ii(isgZK`N-I}pbOFHLFk*g9+Q!vQ41WnJ>${!{4lDUOflm3QZ37rFPP(0>@( z-zNNxhT}PprZmli_WdK^>(pa|Q{;^848s>>1N6WP3g823SL4b^;Gpy;^=p zgq~l1H--FGqmLYfmo5B+iS@xB=HGw+TKL@&M+TuYEj(4Yd&%uf`m07)LR*2pD7yY| z=2Q8Dq%#$V;2W%w%qRa(4>-FCoMm5D#{zJ6BQ^l`^w@t-_htSPoc$#@``Y38@mK6m z#mEC0OQ-JPDdC;|;?K-?tQCFr>BkI{7Bk0m@b-@m5LCE|8-~Z-Hg7>*dw*m=tCJl*{2V~AH;X_ zt<1jC(eoU>TgLnc;JbU6|6b|5qXX6D@P|5R%;CGtzxeJ_`0iEk2hBgRZjP>&Nq?)T zvzq>+u`3GgTR_@pOH2)wX< zA~`ipTClo)q^ZtEchA0za})5PM)A?+8?&?${X%-ycYBGOAx0>gWY@9Qz`N|`cH(TJ zBeCa*ldZt6YcOU@1@=-DyD12cMP=8?e!@=Fz4mvwv3K77w^uhwFQ`tmTW*=9rPz%0 zgY&Umzwk2r!uj}xuVEbWdoIHVEPvP|{1=b(;)c~%KmAc%ORcO-a?9QurB8-EIVNpl zwfsyPtIo;Ocqj7Au{put7km?{e>`$xwPNPG*cUARI|V)FX7rfN_|0`r?URgOKBXvr zaM`vl4A>V#o3bMdu!y3=%9kBs z9t4l=d9qW1@rj3Cke@yXU7yRZ(Q`!?=< z=yv66QkpFEyF6&SXPnu5Bi~*G-A69B=aic%eckA6L3Fmr<;-1TtZ#8Xo%GiEjJ0GW z?GiU2J3e@fJS;=TyZQZrEt}VT)`?BNIRo8{HIWU-{&lI@)rwcvvpFU@_PLQ+)ywch zk3e7bU+Jzl?7^?`bM)47@|UhyS5sTZ^~t=N+8V_`lwxZUm%jk~i1_Sygmp*jN_$Q| z-(<*FWZU=VnMs+qK>s0Ny#!x?zO$&?`}P`mnkQp_a&&l8H~PKsCj1aOEPdO+_;R2@ z*()y(BiAK)pBDkY_iwegu7pMf^MX~rxChe;eESXH%?xzlTy(4fgPi2dM#;5J_5N5l z_N{2Tm%ZMSbJp!8{b6KeKDt&tdYELSC;D<}-6t^HVLfBi=;TfJ?w-jAs zDLi8-JYzrp{a*b0J7{YMb^jb$2K*e|wHY4zM|fyEbRUA|_fWTVEXwTKlaQeycv1!V zfB%8{JsBFxaCMasFf6yM1P>kN@X(S359M8KI_=38k?xA^9NS~k`hNb{L$&lh7J`T7 z!xL{ohVDRy4w#I-+=LArf`@KLh8Dv^8-`oedC1UwN5^v40aE+YX76xhXlk`^)Q23+ zMWzX7g|Cf=2DW(fmxa9`ye$Xj=N%eYbSwS(VFKoYb-S}pV)fbN5l$ZhXYEbsNx*r5 z1LyRP^MHf5PH+uy_Uu|8HiE%Mke?(>y$cv)aT1*6ZzxE>xi|r5%Iqpmz}W)MCBXRt zhxh-d!~3lyIOiweoMT)#Tfo^M8wKawBshoFE^uA|oV&RJXk7&1|FIa)mm$oP2=uGr`;lKI>t~z6u-7Mc!L3YD>KYZT0 zzoIitoAdD_D2BVy&aEz$F9H5p&bpU;x0uA=ThhEY7@Bc1MXJ?cg^oclh%>OST`l{ zk@@K<&iYIvcvmpBVf{9~^Tv?uA9L3u-1Duo9{p^S_B|g&Zx$cECpD{@_2Nsjhi6x7 zeV_)J`9);tWysJ}!Bp}MIah;xENAVj7x+idA|C_D$6PZpb3S!SKF%YjjL(s~+0CuU z(6@m19(-!5=Z&@NY6rMKmb*@Ch)%tw@cQTfFnmy75nXz|HO)!GuDkg4>W6D zb|CXtQ+Rx#)hs{h`^0&Ci9Rc?M80vgse0#8XCZyE5@WC!Lm6Z6Glnv9f{3@)6Pxb& zH1!|98Zk4(ov;S_0{Fz)4YRai6($+5$Z84|#W3!q=eDlnnGyC&$qD=jXfi2=ijk(A?JLH@rXZ?BL3HfQ&H?L1F zk96}xG*OpeCb*!BF49_66nPaP?$h0~qkV%}%@1uS_aM(z)}jy0!>;GQ#Qv!Set5V+ zJTkdgJ{7~qyz|1T?wT3CP0`M|93D;yR}AeL>Q+pjMSZ!{7hGAeQ)S!Wb({K`e5f7xzD+#J z$8KjD-*aBbU_0lTBgD{rxSb2IYnktl-Oi`^9-V!#or{U9z}Nes@oNjvrEtdpo>>W1Pcb06f~j*kr!a3qK>KG=@toVol^JIsC8fxn?0 zaHMNrQahS+Y_bov!`#>J{Ttc=N9ub{x05`_n%_O(fQ_9o9A5LWVaD7>_qc6X;L{%F zs-CkMJ~S5Q%>KjQFc#+4{#{Z#%vA)N>O<`?XXdwmLp#i^`F~06Fjqn5`a|t7XKlav z8`@!R+x{!59p)LVRG*{X?zlW}@v%Rk&W~nl?vX0`PB?X5ldS`M>cq3C2} z_n$KP51$zGL1g!SWZ*oW?aQ*a{xOdnm>1bwckut<753J*ziMygtd^~Z&!_wp>P7GH zeovds%-pG z{i826Gh|m}jkZQ-e#(r>Ty3Q+tTJO3hRx`Oy_SFB#?PRb)ovNevh%nIQ{;# z8J(%|yiMQ#lQEs^OIaB31s3*LzJ;rj`mg%b|D}ok|C0W<(EoI6*uoHFH~^e>m=O!r z=Meq%aQ0es{~qgB_9)HCy|xNT2zq z^D}1d%NjPbXH@3QKVCh2=HdJiGY?D|IrCuJsG0ps!!0LQ6t;X~WkJiNJMEU29(t+B z`n0|EgNJ@rblF2M7GcMYx$mJL^UQ3Wy4>F~@it#e17~ko7n2`U_SVH_D|*JoQSAvM z=Dp`??G=(w&^-&uLcc$?LU{$<{4Lmb?su1&t?$62j*7Yzv!p*<@2QCSb0!hnv|0Y4%+9wZ%?dYR`SZck2(1 zUv1BgY{NGcut(6B!@o(3l%M#LJ$Lr#ZF9S4nAkadR~|O;legM)^V97S{omrdkS?>cp@J-0k9^R5$1?YSQ;v**6I!k&9S z&yU`0&#g-vao34*d+y1v+H-gF?D%!|+-FmV-}P>>JvS$9)LkEB+H?EQx98UK?jC+8 zui*PB_S_SrsGt7>SA;(KO<#2G`#JX9!=JL}KF+iK`PApP=T4!X19|q`Hv+@&>d&(0 z{^5$piw6uJ~TjM9SQBMNQEkzVzwq-d}Nj(TXo!cHO`g*B3o`#rKQm zT(Pd`r#!!jXHVv|7VXLTNzn@6^wXRl7tP6ezUV~i#JdJ^UMlkD^Jh6*i|#+aVPyGn z({leg=2VZfukG~whOX88D!=DE&VX@aq-^EV=nBOiJ~;5iO5(WRC0D8Drv_cO4qtjb zzSbIa-aPcfRp^GL=!O;dE!w#O{m_qYn2U~Bihfvg)gnhX@u4TyjI`IT#wK+272o=4 z30?8x3(1GV+DSM1VJo^z8|TioqNlcB_F?@hl+Z^j5^Xlnp4zf#Q|+l8i+Fgi4e7k# z*w*}&1%2J)$w!DjC7DqnoeCYgA@xm1j}C?Y=;+ejg{J>wwK3|iXak$_W3@5mbQ|al zH=q}OUHM%uh6Y_-&_6hC54M75aE9W{0(}-fV!^cy-J%u!Ac#IYRDOP3eWMlqqKowg z>1Y;kZj~;`xn_=i~3+vJJJR5AGM+%)T4v`-P%FFh+rQLjz_v6exz3P zg9y6p->n_=iy*m$2HQceXvc3fR44oiz7Dn7kXn0#@HQ0sx3A0oV}I^e*?=DlpVC}> zDgiJ4Te=|T`D3*~ULn!m|EP^ZtN*XTKTn?$4J<>aa&2CJ!WQ)n+M>dFVuT$SHwZVu z{d#a;IBS0YcbrRbzKytT&85>0xE~=drWKrRLwEhV;S4w*#iuja4!9r0&T9o{qi_5j z+X3fot-*Wa0r$JW{Z??+-u-uM2b{O?J*u59&WQUsIAH&3V9S4t!|Z+kSU8;N;&8#Q z2L5|G+5R!*$Ar^ui1rza`1?`h)YoPEcfhgR^o?WcdocA$f3!tde0O*|ZW zXa$cee*EEfKFgeOzU*lo89gfBHpH=-=KPh|b}O;vR{Hq)TeNP_gT5U!7Hf9IYddst z>kqV+^uO=soHTUbJ!uo~`sehvxpVI}Ed%IvpGl9-&7W#cEj>9fp$Xp}`*>JKW&J_- z&N*$T^K3i*%&40$pE6%lJ{v!FMyau`y%-<4@3q|OFWC0hV%u!p@<3#FJ$<>yWM9iV z(DkJ|FRr$EhdxDYyn7Cm%9T`N2ROc8H@D3-_-DI`T}Nk}a25C82-{m(_qjni575({ zdbRcanNk5W~epv2HKhlLPdt*F%ui3M_%8c)* z`QVGYwN7*{<77SMy0XXCv7Q^~YaE$fT_1GTOLw(CU|LM@jaoCh?#eR|?O`(V%det6=&&Wt`={i74_ z@fV);J@e_b4<9`1`-zG7=nH3kKh<#F3;oAW=;xg8oRjQxWFA;wKf;LqbKq99Z%liE_hZ|gS}~HnIR4nx^hI;{&W$>si?h<;MUB|z zJzsUtJXfwq#YlCNhj9-)Y7ab0ICmanvku!^^Whb3(1gy9-#s^*2T0Z;0u3p zazM>qzQ3@g8{gi2f1cL@o^+miC)~pNVCTSzLh7B1-F_u?=NGNW`Akv&fk4agw^LeJ zqs%7vMT}U{UY{1*9Ptv{vPjdS-g#8^K zd<%MvlG~@;YMDIR)O_$QyX9ngL5upMzI=kQT$!`7sF?9Qkn=#%{YU*R<;Mdp?ap{O z%buLhI#1)&iqYge3gE{XS5b0l=w5RbJ;%M*T$S?N$9}LY9#sJD{+*C%hjn!F(ksOOrd6IlzJ zqS_SF1V57>*N2wc_*8 z#P#s_8}P}cID6Xm6DPKxy==tdk*6tmD1=QcnD6lg54E!%CYTR!U!r@#oHCZ~fq6dn z4e0rTc`^3|x(Bx9+;?k^fNz}%I&@aT^EyAYf@~<^`5vEjs2e-K1lSL7U!i;8pF^3D z?wNyp?%VP0lrRs)+?VK{xhUt};y%KB)RA|u0h=cNb|Ex3@4uxv#lH`fTXXhp_r>}T z6t)2O&Xw;Mw&Zbr;DdS4&%Bl=b1p0TY0eyI_41;DoGXe>9!qKYU?A0@s}OWm23?gW z=&GK$+>fFw_|zG6g$&)jKg%?Uu1@rtmUy4lvTBshhZ{$ZSZKc-Tp%}*a;M%)euHl5 zOUV2R*4Iz;+LSM7x&LS(bT+T$r#UaePqxy(AJfmD4AP(W>uqF=f1+Nky?3EMRl?Wt zxm7E-!t=Zn&UpB8kMsXD-hP|>&dOu6_xW#kF8=NFN2O~#^_AZqm98;@0)ld{k$yQ+PdZe=6|@gwU`*SM)Znq_(o2eYrp!S zw_~dwq{J3irp8wJ*guiIZLXe8P4!<_MgGp4Cc6J$@!#@;4CeUesr)|~ z-u?TBju+kbx)rn^PJuLd21`{6tO*1{+ChQk4E4@4^n5<%G4?BZHu9cZq2`K#*6IJM7Fs6qKfxroNG7#vnIY{ zWnfD2#E~=0d8c@#GXBr6GV$W!Q)e1-`V<_we>Z0lcd-7^(KXgoSB$l)IkR$?@`dC` z-$U0M!1vn7w>~q0a_C{{W~Ow~H+5YZ@j0(-k{%Ma#&u}UZ1n9ebhI$%o@mY# z!z`X$fPW*vx#zj`r3xEDddVtdWrnFYf4G?$YO`YJMyCKz;-ipPJHS)L_^3Vg&C_j? z+Z5iWG3E^F^>k$EC(2)GR@!mpE=*@W(#g?q+t~E#^iaUri_rs5mVMAgoLQInJn(R1 zeA1kK**1MXf<4lM{SaQ+wElo$&$yM=_b_@_$v275-cb-2Z4(C<`|e0`m-v9Y_Bfzl z{cxD6`Nr4XGF#BaTiN^fDEvxuFpsj8&lX6gbXGiHfIi0_;AbuNP*_e(SoC=-9{q7a zyy9ni$2|Qw>D|w)c%&yhr=Iws=r-O%6VYu}yu8OZ=L6_n@%p9o4Ia`Q3Hz$`eKR@K zBJ@T2MI=l<2%c@^SqaaUFjtXnzL<}`Xe`nplwVCTNZN}R1V$T>(dEpY&3vG*ZD!xb z+A{Qqun9PPE_m{q-My^Y+;WcHv;?`~Bfg^w*zC{oJ8?ePSn(XzYfhg958QILRz zK^N9u`ho5KT@ND5++6PN`lfQRzjNa-M~{&1`QFXsOe`C&efH#aAJG&i@85A`>xpF} zo7(7W7i*!^*ZE`M;R8OspM0{@GVFmxW)$Gh!;bDKFxHwLY#cwZSDvqNcC>RgHqAxE z&y@OjhTRk92M#*c+yjp9CvT7P9tNo6KH`If z$0kVrx3q#e!Kr38?S55fg_EPcWdL2j%U>|ZV*S>TU!Z4HVN>Zy(^N7H8>5t%`g6>s z1K+e;BDa`1E6196i8I$GcJjW0mhy9=bE)gPxra@Q=Aj%JQO+9J1G9L3(zKLMj?NW+ zPo3ER!uLn~_xM@?zDB{79sx3ync1O728tu=3|chqlwL4TUo2VmHGTT zg|lUMX}yB1vkEOPsE=y^p=(1)YHh0@z< zLo3bt>!6t@;R!$FdJBDgg|@=9(GHwBH=)BQhm`V1AtQ6guxA80+iJ{_#_YOUAM!Q_ zS=%aH#@{U)Py86%>uiKywmC8^r2`wRBL&_XLB^CK%MA5}+pJwV#@DxRWy%!E@SJQj zlV7}w{kdwl5;|0Q-^%e#s~B$&XDAeYT0W2Q9VN)Ad}MwreRgOBJQzwN6Alb)eghiW zi+=qw@_d|l*+NB3)6gKr1npg@ntFs!!ClAiB2Kl7Q z{(54P?+^K;_(bVz(&J$7d*Z9^9j@cV;|G<{1@EGy74$2MX z9dGbVbESF8VLoo=zi>h4U;Rk1#ZR^uKN)+hy6bp0cKd_jWGtHkW*GLC^l^3(=j)t>%cRrquE&MXstJ{=#= zCU}VCsiAyTu&uTdeCq)g-vCal0w#VrSHbnEKuUELxf}&g!SWoQqt`y;y(>O%-YLD$ zsWPX|ai9@S&))*UXOJh)pa0 zuphl29?@|Ny8jQ+`%8SB)p~N?x*h2HrQ>Z!&na7(x_)n={p@_;;kW(pkw9NMI_+Zi zX;eMxTR%^D!n1?efF2)_?uTBj*uo%up zF5v+1;;a_SmTA*EWroD|;s`FTE=jp(yl$CLi#TfW5czes-yqd(0X^uL_epR^}H`tyEd zR*>-r$PM8qM`+H;v?GG&w&i90ZP=V)XfB*$wk)Cka{7);S`@(NJTJgAY>IN$Vj}oJ zeChZwIIm?1wnRDWmTkZ$Fr0eHeY|8e&sfU|^2~=kB?rKkCD}Y<-7`vmX*>QhbFyS2 z&!GD*aM!2YtF!?x-{PA}ycuH!FSqz?Y&h<7;qmH!uRjGJN5GXK_~fNu%hv=A$F`A& z0-9mpNVIeD=#A^A@r~Xy2KGA@kPp>VHAIEp;=x5rG zd5iX5+VM8@a-FeTitK6#XIE>_O*1j;HQ2qbUNK(was0u=x3EM3@XVG}$*ZD^dzEj7k74G>*I$Ogz0||V34jQo;YnQ_xiFGf_Xj)c# z>(LQ2@Pndn%{oQSCv+G1^w&58#?f{B$on6`0}cX5q8c69+bI<^LR=)4{ZQBHj_AsZtBx|Pp-4xQ?e^J7{sz0ziO;(fLGnU7u0DxXp{ul}wUs9>kHM zXC@K*bgWGJ;yBLCz6w9z)y(^X!Sj;Trvdc69`rrg(FMT6)B6OY^H?WI^zCR1>rEx} zRecMcOq+d~sV8UNuqGcip{^y24c^+(!#tES2hvAE$cRSfVjJ@?=@sl@Y`^<#tGc%f zn^U>Bs1N>esm^iQ4$X?Ds`m-W4^`)Xu zjEeFMTMmE0ad3SFvQ}_CK|Z1&_*v|q()Tnv>UV+X3i74vKl>LK39i6*c7*cq@y(IR z>^Dm@>_4a9x}smU!T$|;+>w!6jPjG~s=VpCGX6IBz9AQVl}JsLZYcYbiw!?#=bec%w2}`$j5$HE3^A-iX*Z=x_X}@zuwX zPkYFD{~EG)H*|lI<_31U>}|8d;~cu8JC>*Kz=g!40d|xUOO(>TAoWI=%P2gyoSe4B^jEnfnO2}<0d-8vFxM}^*UBE|j*TX>W8S%*-ILLrpTXYS(dpH)eO8Z6sZMXV zc2yBOu+im}=*a1P8_D@n6$n(zKbebM@r?>pFStKriu~N@h%;NWITw?Dq8G;H!<~j4 zxEkFEoo@3(%*A;4FMPLJ-|8Euzu->={hd$$HX@h8^gEsUr}2Lud7AL8b`-2kpRy6z zR5>=_$n0YD-Z1tD-_I;gw5dI8YE$+&xs10y3jE#k;j)?!;R~tZKYoF@_PrsaA5%YM zgZRqO(Xc@}^;|dyUJF;}W!p_*bkDsLQgr5Z{GDv*a7avvWW4ky$$j}|wYFf9;~%+y zv>tm{GDQBI&#@jf2j7wNOtc#{u}kMD7+WG3Ezct!nG3U!hm(B*cBl)qB%A&sYh0Jp? z&t8MpwcaVdCmTe2Vr3(#tZO6r9UDo0iAG@ZCHz@01M7MCAr+fjhW%27{c?n7;zPiz zTCpXLZGxR4dj{R4TJI00p9#ag{C9XEHk3OzKN;kIS>UaBp!nZJ@JYN+`En(jtd(~B z67Y+&sXNAv9#?JB3$|_nE@tJ6oExOGJhJ*YD`C<5j7#Snz>9832X_|(r%LnX~;&6UMBr|`Q)$O&(JoHN9G{0I&oQa#Dv<4?oCm`$$L z)A>+cUg!FkyM#-^8xx5Ah%txDkgc>KJzVq>E;DiD-4g-)`|5*m((9*uBdlFI`Dq?O z4_Dh4(>LwMj^tWS-cfzyOD{aGckhxT=jXiBnEz6rlJgWRho&3L$x_an3gX&W~6TpM#G|y3g>ZYftjH=5f56gTDKh`Y-)Qd&|5t7f&q9XgUe*m9a-$=La4uOLOvP$Csv~ z4+Y=@R&xY7MGQ6iF*)HG2kS46AG~sSw6mW6pZq;-V$bP)$w!>^?8YU`gU?)zezE}H z_UZjXI#*ls@;hWlgfo_O2Bg(f)rrro?_qq9I{Uih#UFH9tDipFef|GEy6l7S?uK*r z9bNW7`+D7%pJR@^Puu4E3yvRzcFTREkC-TXhLq2VHJN3cnfp!jtP>k#7wD|YDV%M< zz994}bj@thHRq|_$2yAiY==LYiG|0{1E=|ahTg6C1`8jsVjJ4gyGwGB;kr&|-@mT2 zc&{ru7I76@6vt4(H8R`P$AydJ{ND8@W8ITVj1D@Oa4L(w2nOh1n@b%%khMGdvUnK! zJM_+)jzdF@toJ-Mj57sZA9x}gf3R#R_S<9oxcc!U(1ZNMCE#)ad)C#S&exKR_X8uz zZl`_9seR$DcmB3w!b$~a*3Esux(8S{0_V`mZRP3!nHEI4EANIa&&C zx;U8C{4bnm`6^|+c6*)kyJYiCZ2qZeHF4JWHz}k0WzIDP8#cA+HqKoVekumihg|Y> zbm@}rIoZA@t?#BdI*{p*{w2LjdQJ~Ip7aCdN<4%vmkJMad~+**$a;W$m%hHAOW#EY zDTM#y+uyPmye~%=yj{Ek-V{K`pN9@ufsS8?j&G(Z4pH%qfoAD@Rnb+o_NQHax`4P) z>C;uL6YKs~)+}!0T$&uq=j2=W^y;pFVC3tw(KpNJi}bMK=;|xIK0OS7h>o|n&W1y7E6^3Zv$#gV zyEg2-`bxjz@7Nz=Y92?H=srRl-dgn^fnP1}N)zkUC5d(FNYXlWgmvl&>wYDPb?Qjc zI(39KsVFi_>)}z>t5tsmI9rc@ERtBa#*U8HA;Xewf>SHDZ$gM>mvElXbKrvD+Z8bJ zE!@ur$G*ot#EJx-;d}wll$W1#uCXx$qn(udw?w}U{80L76g}>Cc+z<2poRZBM%}ASxzmTv_Q~3F>-KX=Lgv``QndytI?>r+@)^pf_^;4JkIq-8 z{xCLP5c_T$-*av?WyUq9P~WB4<`K>s5N+rep>K}=5Zgc5e;8s8D@M{u8#G@T~q6x-Rgg{>{R%Zuq!xtU*_BtVCCE44LKNShwK{jy32C zj+Jl~jz#dHZ(%-u&Rlr&)j>RnH(%?RFU`$Yg9%-fGqk0PYAxa*dUhT-VE)KScj6U}9`Pey-HMgV3(UIjo^*V10$LI_ls@(zO+P zr*d`Z+BkU@@%+dpp35#nR|b`=>QeNrjeIlTVE+Oe;!@xf{=N{pc3H5>(Y5RF{cl1) zkiOl`{A)j}_>;$*#Iudg15fy7Z0!4s#eaci0sb(_2}@UC*$xa#fMtcQ&})INz|ztc zShfSp2(XNDO}0y9GkxLZg80{gEsFf5B?)@y8>EM<=F_nj+c#ZyoLEzA94}Xma$sV!@?ZYo&mrHw2YyD76-M#0r7k}=vh&X(_bOOR(0B@TWjvOy;y)Un_>ZpQKe~$l zaMgJ5T_nb{&C#JeS>cXx+ome^W(|Foc8v3}!KX_Y^Wb>#cML6eiq%BXm=R9g#yG$! z&E;T@Y+~3nW{tN6`=gXGDo#lJi8ZHc>8hpZq|#MyN<$1jzo*!`IwW!BAL&0lHH2j0Hrp9Is zrHXI#U+qhVS78@Rj=#mYzVP?1zh>8{wwTHq-jdd*xcYNI?%Z%_V2_b$s*xp)c$FX7)V#D_l*cKhd7{gG(M- z$ln58@lja1vL@D!JhWH`tI!qxLOkmhi}fyxt6{CH4PLGBZb47Gn|4~!lc*m(ZG3YR z{~el#&XtEw@DcpZPtcKPZ|M9q*`1m%l~G;yQ$}^QgU25QLv#<;fWr9o3(<+BFV7$3 z-QNdSzXp!V_aL33&*87GAN>mS{@vg}CjA=+z6&ot3%}dXGmRy=KR444^+$baL;ujY zmL@;LQtBJC=#m&SHEW79L~ifN3epXa^j zATRJ+*oplVV$+RI0F7wP znsxMN+}N|zYv|7W2J2Znl-#$Pw}E>-$acY{6~6Eq@u<=R9DM*elTTha7EKoAN4cV7 zig(D@F*wIV> z`s}qW92v!#PDAvQXU?XdG$3!(Pw8(%$19%g@#$Zn#|YOr8{1j$h=O~f>2Ar%gl*^*Y3Ef2UryKq%`O~0lLN{^bX+k$?3?#{tLER*)`A+mJ(auM~8~MCQv3srP7T$a{ zkfU-P-&_HH3K!-GPl1JW0m-0bcn`g|%qxy<>qE{;muQ#G9Gx;~?_~EE@4lEkc9Q$+ z;QeO#mv>)6-CJ_oY8!mY)#&E8oSeufCVa)7an6QM)*A#ZZN1~asJX%)rC!n_a(#%Y#nW~J$UKa z7XCw*Ys0~pYa31OA@OOg)hL!p@z}&{;~VEp%Ef^`bZed8_Dq-I&<>Dj_j3-*4KOxURU!$wBw0k&<< zi{I(2eKx5d>a%!9&Xc2#42Qh`08UDtgsT)p}{ zI12Uw@M0rT%jjR*ONX5K zbJ{ui<#md=3WnUW!8hHq!O~yQr*mV$&+JPoTeR+XNwE0}usbVk=w~u)INxSMaAbws zm)9Fz7)D=paS2!^mpzS39vnZv?kgYlz2sya(+$m!gsI><=1E3 z?lGOUptY3W_&$TLaRNRAUvob?g7j;b#-_*CzCk?RFmwu@Nk2pnJAzNqv6bWa1ETO@ zch4qy=Xw9Go~54o>}@?8dFHc2@+CO>lXL?2+kHIKe5q~qNB!WerI={_Zu(K-vzw%^ z{0UrogFfrMM=xP}M(kF~ZPc$aDjPO4Sa(ux;+HoSQO1IY|5#&6j9uf_vn_g-7`ylE89hsk-Fx;l&%E*Z z!Ed!w27ZUTrpIQ}&mGLAH-F-{)8N}S`pB8KyoDVt+sLq%WzVT0=T*s?f~KJ2(~w`4 z&_OBl78%J|ledK~tD9xMCV8{@9Q(3glk+y&2AAxLP!`$LnM2t~s@+t^{|0!E>R@c0 zL4L_~$u21I6`)E+JM(V~4t`T39!;BSr{=_m+fiGCV*^i<$0Yn*#{PL8;HHr(^QUPomh)XKbenNEPPq1%8uq`3RV6yIcUxE%jLHG2#By3{Y+&^B4PnusV zFT95Uh3ezoFv``dt{%>0)4l3yG_ip0Ro6E7x9ak-Cxzc&UAaT*;@vTF zOgVLxTCsO^@AS)x9nrnhFDv$E?jNtDE`Eb`p$DGXFZN=3b@f=WZm+IJEA|Jku5DKA zx2ntMqhG$$b%lr2#k*ZzU8TO*FTJ{QeX$O&uCOomn(C^gE`Edkx?xCNy!)9~SC23D z6R)mDU+hJ%u5G^97S-kR(=Y$&x=M%C#k;4yx=Q`AOn;h<$&su0ZVZ!MXymwS#p9V&7F=mGq0>;26I?q%PjA_Uh{4+)}TuM)u};b#3#< zluzvOO8Uibu&%X3>k7oaK3G>E_SM0<0>k7nfcj`)|uGG_Ybq=j7HC8-WS8D8k z4c3(!`|@C2sj)ArE?*k(N;_Rw&yc!!_gSy5(zMt$UR}9qv8%kg!fCNWr>+$0N;zFu z{)Wy5$UR^yYu{^J?#*|plt7}_I z>=UZX2fX+V*0pU&UA)Wk>MBi*jrQuwO^uE4>I$dEhH)RQOpBH9lfKf1e<*sCm}EVF zU%VhKX6b$#W%v!2IZhlHdm^GAh%EcWZD%qJ;Rni1QkGL@9!H(2IEJN}Z& z;5RL4%!qAOnQfHer!w`(p!&=0_;V`r8TBV4_6+xuJ+jjzTYX<7e*o($7B^xp5N0rCH7^#`(~lmOg6i?w%wWgx>Wnrz;8?b`Cwk; zoHpv(jqfM&P5&A3UebYn!E@<*9bC0fRpaswqK^^kJ+-2J(^L4^ zboKNwZ!FTQZOS~!d+(jb|4hK8JCEKpre%z!JgLu&XK?M|Davo4f70a|oPH+b^R`V- z^4_B@7rsAp*QXr+sFVMnv1N7O|9(ck(9rTi_6=|@yRkadFYPDD;N5M&IDI~PBJ1eM z`wHGE)Ve_$w33cLGzFU`6&ojmI1$!Ev27G{t2mKR!<5*GWtmNi>rsq|Ax7WZgV2rt z^vZ7(#>?5qTFyS!uQHAdGqs}|->B2><5v@t2d#Env6-A{^A>&gffvMA#(Ic7u~Zi} z#K3^n)bo(9shj*WQR={9u(^vdnUnQvB7PIE)qmC{^xx+Hzvw@6s{cX$FV}y?M05Ww z-9xWk@QrWkp4jL%?!Te?JYs>kzeo2ozzOah9K>JKGbAQI1P@A%#aDd3VwX=WyLK=x z{~wWa-W&}SrtR8i@PPqut$`c@cBfWMWX@7!ZvnT~;I_H%+&HP}V6SqhOm4!b@YoD~ z$I7NO9SF{dRdt;gi!YtpG@WNVA~Rx34Chg|lCKK>D?bW0{$pjqnX!%aGh)5up?C*B zFtLJ%e~tYmT=)2u>Q(uwt{Jg1bDnIo$1ud63EStzjv@aJ%}8mwjAsWgGVzJqr^g0X zJ$Pkr=fD%L@0W8Rd=`6Z*hfy8kUcYYIlkh-ca=%+qBCO`@$OQ}d2NfosO_?@VX4~Id4CD(4>uLgO4z0HLr!YmMtPS%$yUvF>uH-Adr9@& zBaIEdxc1NZ?vuy1@@#PFnicz|zMWif#*SF}9$*|fm($>FiVLXaDfoyZCJ9=UZnr7AMqte(#EPaD2h!38+XSpivURH!)L2_W zK#Rq;0*aS*%Orpxu`L&sgaY&XeBYTDB4T&fKa!bw-*b7+bDrCI&U2oFEi}zUZpBaY z!N5b*WbD0u-f~6?jGk1!B4rD>{Pp<&jQD1BaKU5U)6Yg`i^e_?@MVZ z&flZMQnq%{-`>_T_Pf{*K}Po?(3k$Mb|_(I$v(N$ zkvZ|cU3=Y`cglT{7>AF>GsN55MSJlaX70T{vM121w0q6qItd$O2j!*D-Tv)C{m-R; zXWWd>-K%x`pF~@(-gG~KdEK>J<2IFY!MJvm>?7H+A9}q-I%5#GbCZgUvsbS>iAEN= z@kr8@@KV+tID|t7_5=I>r`vSf zyOH*UZ?)-XzD?SjE#{TnRW>}ifq4Q4o#I>ae{}dmmvB}s*P$t5bc!B^zXzZx+0Xja zzQQm5hdSR&)X~{G7e9)xaPSNbeso{SbaW9PslBBF=AxH8`^b;&UnTZbKGA=L7D68E z9M)DQscpN~4Hot{`$z}be=Ikl%OXF*o(Iq1w12WQNAwZ!hs*D(G9{JEt=ir*_J`^n znvds>clL*t#`lMM3W;H1Kjx8HrdjJLXus%p*AWxTn(7PqL8FE2qjnkRUOmoAE%@O6 z);!Vuu93=H6}QW;jL+R=vXu?+xiGf{_TUWaBztTL3p{3G+9pP(7{ z4n$8;2J=tc#FQhEuh*^Q;@CRVeacFnQaBtNiYqnBtbIy0J|Vjim6ogc9|%f}o$ zd&;*HH{H&=)_=DTL*sLourC?*ez_z{EYJk%PG@e|y9CE0kfk{tjppay6zm_OcMLHO zt==&u{|ycGh=-wT6TOVMSiAE^;;BDYH{9XW-A7$``BePnVqjA2M2MU++KidF4j8k6tL}2nuYqf1Q;{nhwPtf* zwx0~0#OCnoWHpb!qwIdH) z{0B{Ip6>A*F$n*2|CjjR@`sP(U$I5!gWIdowTdAg9B+ICyu{A|xiQ6_qNC`Q$HA4G zv*R{sAqHQ2q5tY=ek26_*Fo>A*&`Lcqn*fJ5y)9!1}rEha~OsD0_@M)*PfsE-2mK}<3} zuz?}q>ZoKmXK=LtTS%MYudn)FZ;|by^VliWBNxQ>(0SqQ`>@mg^#WoEi60H)Yx8qP zrym{UwxP3FG0&KiBZ+6ymwx!PhxWe)zCQ`36P)kUm_y{j5iFZ&r3?V7qx5&{b|6CHH@@mgBr5ag(ye%?NU=#7uh$ z`cId)_Qw&E+I&72PSq{_^dh&Z{T~fw+M4x-X(>&@v%2`JTuRIGJFJDn` zykV=&J;P_>?Z43jF>ms=Q!scnM_KJBmXd>ZmEr=?x3m}i9=42N8Hdczw%d2J_UQGN zwO6zDc!agbQq~=TG^^=bOQx*~rg@sYeD1|xq-XkFeSGE$eSe8h{SL8i+Q(XB^YO;5 z?l?Te1=VxsblYqHzN7L%(;mt)1796$2714TUlSPI+%~J3`y;D_U-Ghg<9S*ClW{on zu^BsxvhANUInw(l5_WA|h7UR~h^&xr?b6@IbN@xLZ5`SPE9VKZ&a^RsXLsLVM~-?5 zB152&h4`UgdKuo}EJgT^yNZ49bIt@iqs}v`uM3#&bo||MU$=B-GiMN7TS9iItJ7V3 z7<@XhS;UY=QG2+p|fFddg-^z&z+2nb8M8h^IAVXHHF0jR!IQm}I6KBPvgq&7u=tw0_U8V0XDUC- zWo3*_y6=j_JK?a3x{|N<8D-v>5wGv+XW>_TAl%0B$~j$hw$AF_^uw?2FvO9MM7M<4 zN7{KCIXoz*ILrORkQRD#u(1l)?2i7 zV)4_?8c{gV_(w5@XtFst!kmY{I=Q`<{9P|VZyd^6x5kq}91!vj-02)gfbXv`M$y2B zqgq>5-^>{s)(2Yuw|eZuJ3SA#Jcl1NpK`LTj&PQ50DbuZaO?aTayhadx^DGm_k})h z<~~P1wd8~{)pm)Wct$e~8HH___lOm-z4)2=j!y0C@sQu~@z%Usj5YM{TMhYftvgn; zF0$xHc}^Y&CJUcLKEC=MVibFwSUv9A2G4uGXF6{l*FE+44xR1HW&VL{A3pjw$fNlO z)0qTZ1p0GNm(}Ot4y`cxU+~Qggn`8ncbbO`$pgk8y|ZXXF8zH-e;Usbc>VoZ?2T}y zFG_r+_DiJG@lg~#%J=7KQ|DG6xRRWk!1KbnNjL1$J@K3!{K`+er~a7x=6-$UsvCZ_ zc-0Mm{$=#*zvA!Y%#&X~G4sUN|NOmUH~fmfKa=bH&$sx${^!Sk{q=5g`FcvRyNRXi zx+>o0SJr;Ic7itTaHh}C90<0r{Or@U)BAbaVT`uh&Xb?6orbBjLr$)!JFaz~uAPFb zXoq}X;RMdV^4zCur~3-pA(xo{iUfXM_;l@5mCz1nuDYNr2hUxft{v!-^%gOdQxffL z{&elEnM^z5h=rXTZ|DDQQ9Hl-Kht6NB-$Czy)+XO?Y#0S+nGo^7lWS(iFRK7bnWy` zpdIe5iC&gy=e1AQPWPp>!+kZzZRgET*G^FFaL-Nn(gZwz`gHB|evWo*bExvOta;(n zQOK9E(e9}aBRhVK?06X2A-VAr{vqw^_}SP z9|k?x?BrBI?;Iu8(!N;d14pC3q@Qv-{zT5t9{L)Cyq7$^0@>N7T!ZMR9?3*wHAzpl zdqy=K*lKs~MQ4>-Bl_OO&+{4bUrYYA>C6psVF>xuME7mT!WX!6aXm8d|IO>3s%K-y z`$%dtFge*hb5y<#rGaT>Wlqu;}CKpD-S&gu7Z-c9qT zx$8yOX&yhv9ICy)d&d;1oZ3@cA9(VbSOXt=;J2~4$ARVjS>%mN=)P%NVsrP<_VFF8 zclKR}ekAwqAMDO~=a`ANbf-=(B@eyECLLZv4l-Ad_i!hb><5GWU<~mJ_#b4$$lvDL z%70&YH8$Yy9N$SBIpQ}Y*@v-xz@bTh6kAjNK+S>me(IEGSX+hrW9zPDexByL#^G!0 zo?3E$?1sQ9eVnm&dIv(Ld@yjb5Flh9tHPZm^md!UPQd~SztIy41m5D zxv-A!p6c56`g}Gq-Y%z&QBL2XzjJnjI_IfAW6^hPjQCjLkNu3@Z6jpT(vOmpOfa_% z#Ov+viIE$Sym7=*IQY4^d#ZD`4gcZv-?0}1J`KL@GQQ{5nN^P;)&4{t`=q&?_2oVQ z?n!8J`Wl98JLB)R zxxcUKp89L|du{xk&Z;~0?Cz=0y5EiZPSR<1$-bpl zsT`ntfCdD!Rq%Ulmi)VE9s5&> zYau`W{#4F?LeKj$@aJ3PYE~U`HQLzr(F)U9mq|`+>Z?!gRc zCzWaP0;Bfm@cAfSpj`0pPq#`0bNZC{S<7IC-R^5N&y}$sTlHPjuAK7t0|%5(aLJ!N z?RVnGs5&>PJyc`{?)j#Lub5a+;u+*~CC;WZbTfEnuO{^GR!L|LcX#ky@z?t7y^egW zRlLg@)l_{hdBkl`r1}ao5F~#`Xok`G+sSn&Q`^_PvuJscSfC2lSg+_zIyr&h*{m*) z>AVa4`I$4V|69O~)-t{qtV82DpJON1h+3nOqY7C(H1f&^Ixk>`hp4-bd&gF={tw{` zR9i>pT-#1t?DfndHs=fNWz<)$Sm~+3KDr)qb(~my4YK??Wcu}-zxBrVXHysl=MpPN zj#4aQ6KBO(zk4E~Z>8c_N}U|EJ==PG;qrBpP4nO}eTV!gyID)=Jg{<#a!-R353)Ml zH1EV$Ie3hfgT|P~{sv=wQtRNsV_b2TF)A08d?oVJ`e_5dm~+-u>u2fU>+yAprohWO z@?n-ho3C02Q>TbNL>u=5vz`e*QE22I+7s;>;!LihJ>ky98_&w0a`^UIXTQmPF8w4v z6Mu=X-bd!f^YkXc!^+c}>f{u<2|iWMpZD=Y$mgKkFWN5J5C|KR4FQSKo(pRVXwIgUiD zf#WlFSjMnd;nMc5Edxu*_4&=Rl07`@V!YMR(Byd|Q6Z-NSRt_tQ%vU`V%mtW>MZ52 zcaA9uz%%&#+8fBx6T)_^XCJjU!_!^`4>SZ#Ng4duhHR>I{2#g-YDC9V!|-R_VVC&P zzo9GOVczeiPrXw)`9gEBZNk@?68UM`*hkiTy?c{76M${S3Rk z8;?-FHBnc(LU_D~xpw8ZoAWH;yDXB=#mRF9ec8~|DCY90iRX)Q?WMsvw&Z(JjN$vp z`7c2$)%>+`7v*O1!+i(28skK zDV{saN*eTu&#={a7Wk3D(mT@gZW|M*?;T>M?9A?;l1m%%4dsLXG2h?}4|a|FSI0hY zsO~>@n6`&o@Vz%B(+@V?x;FZNzoxX&ht5$y{*AH{`MG?+4sMI~8B_iawyk(H%Gh2q zsYUOA-$!qD9CguQX8qz+on_4LtLH50&5TRFn496FeBjY~Cf9kN z+ZW~DB+h8n7zd_REjBP|>;@l>1xy>sqiq3`2Y5>9CkPxpzyaQhdN^Zk(Z8N=UQ@Ox zgpVnH2Hc6YD-x_3z|)40iF!F_;^l0(IJ3USx)-{A3>buyUG(MRLO4i)j-N68Q_jT0 zA@-Qs*t2!y_@W~ zlNaZXdVJ${nsVA&3shF`@!)cwLAU44$N2oqMkNEIe%BXLNeaE38MzImYuE}*V_*P2(SFK{-v586J&I`LSPb@YkF&pE!|xG!9L zuko{*_^k11y8iH_ju7x#!<^i{HOg~aK^yb3g2#HuweT(OIqJnOY#hoRdOptI!V}Z6 zkt8SO;#z)(T_Id+y(&78-W81q-zOGt zS$iXSd+@>4$j{p_l=U&V&>GVVJz%TM2BsRxHpL+Yu%QB7OIv>AiTfvV!NpDN8_6eJ zn){``Yw62WlcOHGyptU1%_qOKBZQycVC(paU8?hp=xbvx<9?8wjtunaUFqaK0se#R z<0wv8@c)JH>w*7^L;IhtVvLG&*}zx~>+mk}R4XrYJpY{ROyXAY(+dW-T{DgQ_sHK& z-;=dBq_wW_srwM`OW;%SjQ4?0<(#mHKkg!@V8CA-`2o079pTK?6T*|`T7P!JHd1~g z?GcciX{FX9s-rfATeYeBYFBLpLRYX)xMgjKao+pe|0OJTNn~LH7Ue3j90F|e=ZdCw)}6yrO- z^`;)y8h-5Pzwmr8{|w$gQ#^r_$ANh{wh?&=ub+7iRdc$Sd6ey}Ij!Y;Gjqy5!yzs_ zE z)7}Sp1|!YveZ)9F6NRT%oZPi#Av#HCLkpqTN2Hs_lIsh;BTs+Lwb;A;%vbe|cIRzl zr?>0*ydrq6HMM;Yd)}2}?E#h3{Sdv=h%@yi18;T*`>;<|H_sdpPoC;?Yiyp{>@Fty}!Sr#|NWfcoorcYceIy(R2KVgP8^2ckr|GC72A=2J4hClUj8A^{nS%^w{E>hu!oobjdw57&O+JFrF&LBRM-RF&^okM#j^Kj8)vMt(|20s@O$Cc)pKr4qWE|&{YWRghMumx*IZaf{N>=WZu%wXzL;-a0U2X&)pf)jpFHI;FX>JJY$#bdz^jp*H{<4m11|U z;GS>Y!_|y$#Aik?cn{qny9>YQ@Eq!CFTK?}dV!7owUM<_z-y(qdaVUtqnv-(pYp86 zG5KYG2sBLrhj+8b*~9upcam@6z4Fw_C#yE}>~`_@40JtvkzWMHNziwl$2tT}%nh@S zX6?ls#P?S9+|P4Q;f|IHGwDDjwn*<3)=|{I#bmRmo!_B(dW>9>tevK)-^28`E5&s3 z=ipa7=*CJyOWUO{_q%2%DzKAHDgyWhp`(AK@)D#a}&BMa_(=i zA6Tz4r;2@x;{;p{#z_FZBtOB~d<5_Kg2ZW>$X;L*?9*wZQR8GhIwJ%g+V=v}W@Nqk zcKYqQGIH>xc)wwELNBzYF+PRfarra0$+Hbwcu2Iczglri@q3JPXVDR7-K#iu-CJaJ zaNZ6bpcIsM5WLyQz_&eO3^tlLwFjoSlgBslNdJ z(q#JEljv_Z{i!|np^84KKlODx=L*!XuMf@sz~Yt@}645kBnQ<8HIE zB^;Z!<0$^<`a-kqDEBXw?m@ni^J~g4Z$y9>dE z1&wQ;<`rbQa-q+FN8&jE<9^C`KJugv*~@X?q_;@tULF7CQWKH?QaoM4=VoYp5Askp z0elDVdD{16e3bWM_zdU4e|z{7@8P#77Y;3WLcI5R-~$(vznEes3EnIC9Zqq4n-4iT zx6br!;=6hgy&R|Q>By+uq7QDF0d0?l#%DS_B)O-Yb&_k0G4eEP9hZ-9$IgBSJ3CH; zBcMT@Nf_{_{0x~{$=#u6%1rgAejFN4$cr@WMU5wg@vvX=F+SSQ z9O(T?Xlg@3_89KWwfkdpb^iJv!MKn4HN*|8jSr5;CXpMh^Lgw)^I__g58#P5=uf%r zUA`F4dN@j73+YRHuWj%}J8+3Fs$#(%qN7{PDu*u?>rPwvV)3f4E^pmu9SRk8wd7yA z3ckqi+n?Zr3le+r<|@P$7az9_Ui1()p1f5aDE@CE0T9loe! z&HhPzQO_FY`N2H#NZF#EkMM;26E07TE;$+Go#=O`lUiNK$rV|;M#k)@1@Ogphbl{0z z#U87tJ%)_s8TPvF0zU`;Yest%{XT-WCvg|^W9+f)#THz%JlOuIV+(SR47~jgI$G^` zIcsM8QzMU>Z?!CAe4O>~b!Z*T_nnfAitNzSTqLH_W8&xjT!k9%`%gs7a)gH&BW(}+}Ac}?4uH6e`cpU zcJ9sTijVzD>fbfy^wjM^g!G3M!v*%~xv!QQaIU#zj04jS_=Y)Or|f%_HQ zF}r11Pe$DGr^hULn%&{-12E=XV7ZwwbNBeR4anpG{uSkJFlKaMYTA1WwFWhP8}Z*CnWKBRb?5d!wTm`FIp7I=g`pAQD>U3pvSmxlw*9A% z+O`&Vx1Ct5ySsI7H+Ob>lN>!L+z+Oy=Lgf2|MLz_?cEexdCR${@hX~nmi=Luu7uwL zaJw76adF!PJqcfCK|yhF5N-|hRN?hFxDA2ZF6hZXPi|S!lUx2YZsYVMydyVCHUiI4 ze0Fhq8Xd>2@@1HjP0B-hHriT8ozI~w-0`|}^ju=R&$AYH?qCYVX=yFx7jr(z<)eW? zd^CpfHZb04gJ{Veum3FL^?ky4Rrc)TjnmS@iSgbCETW|nhkwSz$IE)p883N(&Nkj! z>RiHj<>%C$%=(nCNuSzp{Ukcs=&UubbL5I>#rW~%Loei49uUn`ZNmSnz8#)5k+0AX z{)|yQ!oQwDyI&H#JQH2aP+y-QJ6)Oi1eLtcSGMKaMdSnPwvUFPPpgAHcq$*Z|y-j!j=Ae0&wVS!X(B zuWH?0fNnkdAvQgFRCk{OmuUQ;6U%X?oq(IECv!vqvFYB$U8)!6{rjXWtxLzsw$vi)+QL{w9HI;nu~q*5IxUBV0e6z_okV z>_5VHWdh%>4Kf(d{{f!WhVU$W3m-17Cl12(_rdl0?H|YWW(U{kz%y{21g;MxaNPmD zxwv-kL@o#4u5Q+6Y?^lv?o-=OY{Jhk+&g|nov*+?ar~lb$n+t^ooTO7`2>a{-xb?3 ztli>n%dVlXw~T&s#4225a_#&@@lv@6)guG_TkgrJMz6XWbzC zB@g+H|1@hwT0iG)?uR!mJiKFUv7+^Ip5`+bxOMl1@^p_%d%Y$4TiNU6 zh2yt753&zmXAgz-<)QW519s+|g%8Kp9isd@zN_2c@p<6sF&*>3os-uL-0cBRE>GV< z4vQU>9S)8CiO-CLY}43|%!$*=U>aG+-6if`-QTj!i3~e5R=#%esQmrHhY5Kj&w>kM zwrr0M-%q~ful^)Dq;9Oo-1C!c;I8wCw&Hg&us~Yb#TmXkdG@z#`$XLLA3vY1-{Pw? zpQ)~%SK{FjzMr)N{)5y@s}FUfj!`U8hQcUP@;7|eJqzAOd@8Vyz5)fH`X>{6SouN(PRa< zqlfm?-;4CE_5!pW$ZUQ4CGLKYA~#Y3FSX1>$Gt?k=}uqc5`85dSj#i@cQrV96rSn9 zCwBF)Wa7%nJ4PMBCVI1T%bpi}6^tQ_FF|!mxgTsJ_VZ)h>r6hErhSy}p?rZ!d2p8r z8~~@ze!qc!-8zcZ1sCPYt09=qgh%ZPrbqEXY@mz}x#Ir0XE&XFQub3ub2fAeydC4d zjS%D9+MEz#cQ8NcR}hdi(|wmG$RWu6uZs-dIP=ya&K%O2~O7+bhoT0 z#&oC0vvqyJ=9XQaoW4g`M^wm=5{syXyH^i6h4Lfe#+^rqZ4}uezm71`?3yv`SAyp@I8h8{DqrZ z-ZI{;hWU(^nt}X`Z0_C8?wb!?=x#>I?K_#jR_4zJr%~@ja^rWsZNSG!<|@Ydg89hv zqsZhBO4*A6Z|m`;mU>6_Z30J|4R^jH*H?QKBazebs3+$@8N9X+eX|}M>_e{VoTy+4 z09VhInD`!*=F7!v{A@uy4nlJx{O$z5qKO#!DH~W1$-b2guJDfOt0(^S z%(+n;;F&$xcstk^+f(WPb@0mf<$vH)>$=h4qRx}oR}C%(61bQIE;fLRD75gtcTz{Q zr|a$Jtgg3zfV_Uc;5E+tJ7xhn4=uqXHeR=3`7q}V%9cnEXhcCac2YAm0-p{^R+MPRICYT%rZ#DePx&MYIs# z+w?eh|8cf_?v)y+=)mjUx)@rJuOg&7=VzILv8>~;#-_zz>$TVt7E*{IZo= zcLKU{4}CSNAJ(Y{z?IMB^?Bj3@!mY>a0vMTyv&WaW0mn9@IxE7Wp}-uW~FSs*~`7w z{w*yP-l2W%^f4HwdU(*4Z)cmUI4%9wiJy@DIuv|LkMn)GVrEoF^!#x;j@Qw8#%+I- z#_r8=XioP~XzYuS*A=P8$*J^#_ll17(A;`x?w8QqT?NWB;f25Q7*`(oTJsM0;V;pc zWM5q@PIGSiTI0kX^MJb@=vQp; z?Lq$z?wdE;=J+=;)(IUJIwk5YK;E0?I*+c93(>MBaQxZfiqUnobfw0i0m7^1Fy@^eExrF z&xhS4oAu2020wS@nyb&^@++>tbXTv=nCwAD5es#Qh2h-Laqgg$odMpYzpQ}P=lD#? zNzl%oWE6ceav1px$ZMb&55-S}^@-fvj9+OFvNMbxA+DbIF!uDlNqz0e#VES02ioUu zysf3VwsWUd6gm2Lcye(e@$r&z?C;_GTu!V<-x}zaV41BI@Y`DS*dlDTl)|@L-iB_2 zChx%@zr`bx`!RUJLZ(O_DE2~gqq&lfi|e}h2vT-zdPbiautQT!r z&K!rg|DH0ekJi7>GoAgb^Ku>$+WIB!s+{(CDv>w%T@P5;U)@fd`F+}75)DR?!`Zpn zO+CnA(W7*^JKnR6Re03;=0oNSzSypFS+c`4A8U`q=Dtq9j^5RoNc8SR%7FJnvPGH; z{?vl4w@o%lGvm*r-p$@ITXWG3&ETX0{1tK^jqr9KIyuSYq61 zC+CtgrjdPN^o;x!@`r@63v-EokI#Q@U%^4-BF|lZ8^`yzp{eLJa=c)Zpg$j!{#3kb zhSOeNpY&Dq5b=wYm27>UPuXGb@wuCN7xL+@cOn0-W_jo6w*SzSpaUB=Ye=BBOdrcieS~Y>XV* z(LS*S-Efv+tFzuJ#J@s+VSE#k6YC2$I%_7aoq8Nw)!H_iKAM5=2O~MxM?9N>{-oog z-atptzmsyKJ8p!Qry%2o0LKR4_#wO{Sl&m5X-##6_0YtO9PWh0he%z{Gc+rI%?9mb zvj$aJbW@+?0P!5_75}MmNWO{p2ao0Gxw%d9xywf`Iv})EzkEyMHR9|ol z=08(UunKM$-gU=_qZE#SaRm74O3YU!bELU(;}6|6lj4kseQeU5MHS%j8fZBt8zn=((Gi=N$6#4W2{cz@10VfG*cP zOCIKsezzX=Slh$NA|f&cH>Jcpp2UfE*>LThM(LxdstrVs)Ow5W_tduU;&~u6MMU-Dg`Si<2@FIt4&*dOY85wmtlW4IsKV%OOQ6Bz(>HS^3HX_&ox#KynCZnBN@*=u9Lf8X9ES7 zSz@hHUDAUs4+5J>>u!+@P}_=a_K`zk_a;LezGClL&yK=BO#Ac6tryD(J2^Y7Ox8xn zxzpgd#h>R1%b3^DP33!uhd9R>qczmVKWY&J*9M){nG41S@n4dstIf%m`(x-JY7uwmttU4# zz9=uW2QFr|IA=}PWv6kjnzg69?hFCLcx*V~LC-bEvK?fHd%|TGRv5G3I&7TEkcrrg z&CfXj%WvO<{!XtPW;(4T<;*kASp;WoO1ZVPCq3mcaTr2r;ZCQ0+SD9_^ttLsZPmSE zUC0`({A_IwhSQ{(;lA3j-xSV|c(Z58Cd`J$)lU4}z~k+n^O^Id)~S10wHGJJ_h*!=0nb_`Tpn6eNzvg)p_hl;{ZS|VsNEK(`l}EZDN#~fMe_Ju{ z%&XSTKj8jiYz2+KzIH;ta29=<^%r$}7b2rGQwKtn({F!L>OdRq^{~FMa?QX*_9q>g zzJI3fE{V|otHFn64on0N5JP?h=;!D5z-usPvvaSJIn?Y4w;Z7l1B}+eisIGC_R<$P z#{mpBb&jcj#yL%S0#@1sx1pnYmd z32(0iAJREV7Q7Pkw(px`I^Vg4`Gqbv0!P4~9tm0fle2)WXX*7$yOr3q(eJTVA_i8^ zrHAXP@Z&STZN&1MVfH$Gpoh?~@bccJ8JDZQu9LB6G;h#?bNB9gaN`G_wxOov z^jWBn^ThbCom@EqTrJ!QuJ~;J#Z2@h_lbYY?l-1%r|h>!z{f}V<5!M6mc4Z2D}g52 z!aIR8E|;w$*}vH1E48*#OfP&^)P_&+v7en>IoR(ZzY4U(St~OTOtfRO&rs=s{+;^T zBiH^PIX%e_SbdPYJCL^r(d}_~Hmx-t-%f))BRQ>{*dA~V4L+$D74ggG$d%~+E_?OL z*O6x>272=wU%#6h+kLmOv1SmCHI~(>iTuTm-OnB= zW0?s*aEDS~6uzQvNgMlBP9F|m;EyqhbKi``9f#W1Tx;y{bv1SoXGE6KmW|8|@!Xn! zS8*wME47cK^^wlUY5!Poh|V-#^$)K-r_u`@(<{9A^fI2s3>i&=8RCr!_L0k zx_bI)JDfZK|3w=9nmmwQ8|WY0m&WPlX0+KWNa%9tz(f45^441&!s&<1+X&s&h@N(E z=rxnxPcjp=HaNN=@{ptJheSe!Zu3o~o)3|NX(nFqY`tBR%sTmKhy(r%5u5%P`cQ9zOBB3`!wbIQ2 z&aM5`LO$}GP{7D>`VcJ!%>U~@VbNtbpR5Ilb-N_;U*n0dAP!r$ z$`;8&EC`C733=L~n@%b4{n+{z92j~1Aiqj?WJHRX2 zBi5(mM*6Ix&nje(_-)OHvpOrnBE^jb17%{k_Qww6VSYrk%u!C|2sHDo~)~+ZRWAu&tEm=D&a}_)YZq`@wT(H zKZI_L=SO`q!#Ovpwc21?2$}!n!YW7RKiT|PLgs(B;*`vnPbVf`6bGwP<&>bhc$?cJh4f76oQ9k#cy3`AMFChK^d)E&xTCu+kJls0Y)bz-YCH)9} zjrU?7dEz#b?cN9k4|;RzL60GL(p^9I(57NL zWS?SpZtvyKPy5$_j~gi`TgI&)fc{0#;voavH}GEjsI9q~HGu?t>SJU0G;ETIUdpx+ zXRbY?GTJZTPwnV8MsTzCchrY?OwXM$Q7=p#_2b%uZJzM*%P7x!>l92@qK0w@? z%t^H;<8l{AmDml{^kZbBf+LMB3Yu{-faa|+GPJ+*6O+!ESww( z){f0W2R~J?g}g7UZN#6nv;8D(Os8JG`AUBy^~5&?_&q9ynWr8A2CX?ZTQ# zaL+wF(!h7izJfeA6+&mbqH6Ad!yCy6$s3sqlr{nYLHtzGiQjfORe@A9tSa?eCUhsEV!d>>r? zsJQ+A^Y#NN;jDKv;ym9+jMM&UNs;}9c+IyLA(TQ+w zhf^Y)7hLI=ZcE%JD1C$Md)1g!N58zyd*Q=<@5s%YZs?55?+xu~$#Gjqe0ZbNSNvW= zwX+}n7651a6cyUAf{E?^IWS)UP-sW!Bsq+){RQ?Le z-#o?Scvw#a?;JaaIa;t5Imp^>7P_~*Ak9h-B>Co48*5f@iD%Aq^n~Y{s$z4^yyCL) z8}p2RQ=S=TFgdIu-*IovUmP1UX*T<=4+XkLM*Ov*TG62Fiuicrwzz9YO{AP`K!e?h zjGXv=Pf_H2%CF$_1h5656+iDhCjasz?v|O>XwB}TT-|(A!=idF_nXx0M_;NB54v|3 zd4jaJsCu&ZR9Ed17hCeuiP)soe4=9)Ky!0tr@u&!c|*DN*jQ~I*74ZBZ7DYUpq?5Q zrE}Yc+9m6;*~0j1l*du@DP5YX)|}*(!a&nl(M@HM2}w@^H5g<%WPB z*hBrOJv~_8Y0bKxd2{#-U88lZ>;>`+I?viPN5V1p+!f4USX}XCZ%rD{7P1Esm>((* z1r2+`_+~Ek)?CB$(sACJ2f&L#hfQ4pJt7OTkuz$Svx1$P*~(uvvN&Lw=mx?56r@r09{vrtYyv1&6QIe5j7%{tp_ z4@4g(E)sdu&F91y{&`2?Rb@Nmr+3Gtn2-DLDNAPJm)*V&8r_?b)-GHBHhf6$5aS_x z6kT%gPrM5}IU!PgkX(AHL)z=FXC2PI>T0ifGJuUzf4v#l$DNzvTgJ9s`VarX!Q5%* zMgBM{TwZ$Is%d9GutIT0_?@bg?2>uMt=Uz^n%&w+{-y9Oi}sTzw|bd18>^${qDIf` zs&GSIU8PksB*>Y$F`n7JKUpp=9W;cwYai2Fupr4U^3Ea;{_0O&UteJQda27Eyg6(DIP_V?`O=Kc^k8)&D3cJ|){jcE<=(4`S`Q`N`Xx)wiSeTvB{G`{kSj42$2I-SYI`lUiPuER+4k0-eV>S);9Km z!QVZBq^_F-SCJR(a<5}+&dm*%Z;rK=EB3e4BrWJ-j`!?pE!TI>>b969?z2d;wpI@{ zo%lL(l-s`7L!7hM3{2p6^BMI6$ia0yL*_(+X=a<+mEAb;jAz;hyaV_SSiq<8|200p zyPdwh3)+wqrD6Lri+a0(Lp~2^m%BB*TPuNOIDOAO?=&o#{H|0V0{B@ z5YFio!Yg&~%v$I(^Uc54u069ElBdr_QG=&wQ;%bhktxm;YTJ`q9DmT zyAnRMYkhM{Yb{64)zNl{v$)ks*6iwVeIB-EQ4e%yPxm(kj6Y)DTe5?7=~lxXie9_0 zDNtKo3;#^|0lZPU#523}PUs(eA^V539xT#+mf(G0U(AsKhCN#YJRV>#E%wxa^SJ@O zx1}R1uD5D}57~?6kVVrw5uUPxYJR^y{$;=(nmikP+u)mu|L{Cg|qXkJF80 z%Ubr}Gr*Jh-!d8L^MohjMsDxHUXxrRUu9*QU6TF~Ie4t3I|9Ug_#VPv&z_@yj7j&6 z2RFbwgn1<9t;R;4`O>^KJ>=i= zI`BWAdGa!U+@X{HS>I2xRNtS3&)=MruJ!SJ&IkyuF96fGA6f}doAjSq!q_W-yAd0^K;YLx4;a14IS4*yvi`v7rnd>wxve)JmQVK zcQ~2bAcGCdhRd^AVcQWR@T=2 z*wloj-@QpiKg}?cBol)B$C#hKQeV(cZ(HEoxBuXTyHWiuajOM!4kOx17PpkMId}#Wj2aqAs6%{$q z=N|Sw;Jc84fB8NSo@t$DYA%G{LXFn!d1H_b{MI@lL>(XexTTkJ6Rp`{>P;INF0WXE z905Oerpf=D8HWDy<_}%H47q#B@ZVWOn*!K)_!`Um&@ZBI(RG}T)1l*GAED#MgPzC+ z=siS#4F^5jMAsK3#p${Zx?c8FZ0_bmo(Q&yL)Y?4GyWp=A9D28u!PQ*P_|_>E^j0ab=n}L*8}K>u3+YWO(=+pU~D?+By!8MLBoVMn0kd?c}otEj9Ul z(~xQ67oB-l{QNPWX~q|mqi5fztjg78vu}p%+{m*rqr>H*!GOtc5?qT-{#L;ym^K27 zV7e06-p99m5zjR?Y+C30@WiLa*@O@KIAe+u``Gh{>AYU}c|`lk)`AfCth|Rzm0nXD zD(A>)VBJa%f7L1De6IE=uIF=V0=Jsqa0#~f-DBsJVwZ2Byxaan%ILlN2w|@WnCGL+ z&sBNha;*=)%%}XCgXcnh&-x&?PHib3O)1)2J7Yf|J1%OB}Y`fhpn z!p3rZVdZ;nSz5mPiTZM_(Zj^hv?d`F*oW8e2iR}#N}HaJkGrUPi8UvDPpJ0XtFGD6 zoqTRn5L+l{&fOY>rh>-bwC0|wTFF$wQ`+pQSUi)qW9^puIqcR2=(}f<^ z+S$*uA@8-a78sK5!(N$>KH2|9D>iaky4tvx`fht0(QS+PeZjcKa{M9bb;;(?h1i6f znCm3uL<4y1+0|68c}jsFt#i@I{<0k}Vbdv}MbMEy6#lxwUn}?v^jpIN>X+}rm0}2!9lRu`zjMJ) zvN)^c;77KNc%&3N;M%>=9B|HH?1B^7-%*HeC^Mefg8wBxzctQavg9@i{xQHWo)mvp zCYzGW6LX{I%*E#vI+rnwQX z-9-O_PxR=*typvY))>5r_ZmkXeEq;nalRH@)8&tVr`4`}8fs5AQ8oRlef3+7Jym)8 zxMK16Ci=Z9(YA+AwK)b{jiY|6*5{kz^V^VWvc5pu*W){RLgQsjvyx1??6Yz7F>Xoj zoOz73U`eJUTZEIU`JZ*h?9j#hi=F=GX`N2LmEc2TmcK~j+QXcwZ^<)!MoyfPVqe^S zN5wRrSo{aa53X{nbHe4nB9>9Squ&dD9a|@!SdbjIG32Lnaa(zio2D4M#wvPu*Od+5 zi8;Q&Mvr&4YY(=zu`YZrHg_*E_&>dAUM+l)Ij?}CDKPjd*`M6#`1I2vf$+pgg%dYd-=g@0z?<{>_1^f?$eF?!7+bLp_0IlJ zD{*|R2j==Fj{Rm2eLL-TO?KkUe8s+RmbuTulOt)g)undGh2tfkoD*Z4V;3_YlfdO{ z;#wv-@*97g2iuu5nG@A_CON3xdc>Wa+IRmo-%l)Ff67m*e6Zt@kTCKwEKS;wuxI!rU|BW4spQ zJp$Yd3(_KOw7Jkz-(mu$S#)#ShrHFH8zAVE zVV`O3i--HYo`^|}9#))w^!}9g7`|NDd+}I_cQ0s(VuNz-)7%@CV;Z(sea>E<%N*XpJx}%QEm@9lIv%$o|GxHAv?t=8{dy*u zeMo3rdk~7P3)<|NvTxajj+mBI*N(rS1OLG~#bt#(W^0hRnhyj%=|O{44hHyswR!(_@8~2fD^>O=5q-%&+XX(loD0PMj!m>r_mN zm1z%*qs><2(k$wd`@XYrTzB3c?tMra6aU_*@2}js{6*&F(YBcbjmV)^!?}3odECV* zb6Q`(KMI`5$f`%p)W}cYoLAeh#FJlPybFHDx&U89dMn?*!1vPQrOv%*0on^3pS~l& z{&Q(pVP7@&jAUs+&Z6ZNru0w`w!GG*+KVaxH)Z~fdG6k__KIwtRb`>S$N_$9p4~Y; z)7~)ySgK8Ok@0WN8z-9@Sp|+3M5W8~%%okw`LFm<29Mu~sU$A&))da#@cw7yC!7gB zDl+ZEI^$CTyjsU6F}6m=W&^ij{u|!Btu}bok{v#LGYy;T16kWFZzec;7`ns;>h3h7w!lSQ}KZ)-(8X9T>)# z*?#-nl8Pm)nZQj!7k(J~oRUiF;}0Fs+%!|)f#+VUy@6+S+)G_R-jXVE7YCXDD)i?Z z#!&^{<}i*h{3Kkx%vo!V*#PsS#HpPPCpyc}4Gil4TFSX)gfrm>8@=DfRcUQO|MQd= zUK@Ge0IVu+cYUG6_icQf*kj$`gIM+9X2gH&XrQjq-B`#lcx0~c3#R4^&}rdNGf885 ziT1;bZtLH``;C8#&BboZQQNovEw=utL_d%5S3bF5-T6!c*VB*0)`j7b=wD)! z$oDXCHFVhU4PYkrmfflykNUgv9)s@K2cIPyN-+;h;iF2EJk&m3v8)byl&`VuxLFoP zk9HaF&~UElY@{FL-oXGdZN?rNQC(k-L7!NC8!7Lnp9p1cbMn}57M&Q8jmU&u;64Ct zOlCiJ1@9N$VA@xZOK1%Bg(t~UjoCoEp4!{`CAXx9G`>yyV(aHYhuG%pfXj)0Eg%L$ zb8IumA?7%nISx@an|?yyG!YMDC}97~bKJX3c7Q>aNM{b-pA`(*wdeOM4&4mZedI5Y zo5^(i23W+y`;hID+xRh@oc9_>knw4ZNhX|C4IOGsn_u-6?~3A8hxe<+-|X?f1a5DI z=d0lPHNXM?-61(FIHVhViE(hJ4qN&jV&hX5*vO_{{KsCCoL)h`VVm(+F_!6!WrImt zfIOW$?Re5M=3r8M9Pzle>anI%@q?N}*)y(Qf?f_9+Vjy?C2dRh>|`vG>#||O4e8i3 z#B`xAS8#Wx;Q9miZb>)Ab!Se;)5P0hv^76XT%B}{Y+co3uekF&)axl_Urc=}<`J9X zKN+LwBLu9zsHaoUU3gqrLePoIm~eVz$yoOM`RwBJR7?~&7p*(8Db4QmgCp_T1n77L zysCMY{At6tSp{9#%)LIL#Q{HM^e%u+s&f7`asWYNZN&aM`|gFRk7Z8;;34)_n==L&V#aYdE9SV6{tbX-?vE`J!X*l>+ZE;S=}-uNON& zHef*x^*O)cGv2;Y{-rHzhMM)0UMX|V%F_Pa5!i*#ct$mC$&15?t?=3|@Srun;A4HV z+@cN7E7m|ys#*U-p54g!llc=ZPfH5pr@+P_?$z*YXTipnjobl94tU3wZG(P#PF}wQ zJ8_$bG9%2WrmlOfRXzL;vTos4p!N~qkn@}eDw&t~IlmDdRkYbp8;oaSF|=ja7x(=T zp9=Hi+E^PHui#d{f}1$rZ69*RdZ)+pplr3tz`T=t>4bZCT*@`zXZ){wMr~DJr^b;I zAIB5)DVm7_vp>fSsEu=&-x1?jo6!CS&dzE7PwSBpW6VGXpOemb7ac%$(#Dz;JL2GO z95mu*?wLpO8V>~7ulpjjr+yw8hJ0Pa*@R)??UVdwU=1>&9tR>kOBD9x`=phV!M_i&%NZZtU{izA-81}aykSJt|4AJ zj6U^1V`Y*_JfDXA2_S!f5u3tV(1lJm zF?iR8ee1Sgx5Tu|_As3P)A}agV_pq;r#dos)SGwJ9!FNmjz9kOF>{W7e#wr|6tm8$ z+h-!(oDuO#&%qNL3QWx_+=XXSbDJ#M2!yYS6kyv?i8Cc<5UF zW%BOs5RsEMt-UuE%;KA_TkB$|7pWxkZz_+0v=_>pd!+WrQlHRPW zZN#3fWG^sao8eyCWeqx*1$W`Rl*~3*wK-) znFGLvZ*6udHq2UN^Crer*%fFi#4g!HJi~7Ai)}fp%=m{^(iSlQcPReCn68^SXMeNH zqj6uxOOA|UT(b+i&M8r>#A;$w99-B}MY6Y47CSQZUMqVAxt&A*K6XxBm%nc*<0U`9 zFWh(0d!Jq;MBV&nSXZ5UX&D(!}Yu*U(--9eyJIfgN&B70B-TlC<9FE`O zxAq;%j5VutTs($c^<|$0&IxA){LAtz#;7tDG(Vm{=J1q_>Zkv1V7eb!U}K}+Mcmeg zd$G%T-p1XWKJIe1Um+%FKej)#Sd}^B*qZpeEMkvp4Eu%~h9&i-=`$&*?`6i`222I? z!@8kJ@Qc==^H&YFRxn{}RbXpXFeca5Qhbm4a@uE{8lU@n8oXRL*3{^{TJLXK^TKs) ztBz^Cg-`5e&lUt(=3pGFsZ&7PC>p8l2>JEZ?sgWnAw`g7hk#a>HD&bWaR9IVRmP!$zBk6a%o-=+%<6atzgOOp*yLQ z0uM_jtsiN!#`(MR8Y%xG_O4`CHN5et$!mIX(fR$2d8U(lw=WM5Wi66BqNovjinH{y z)PDU~)0rdLf~{%6OJ(qm1@DA|_B{>gaC3c5Rz2n6@uG0h+~cQCnD&CXPM*(GYfI)! zamY2RiEGlF+|DO^bw$F}9F3oSpfm9mBF>v!yj$6$d9Y-T?AFmuqBqg?ZrMI*Nqvuy z!=iGLZ;s+oOOZJ@uL!p+{6TBpoA%cj_kTbO zYgmJ>zLXp&$de7W=g_;*3pkBbBQI^@Oifadm?OIg+n#kLf3{sT8k}qIyu{meb4fOT zsb+QwdvnAQ4UewLQRLmDnPzqrIkSc_YE9?Ow3mmL*~gf#xkl^UKZa_nS%2!W=SsTcOkhP3h_Nu0pA{PI4cSJ+$;N>HJPu=S1ef6 zKl?IUweMi*y@fvnSUi-gGRe-K9kP3t@8v*o1^&-h&|4Pp1)$A>98Y>#qMnC2k$hMR zFH~P_FJB7nYaF8C>UnvWdubbf%Ms1DV(+!GPVItTTPbG_u|`Yb6CH`{8_2WWMBa)|d)f2!@jmQf?Zlc&Wz2v0U&}?;buFsx$9Dgp;3^kf_W+keql=3B zWwXkD515AKJ+Z$X*7`}jkOmz{FF8I`XghSG)v;&&oadFDgB{kl47szKd)fWqU$U=s ziRX~yp@F^~eXcbjG#*T{iNWG;p0UUP*AebmGOXo^)$R`y<5lG`m)p>`SL;`vPpPf$*SnSE9O^;-Y)m&3 zeU}nHL@u@XS+wk;FflaPxDJ1Xc&>H9Xxdou`w30W=h~fj7dh+QoCSwZ`~IK@?)El0 z&y<^pWVFt@xwiZNr|sS2qpYs||2>ynAc&9zhyk6M1T99ZRUjl4n5>%2jbd}~MHLt$^n;2qFu44qu`n1cV-aA%1rTKTy0!r*(Z ztB2<5xx*y|pG`pHbAea1oT*3rJG5d}=S5Exn@xY?w`83B37Imk3V)R=juu(Ui-tNwHxkI#jFTa!6-_@^U`xBQJ(>uO356e0_ip5`lf{!G@{J`fHvqo)@oLsRH$ixZ4P^YJI>LoiGShGEuv8@w(IuQTvE;kg-J2W{9op%2=#c^>Qh zbMUxE{Hh(~DiF_sf9yLCIuB1d>wfI~&MbJvT70Bl`AorG4Qn?Go`;Wep5{A{UYq!A z%M|}07~Wufr;q)sylZAon&A<$(O-y{*!*%=VRdgPm(P^yUUHAXFH3BGNu0o9`h#DV zjO@YRSpvVT&fu5gg9bg(=9i2^u>0YcyBLS~Ws@-jYZ!;0ay4e!IKOP$>+s8a;nglr z%H)@)!|k5&YI}?}gi0DkCh>|wPhKWUJ<^1IkR;EZ-_7}p-~ z$^IR_u3tL)TVa!?!e61Q=&G{j}X;-}SIh zyq`5BKCzGI+na6)S2R#Yev@UKN6>mz47J;~V9V@3@ELPB$T-;xc`Q_PB|HOPEq%2i z8>myy*rPgwD_&NxZFyxjJY_C6nV)ZD{pM=M>tS!i_N8Q;Pm@1SXIHlcowGe&){|sW z0en{HMZhsU6kdYgi~7&i!fV5Myy6Tp5Lq z-(oKHIYao9L{4oFa$Wh%>vRtv-}%}{(fOoplr{7(`KUhi`&{a|=Z$QbDesm!K>IIp zPE0!FHqP$7NKW7IvGURJJ!~^*#ixj`2Q=_g&sQk6h31 zIG;*iq_}kD+4-Bs&MZ&fXpLQ_H9$Tg=@Yx~ttA`qzw`cW--Mpz5;OL+U`Rhpajqu8 zvt5i^zN-{tPUTEH-%B3`9HTpIZ)B}hu~zW0+h?+r3s7rK^U%avbJx+2X%~8}+s?O# zZ;k!S&?q_K$`UQ6uMr%HUc0zcJF$%alrcL$wxY72z4+5_q~^<>`61^6WqSyB!=>IR zIc&?+j`3AJFJ0eyJvDD4u&pFM0bk7cINO0O%(~JZlJKPdo%KL&hx8e1`3IyEo>;lx z_7R{vwFbe9DXLjvf%`Vj=zr#9YM%Ob`xOjs-{KF67VaV;4~xEE;r#yF%s2ka?Q!;P zJ@`Rco1M!27(|{XE+Zc;u$D6JdXiJ#%qL!OWB*S@xZwU}N4QZ9B=) z01eiD(yDwiD?a!jUy&9m@O&78@{JSn$hFx7nFz=@S+d~M^uu0wZkeU!0DKM+rI^laVb zW_tfjz5vec9+t0Uco=&JecI8VeB8O{>us0@9`>WhYCefS&|L}a^%4)DoF2ArIjH;Y zq>~fJ363L!FQ1B2An$?IBe+Bhz}6|g-p`nUE5DsFYfeNT+8>=%xaJ-3C$D54(JQeR zY@RG0^@8#o@Jx2(wi5A@jzMohhkak`SaXS;$4vgMmFONCOVBsd&cFA1Gv~YDYZow{ z;rx!lM0089llq1h63w$ai6N*H?*pzu(M_TOUY5$~!Ld=e5qNi{b3YvC|2O$xO?knt zalOT}73A|wG4u-2vF9H#17=q^9wek<}&a@IRiw}tTs zTP~~6IeDjV2mTOy?TK3IagHOv{$L05fPFHr0vy@#CA?Gqblyi3%tIkK6aJ+0oORl~ zp3b-AW0^u3TjpO!Y!mynMfeoqr-v6X@5_*bCE!;3@IlIoUc>AoEP*$)r+K*a9sHPw zW9W38|2Z6y-at9ggM6jQ=9)_V7Qe5ita#p0*7|g4?JxWe(!ONF4UC`t2Ag)UTk0R; zo$4j{*4U)0sSUl;nXQ@l%DQ;>2J4{zmbn!P`Vn5+;kyaWdW5xizD7AtL7}#df;mDCOPe3iZa2DQ z%v;!*1x(BMc5=HYwrN`Lg^bIl7k*#UncDm-;o~&f^c`^R@+INGFfN;iIlRf{U)q;> z378MT!=$5$-@l9w_R4Bc^py)dJujns9YXg?=J_}i@8l>PF7Tsg`nl62tM|nV40n=b z_Z}o)6g=d)A3{?nRyuxj>3?(1CI@7O{`ZLeewQsX-TW#27g|R=;_2wB!e8R8w#TLO z&$0QuuWX|)xIk^J;Uj+0jBUDirlW&);j>Ua$8PYsEPGad82hKK1AnvW`m@F?-SzAL zNX_^ZxN!L%c-Y|Tw30<`tdYtqjwm^U_``zi^L0n_UxOD$j9`qQ%N+3ei81nYhK+w+Bj5OM*M;EW!q3g`7=PL(Nsn85)cy3Q zF-lK=i9JHqxt==CSa#cZYlCk&HW@wz?3&eP^%lW#6Z%j!>+=wBiMOE_MRUyETkzLx zKfoQXLDrFfmMIAPD#}01IqQeXz4I`($LFZS`ORwrd$H%ByV;x@Ut<;7_tafM?CP59 z!aFrT#3S?t!QBMbg=p!3&e`+qnI&I}SKn6Z|JX_7>SR$<+OIv@y5_ z+noA+Is;=jwRBCMvCK6weAw*cw<;&@x$p+!3-&!s?E6DS{;~sRwEU5` zQ`^sFJbKoD+_b*T`qP;=oV#2=r73)#_PlH_(OPwlJkdY*|Ml%HpY-M@4c zXWv&kkMtSQ#vuL3ep9{^<=~OcruM}PTpIs2^JDXQ&OZ8!u3e#gTj7#v`6hfuepotc z>buCV8RX~czg78H#AlSF--Sys2`B3B$LaoJ$5RUmJWhTU^at(Z492|?>7~!bvz_ma zZ#R_dnR2kS^Ip0BGT%REuce>%aGtUEeC9&)gDlPe!5;DhivMsXXIrVK=LhhhZg|kv zGkrY|&G7d;63FTi4Gxo|gS!vxb32}AZW(LwsgTFbbv@u|J35huU9%a&PnKqI=D#?n-Si6Gkl7(C31j zCkCeeG)#Nwvz9V@fI(#8^znEBnUvY{_ue-)o{X zV=iS+UiW0_z6~dLgLKl{U$y2DthNdUwQY z=XlK5-lg>TBJ_!5s(mnrvc>2R{a-eHXQ69UR&gibKUQtP&O**%pEg$xR1;_WO=9`uMWd*G73moN0`hRQ2^D{d}7~^!)Gqc5Dn2-8z??iTP&J z*15zia?gk4yZHE<(sQ}H(3;(`CD9y&W{P?jQtlK^O!Pi*67k}H!LLBS^?^^Ozp?*g z{8wL+@$k;N7~_c((;R#|HBZlf!*k|(y*t<6;J@Q@dIA(Va$P({^J&jxt?nrF_i!F< zv)28;;9EvleogxX+9Mz?u|RzLLh$e)dFkZ0U&}lfBVU-8R{14fCzj;n(3IK-AMlq* z{%`OV>wBZmtY21hQ562W`SuzQF;~PWqr>{We; zck^|~r=n8dzEON}h1lZlX*inGa0mvKEypK%CVANemtayn`WEUJ&{w1cU$*w1yhS73 ztp5mbnd2WGix9`c`1;V_`lO>R1E$Z=e;fC-gwbWgX}BzSf!^skG(7%X@P6fFYQEm7 z9s51wX{26|@u;5sV4tJSa&kNfrnTV4hAoZrnc!Uizs>)z-~-TFfF|GJUpTSyxAt0K zoOSn7?muYrgFb9bz9p}8PnKkp*0p4pc(i!6)^~(8^C9~GD7^pF8s9m-#zhC|xSO(L z@IVi-A|tw{juqf7N#EH}E4mQobI~(S{27Pl%JLEJ9D7M_IvE zb%#@4<+K)!&#HXm2~YH`Js$QNHVh`A-zn6q`iWzAu=wgOQad^5`SgVjgB?-So1k2k z&$PtYZ)o5<%y*dFJIm3Nb&fxRjTHbU3waj!qNg>%d+TIXxqKbckvqV}<>XxqP8?kz zA83NL%)Z&a0$)L=V)(e<0X{IpE;pBQiQLfz$v$&1_UuS-+ z`Q3nTo4r;$mP>GpcWE9qe@@JZlXFV_xqT@v&d=CZ?<9{O`yFiy(QB{)_nfz9%bwAk za$lZRS#YL_!b1zGgOA3PL4%cD#L2Wnw;HSLr>>XCeT+Tn$um*eVnxO?5U4VTzesHF z+vwqMgJ1C$`B`R9g`Qg~D)hTeIxOo&``lsam+&<`|LXYj;<6j~9Yddc`vT4mGcM^5 z&j7dnKghS}K+m4!w_^ThgInpDqBTcX^7bw=M(ep!XR<{nniI*qz&3c9<_bMc-}oiS z1yRA>h@2~VLUvkC?|%GWVdlwW3i1*c6Y~K6+P!A%dT=rXZ4OPu_c}3mfknR&o*(u3 z%Mv_4Wc;1t!Byy~iv7|v(QyDe_Cv?SylwGA$Lw>Ae1tf?VRFV8Xte}7Yy+=;vj+1U zTEu52e?fx$E{1!Q*;|iF4y&HdD7;9$e#$>ogl$hdtL-*@Bhot$Qm3D>KFQs5=u`Di zG0zV(&;88vOX#m3p8eF8hfi?so_^I&55BY)sj~*V5}Ms7U10T;)m!xL8GLC6c-N)A z;O~BZN6Zx~ES?J{ogrQX4g^ zuV9(T9Lo=4L;Ic zJjmXa_*Q9u@F+6dg|m^nMZ`CB|Fq5yJWO9tWt(}6fLk!f=}+)hr`wnRw*p#rcz4k_ z?>@28x#z}_@9f_$@kVDt-w|~5vCUSW?)G|?@!8{N9HZPLp!3#RpEt7((J9Ns_u|a2 z#%92;JGMnru3Hg;cjz9l=kblbp^?t+!J1 z^gIP##hZ7jU)lmkQQ;#AK4`1+XX)qqEnU*R$KrALN&x>^E_^7?JNXwC!&8I27XQMR zvyXQ7QReXO{2PvSFb3=B3l+#j=;eQm8suXF`CFYLwglU)3ZAfg!wtt2GbfpO-5aSL z!q;*9c5k6a>5iF*cUFF4rRkeS`=W`r@N)|O1pYp+nKcq-eSQlWQwsd^eH9D((FJ}B zeUW>9xp(ET)h>!09pRcpgH)ABs8ci_QL3BKq79;9n_THwXcodEte{6<5>ylG5tp>v7`k%G1Fmosz9tVdx;7~UFA$+Brfy`15 zGsR>7D{$C4K67|JFbjqx@M_O;H@2L7$8Ye?OTYT9_JZ8Y6Zgg|6aPq|k0^#?S}%v6 zHknjvL4vyRlc@#EX;*7jeB>X*7G>IC;NZVb`-lb$be0WB-DJNJXfAm35^m=o4 zzV?B{=VX7i{hYWRWR&!V*XdVlQa)v5dgmJ65Bp}6IXZ%U|LlgF(4QwUZ}K6;a*5la ze!Khwl+|z1VjO*;oObjq#TtkZ+s+LT|xe1iUC$hBf%%mwbuGA@i)P=>jf=k!@T?lW<1QS~VsO8v!H zN80CU+U6Q{)qsUS{j=oVD=NZjjuYpF}==<`wDfj=v9j z>&2(BUOB8o@ef4}<;cD1_)>uJ9q(iR{9b*iu0=iXX?0~g%;BB8|1P~tzJY~y|E{0b zxs%h;^EeN@T6!}%$&rig_+qb8+wgxk=l1)lYx{92lcbERAG>lmj=vTM{3F8Wbsk%e8Z=iJh~54ryc*v>wAE! z)I;v^$l$>lzMkPy&dHDmQTu!F;lo3<qZVOc!(d`quji}-5k%023OT?#mk&fzlX~uKee4N*NEjC)y7bidI z%_csWWE`5$V8(nNgg0p3cvcr1_4Y|l^%GAI9Iu6V=6TiIr~iWC2fRzL*1}$7=*4`m zGkwHJ_9o#cVfgkmaMn*5#W$imU*n6IYozCQA*(f4;qTO}co};LKT(;^j0fQn4xXOc zrZd4SUUK$CU4J`9eW9IaoVn4St>l!WQ+j(j!~5)2(8%*!FJ^A0_s&DVQ0_SQo`u_a zr+)Nq8Shm0YMzNVIR4H8d+oY$RkMLtaLSJz0&c}moX>Bq5$Olb+&^rTlR`QU17 z7ROi8Z`;O;fnDomw{u5MehK!yYd`C`>LMG`bxy?;tx$}bc&Ke#0aMmO4H?wRIJF zfKP90@w>F;*1bMmS9LnHKfwAMy2E;WEpbNu#64Ix&iuXN3(b6T^BjJo|MN52{Tk=I z#8X}{StF7g+Pl|((X)fscjoi;+-vo9b4SQL{mPEex9ojAN2l1_!u>am<(7S3z3GG8!&1T7 zb-o)FgTnKYd0I=>nD*`x(D_ozhp&ZR?hr34=ne4wN7_>z=4@<~-|jhhtq+SmeT@@( z=#|}EJz=!GaXRbl9JOieGq}y?IpY?tG{0fSAEu0@*r5)qN9E%5-CQO*H3KEIqrO!R zzttx==P=gR5n0&Ae!^`zX1@A49cKprukfaK9{-}TcE)G;{yjK=-<9dT=iXZ9Ip4qI zx#pmh@fI-t$w$VeF2_N2?-p2qnAWtV}IWs39pS9EX6d(e+m z*R6-lt>2NZCtuN2`qKRDI$NkM%dNiK6c?5DtGRkm7WHmh0p8Ku)Q)n; zOSk{q!PI8aN+_K(LcAw)Jxbq{d=KHTU`^TiQ|<;&3HCkJj@HOiCE5cx^HR~eWRLc8 zb90#K@=8PWX>KGE|uVt9%;U-TW;R5AC47k9pt2hZXz^oUK*#hnq>j(pQP4|E4}!G1xR zXihZU&42CB;WrtOe`6!^PtRMRCyi15SI1{n*t;4&w7LY|4d0D$&&pZY75I+_!V~|n zC39`ahH-U}ui84m^j^)W+FeV3;$1F|8_-Givc4VMc%69FG`<_)QxV?%SLKChSTaKL zKym>dlZKsGQDlQFFNiDsNh7k=fNRN((DIr2jqGLFV-Ve~bm%fKgDxqXuUKc!S&c>D zCE{D~O5tok@=tTG`dWiN=C78ut34`roX+^5`*C{mmKA~v$6vwyMBrKL@26>bGZ@|B*iJ1?vo@2-}$>b{T zO0Gpu>fRt(&we@d%X4TBeogy9Y~XIrPDm$u@I2y2DC6k0(`;Vw8nh|iD%|TnA^Ax- z&$Ey7Hrve_dQ!FQ+GIsi7y$Ts>GESrdj+s{4V@;?X;^&vVgWleP3 zavRt<&)+>Azj>OUyYtN)4=OH0u{iHsvzj+&-OgvNYJFBGP$ey?Yb1Gjj-h3whSoPQNwwft5y z_7m!jl{)L=l>BsZ9%v5h7@zthp07;(Z9kscaggzfU(BJOX8Kegk|BEbI(XY`_f^O?D$-PeVPsXL$uKc>^p&d z(8l|}^^9;=$}8!+Qglzs%eCB>QtI55@-ldQ8Q4ZHm~g%U$I6#%tRS|`gzONdDkIdTe`5Fxt-ic$goJS2h%~zOEksO&lGeoA<%>?3=SzEcBle zQ^R?1???h!;YFt^DYWl+tCOCDJgs~Vy~g1!uHESH7V#+I`PH=V+*zju;N4rMJ}cx? zI-V+5oO6sm)n14>ELasN5S{8-9{(M^re854y-U+JnQS{Rb4mZtVTYCl>hZC%RxEUf zA!yLG_v~`ksdA28f!rsj@_#1xhc})?m5)<-uKni**JIQ{{Q!S#b^J2 zZhu)u`=Qy`_=}LI+}kirp3~+8KB4Oxmp76Vuyk8#XPBITwME3mVb2VsrqO#AGxog?q0^r!9VK$P zYct$rF|V_ndHtg16VC!NwVfs#<$oTjMVjey)Dv#whnd_OBTJk#}G?~U54C8GpTaR$NB$CL}nxto$boifRECl174mY9h@`flPFe`orBMt;0F{`;-Ofz>2(qeJY& zCGxK5sW#QJb?Op1**!y?yGhK9U;e)+i|*O_G3}r8KQY%A)jn&5Z|0?s@y;_V_VLF) zz}(Qj=YaY0?d9mhzk_Gr?P(28GV?k8RgXT?+H$F>|KP8pwB1#_V%GO=O>8uMEAIN~ z{Wqi<_unwnL~nfSuKi2F)u%kyE!gTcx57hT0~fEEQF3k`07l>m;t$rI;h!15-kVo* zqx#SDx&-ePbC?9i;H>VCU+g9nRUzn8aD8yRE(y z`*Nc{=_>B`E<0lK*yl&qM3|3+Z%+A+$p!g4zA`1hf1=f@ci^qwt&`XqDYj#-nG1K$ z+&u>O49O*#Ff^rMupAf^!z$mU_H87eJji=u6gT5npP~Iv&AZ1i@9arl!@LWo$lDiV zJL}F+?vF(7EatiNgCWMDHq@5dsiPg)>=u3p;s5*R{^$6Q*!J^9QO)xMcbIEDtdqm` ze2Nw#mv`wrR;fSgYhTUTJNW0;$QJGWseEc7G<5~1v@UmOLeI)7wTFsLZZ0M!F@E{q zXv?LOk@;qmg}t@(qn(2jtosI+7HmIqh&{ZQvi@*HIZb+fk-B?w6GuL>?0ZM>K^Hzi zzK7$`ZW3Hv=9_uz5c3zD&6*=;jp2TB(ZLWj)pm=MPsQ#xa#8dS+Ih=cu=pJM_d#F3 z0ETm*S@*qsFY&-HKQ8$2|6JlhCl|hr;va}VGUytjsgsQV&5O;Zllf z=uREqd*FrxhrEy1&Sh`tzWa`d7ZlT<_Eoiyu6;VyJ9$Cma>F{)*{?((etD91w1!Sz z5Wie-{BQa&=H~Bu%n!etm@C?*EcRJriPCPDy&sc-d{po znnmAj_!c9;EV#OfQ4I6pe)Qr-auSOUMptw1#S$}?{6kmqadLF844C}4u*n}_-y!^3 zpd!gS3RRh@f1+MvB2ZDr7z}x+^se?|&JF;F*7-{2PwOhlJZMeJ&lW^4{+1~!f0H}Y z;?&3IRhe93ja?61$2Ug$VyUjdO4<0Se>5rYv9_Qcg^<))ASgpUwGVMo!!wszVtk`CBLQiZw)$PHopZ2`;uqLImF_uAP9T?$)X}CLBcG0EPFlHcI-v&mG?x z?9I!c+s-%|k6&D_e$;-qcS@c5s3IqU`Y`xc-8%0F-@l~JDc)6V^R80*lds{=@Hg>x ze;DaYXq%J?i^_PtxrhE~PTd}28~*Rvon?lESfnZ1`a8JVW%YU%Sv$d3YnEBNrGxelK$9Jmd(-Fd4czZ(m5h!}*}P>Jz8b^b z84Y>uBhw%I_Lk3MyQgNGOV$yWacH)gItM%EiqPI--8uU-{x-q)h4c3oE5^Ad4R=5N zdXepKd$R_%0-J2zm8qJIE6<#nCw>{^-Yw3uO@;5|LB}l}z$h5P1zp8quUS0ATG&P2 zxDC z(5lGdl1ZMtbMD4wlmPs7d{?%T27MNYlzX!8^Z zPr0?}w&NEQM@+rV(Z9>Y5umW=Xwz;w`e1y>VR4;~DllleZ< z@@-E^7XI1yrRRb^fai1T!gbU$E|Mi=*-7POOhK65>_KQa*fG^Q~KXk7> zn*W=57tEu`;&)psn*SJhpF73O|K8-<{cPjs9|8_?N-Y+>v=b-Q2EV$O`?2_Ly%&1u z1zyRd7-!`_|8!Sz%FJwO99Z3eqsE+w9-4qriC9et@i_0a>I>Z6%B8?7y^9WU=|7&#zZ#>BTw7#!$uFhY7EVWr>U3o1zE!_j3{R$7X=g4Zs ze_|(q2i29%pgO{HgfcPWPTof66TOat!S~_XOx1sKJeCE8y+a_+9E!~ zGp4GbEIyUk9r}vXm&W=Qe=B`95ZH2NWCN&Y9>^@yjLeF=um_vt!=efz`wfF7p;I*1Xx{d+^$0 zUigALccKsS&2N7%{K==Gu?pX!d_1=(8F}_;_mGgRDE%$($#* z?%H%+)l+%ZlRYe0_1&mFbbPAXd$jv~cFPvky@PtH^IOU^eH0vDMZP@nC0Yr39e+TA zH8}1QU@ZpIy1jJrDtKcMo%|Sgqi9}gm5%}3|4-({jsstUe!tAeSq_cW4BSY6@-d&% zfgOI)M0q{8&@&Q0%_$Gd-+`WC>k{Z7_J8XG-U83^E;XB8yb)NqGdTVW|C&E_ZQXoq9{HQ(Ut+Jv-sjbw&)VyiZ%DctXRLdM;VUWl<5|$t)>&3X!zR=RS9s3#JeLH+KtUI{*O)LQPbJpVRrg)%hcj?I06@A7y-{~}lP5wr#?DntqsBT+%=tm$ zsox$=Z9Yic@-Xq_(&yE;pSv!*SX=i1OCxuHSFI;5gWrl#^>{uF(J;~j4@?!WWNqyK-SUt%Al73?J@8zLJN9~1m!uv2gdh9qmYd}(+m z%5Y(FMr6Z>;SIs7PQ6!KN&FG#E)DJ!{HkB2vh>+r@ARoM*O6;M{tFL2c7t!W9bf8@ z{^Muop2`i?_)@21GiZ*(?591SHCm3Z<`IA-oyzCjEb#=;b1- z!u|gY_T@EyOKooCzATS1eZ&D2cHDPgoyOB~U-zx&dLsFG(2D#iFW~Qq(VotwaPG>X zJLaYvz7=NPU07^708gR=d#!^fYq6PMME>?n{IJjXPx#3LKY#aKQ1gcy{ zAX?Hh7Y~!bLl%AIaVMqbK(wVfaB(>YzSib(=Ah-yteK6TNqMp-+&O5ZJ-4jnbmlzh z-tb)7RR3D%F6~a@e>VRWx1)FJQ+w39qfs=(z1P6&vmnRiXSE5pV&&WCMI|CGz*nc3P0%NN zz3(Yt?K+FIqbhT@6EEB~6~7?o7Sm-a?Xw|H`LFg-Ue7~q7G?|Qk@(+Mtv2PSJ^Rv@ zP^35u|FxgH?i2^;nPhD+NCqx&4 zv%AUvtTiTF2~W^auV_4&Yx1RgK(A9R+Q4tH*Ve|6Qb`=xAiKy@c!VvZUIVrxysB|Ujq}!!NI)yCJpG>tH?st(Ypll_C@r{A^H~_ z52IU7OSh%6Zu?rxY zZ%J}TVL!RF6VUSvXt~Qn&X5^h#ce$Eqq9_VCr=W%TA-WvGk!=MJ@5==Y3C}suhT@l?Iu@Rk00?lZL3|2d_S&UfX{MX3+q`l#l1i~7BauI^O*Wn`y=@I)W@+l zAM_F#e6`J2`D3;Fpf`&Dbc4qAYTLB)MH4ghoW8oyv-bX4cqwcV4wK+UHs$N^hyb!O z37-fuPX?a$Hfx%82DP5Q&i`uGb2Ygbbk0|6`IqE+@UV^_@Oe6Ku-7-|;(di9;@_GJ z>*U4wZJo2jc6rXwYrQLe+Bp+99UHQ!*TcHg`#sW$crUr}GO#7!OXuG{CBJHd)hgSQ zvDYWySLlk+LZmpxUBScrCkE;r?ffX+j{Hn&S9BSI782xBbj~W}m2rN`uJaqp>TH?K zhv2J?+?PBVoI}@-xoc$vdOFUhK{$kV_52KIw}00KBVU65sI1Cp4XWIyC?|P$+~+UT z8ax9Wev)UJyW`xutvTbo0rOwPzS%5dmS)j zEuMWq=e=IK*tX^MK8!8j&Riro_bi;dZHd;y;9d1aXm{a4zuFOI&f`E=(pLSLarQt#|5O#?O5 z(_Ea-+$Mcz^lFW0JetRg()1&J`8(*Q(p4kBCbsJ`i+rc#^I)IdmW9ig?QFz1X3Ic) zv6A7>*O=%!?tp76Gl!GN?n>5D9y~$s^bEgyz3Lk3!sqAx?JuWdX_1Nf+Go{SS~u>> zJ!5xb<-ez6T^}J<#yfs?U9mp~nCvqtoPp8#6UT1xJ9m?mdZV&k+Ll|79$?>Pm@)RR zFu0I%ds3+hJ>>lv`z1b=_=M2~#ANp=-aMQ=S{}aC(mg$W)#wk6Ueot!#}+j%Y2Bu7Y5ta?aw(AcpA*gBb&TkH~bj?r*MTW zTIR-$Iq|H2w(+I*A0dY%v2tbFzX=o57({P*H`rFCVUzBxdOH|LoY;7cN%oasu=kvJ z*H4?`A9|PI+sy~&#wpnIp;*e9oB;>_)2~KX<4zwBcOk|nTRr5E8t5{*dmT>H~M=HEkWKP1CDbx@I>w8A*0aoX{fk>xHN0XN#l5@R$RO(Vu=ruCEp|c7pMxR z9+a25jk!MEXBe%))(qjpZ@}I>pEkOX&*8$=H@NXZ8jnra%(WY9DEcO+LYd<1wRY`X zU+4ly8?uPsG-DT&mk1fiUaI6@VQ&e2e}^-0(`3`Lj)s>oH=XFo#y6n*xs-eQ3GeB2 z?!*V3SouTytc=?y{9=O*5BL^5f2|HPUOFQ)(1 z|J?tnHRj+t5cK_iHt=QfX)xZy+c;y{5HW{eLQiS1%;7!Y_qJi?Ch!Z#upTgadhEeRnvF~v|tDdX9-Q=I(UEe+NhMm0Y+eR*Rz4L;bk8{Tk?<&{5 z)=)8L>Hn)Jg|$5w&U><_hPzI_fNZ~cmZwMWm(KRu?{B%#XP5cn zyfsca!HaDC*M+NkRQ7AL?zGF^GyAR{m0fpXThABg`Fno2aAD6Qvo7!1Hv2=sv%iGC`FT5J! z<#wA7EQpw{g-!U(4g4lzzPr$V$8YvsAB~u*NA*8Myqs{MIt>x;;ouzX4f=o4ke85q z2KuL%!@Iykn0jIAH6K{9p#wUvG2VTGzu9=ArMKn%o*X@gx3S(L=0oFct6Z~X_F@0- zQ{AxXc=X-MX}`#ZgZ133H4M*g%e9Wkf3ur2d|}1+;L8ZnzWY1DyYtZ94(u_HTllwL z`b*B^p9hcdL+-!y-~ZAJT}Na~GOocU&Wp&NYvye75HPhFW1p+M37US=_0jXc!WY*{$<&E z(Nti1+T;xEG5&#@8MAE34(_h?E)9)_7+aOe9>G?wM;>=J)0c8z9-)2*_mPz>4OWOh zYV0bber}$st*1a@1 zsyJ`8sXp~iZA7RWquztqyov>IV@Jj7#OHMW_>;(`F#Ze0crW6&^c3+dH)eFaP98D~ zpA+($oUq6VGq*+OddV%iC9^CvbgRwZ?egqFGM=`H){z+BVXsx0z>g$4iosu`!^;1A zF>Njb2D@+CblVecC7`Wg=xP{RdYU_wX=BW5vVWare4UH<#7uBqhbdWS408|dw5wfm zk-bT~YF~X?*rV!G_>ukUg>L-hG%n=>O+}gWmPYC|6K5)U7a?X<^YYHIo8EunT=VHL z+IvOdJ48EG%wL3dO2AJCZB6F+0boA>?4s8Y^ufEaTI26*0_FzbMQ68ZS@l(KH}!(h ztop}ywf)1)BXV}o%UsUpBUx%qS+s&RG4CN$drWIJL>rdz+b}b>I-Q$i&DA;QtWKTF z)|}t?E@RL*T|Ft2HjsaH$A~Q|K+g+shAo`G$v)@h&d)f0peJa_KD&kQYO@!Zpr^7- zS}G`JzqGbwA7ffy!=3Ff)_UdlAqTIM)BLkB>uT+V9N&XZKzrD>w(g}oJWfomr}w)L znybZ6A}gClcP=d*4H?thzOrOA*s{FBJL`)5zFBwfZx6<~OP=$fJS)mE(HJq4>!~x0 zU$Kg{9cF%nYxy4)6Bwd>i#1xx8PI?!8rcm`y9wV}DgF%6g#|DAI(2X@4kX#{lD{cP z9sBN7>es;Q5RMT-F|Vui6l7 z$^$Fftfu|h_*bfct%)_XcInL(vs)S~`i;NE0GrxXn;raC{Zf-XAQ*R1cP;c^WwJ)F z|9XG2zJ1H44PV=m1P{u^G#5KT`JO`Le~Xbb(8D>tByp1o=*p0@2oRX_!VwePv_|7Ik{jPpX;2_LYsJq1n5)>^hlKUc-EL-_kVd@p(pQx1RX8k?PX+Evhg5 zPB?F1&%35{^A@cy;j6TzN%wipyXlrDd;YY(Y8Z=C$J;3zOXF`~AM@+rlREoC)Cp6* ziv1Fc@`;u!dc=@TzeYYw$EQVYbl)z&f%HRm{r-$ zIY{wKoriPRPY?JK4btcKX7*@&*zZ~7Y&)O6{u6Il$ZOwZ3W3(BJ;h*$8OaU@UEjy2EP2`t2300SS($ma!+HIevk1(||+rL%(%i@x9>DImk?SZl>R_{1ZIWd*}0fH}LGrfae8Z0Y2zq z`ai+51R3GN(@z=4c^hyEAD)Ux-6iaAFqV19%+?rd?dm@`_pazX5j+%Kx*29S$V9rGWF8{-FcvS^%bEn$=bC%XK#J#+I0Up+VkYU^u2`7-@6rE^(a>k zWAt!-jk}D06b4rho18qsqH^#-`<=!sn9)tkvdC}M4Q%SWKfO-ezLS)b?rpH&EXF5T zIO><*NZFwA=Y`WbaT1KTin>Pp34SkFLcmgz0ZYi9Cvw+7mx51zn_Gb^qBEoMS<%;X zPyN0lot@BYJ-lvAvbZ|N-l_>dZgIvj{4}--dT5CELH0Gwzg!k& z?TkD~|MveW3+?~J4%I7PQ;4&P;SwiLw|kD;rC;$R3z(nct|9fUcA?XHw~uzaO?;fY zO{H{f#<$@n_C@S|f(xUOzu_C_c{O7SMy{<01%3O}hJh}FPde>&(+2Zc7dbPsp(Ji9 zg%_RmRa?@fKS_I6Zq<2Y-ANFk-L8Y?u>Y*; z70g-vLF^6r#mLKzUrOg_JiXz^ZC$PRqwoTiHARbl>Y=Rg9R}Zxj5!P(vJICf0&u`W|5{z8_27V+j zF|P;qm_GPHeyRZfoBVLc@mU`CJ{p)6xqO&-CHVk=CAM7zsBeGCwcr?WL5`lYcAlsb>goL%dI2QSx(_$x9!J-NA=(r z=Hu<5o$VobYRPi$3gjO4MtEz+xHd4JG1*5RWbJ%#ELUSi?N{ zj~CqY1axp2{YXB4H5>mXd8sY_e_3_O4-KuihKci0&eQ|Gf->EMz@8R%U2!iLD{ZP~ z{|-J>R%Onz?dQPqvu~BI(3MYo0(S+y&3VRZyM1re&~JFM;t##iYG@!#-$y&JU3njd zUShm+>E&+bJ@Z`zy}*4pop)NZYFG6HV>>jX_mbtJ8NILPz583VQ_BAuJ|5=r8DLl4 zAmwjM)94U;L>hZixaHhu0y{K8-jpQ1^%Q%X9oXVQXht+7`VOC*dCy;{UzJPD0R0~; z@J2Of*Yo)Sbd_MAMf~@d)deK~Y~6}Iet24$B^yI_H*=@9PB5o!Z+cp*iD@~PcJ(YsyXb|5T|5sFbCiHK z0?Z$KU-mhd5M`9J;&eLKoo9N^x_NFG_=IE1)+HFXJ+~8?TYkH9`-T7FJY7p0?=?>i z%u^F%F9FYANzYSK^TXVT=40^aBzvfhtcfA^UQ7JU7xSoh;Hy5tvoP;eM(f?sm)Z-T z^xU(HHqk*^Eo5Di{pDS6q_&qpqZW7$9{1gY&uo4JwB_L*?_u6&&Y{bnr2lzdcl0sM zqimQYeI}tB?sc%wq>2t?bG{cHglxQ?q66i8b?Mv1t!TU(8gub$0h^1nH^KRmci?dP zJ7|3}I8-}c#^$A7lHYUF>rb@9nTU7N4)7z-?O3n~F+-A3rB_=!o8af@aIGJcPDNY% zZt0e+r!B3!1US~dbPSs>oLjV@8-Am523`|P>#)*;G&Ys>PL0%cQFIo-W_D#TsjwxYrdMntc%mv zVhkET3LNq)^|OX#gTL3h*>xKKl#hK0V}lRax`Oz*xP2+U4!ay@nl-24uhsV43r*;3{Xf|wQ&|7_ zu{PGocaCkw+_XuK14kqLu7kM|UDfao*axqm9aok{(3=gmlHTXa*F#^Y!`R+r?0mx( zb+zt&*m)|~?q&SY%I6rDY z3w)aXw5ReGv~KV#C-Tj_7`Xxhlgy8n7Mc2Aawl7V`cAf3F)@&|8KJ#9poiCq7hO(! zZ!WgR40&X(J?PLR7!epzBx2K~@VEn{c`_nOZ=!>P@>yNEIWD2R?K__g0tOS{;vW2?Qs zfrZ>lfL%H8_Dr+=B5ZTThJM92eZZ#2NM#hq^4RtJxsUOFtMA6Y_>mg5GH+NkG?lpb#hpLP%Lw45Mdb!@~3<$KcUb%R~$Y(txznSxl zJiP8R@?kExN%_8+yVLW1A83X*vIgz84Y{*FXvR1@W6y()cR#Ti^e;H1$LU*pa{87| z`ZJ9a81QMXH~2H>;n#fKm)nD$JwvnzpQu~q^9|e&9cr)ZHs*ZQ%c<>J7sOZ&ie^Ql z*HPXB{fpK|u^G-|ZK&O+$thc5ir8cK4474mbl1qfBA;iS)`MZKXiYp#**_c4x_Vzx zXVFAwj|2W*{q}cec3kL9{|2< z_u2Dg-)$eU<%RMpai1@3tO5_W<6qC5>$TK*llSr^!}IL5^nQM;?@XIW_?LbY`n&XK zMsA5$l@t*iJi zf3ydmax?ANdPCY5E*nvHV>`N7DRzzIiK}k~DVuzP`?&BqYMhcIS1P80JDl|YES{h8 zkvn@&j*sVt(cpDqTOKH1tgENU$LsivPQ`uBrH$lVPgH$n@tgZq(XYp`25hnJr+ zUVC3t^=NNX7+TeQ+pxlGtVr?bLB`PGg=Q&J63OY?zr1wxHp+s>y3=*+6Dzmd^PBbs zM%mMROnY|qjKzuVRUX)By&osPa-8<=rk!KxiJOq&LwDV|-;PU!zg8_R86}p9GfPe^ z(Nu7*F@+e9>QyaYFuH`gYO9vEG+%G;P{8@oU$eiB%tiLz5~K~i`!((A z`5k*~S|2pa1Nre^n@aI=+1BpZ+_mi90eWKPj`1-<`-*!a9-e!=Xh-<@3cC6$;_TyRtp4^2;8byRlgI z2XYY~o456g(6r`2bnNPgUt_#_=H|T|_hC;#muAg`s7v2{Zas9$zAF60)!@XVJ2L1; zJZUSsxcJdl_>o&ydbidt^;s{c=2>xH8ozWz=~L5avwyO6WEXAvY12cS5!y7!6zx;S zXw${56AS;eJx;a%DSW{42mb{=7qCpL?7bNK%eR}N1)`y9`o4lPx??$kZSpYtIyu-U zVeS>O&~ajm(6Ri1OBZntM`!l6hsqfnTegTl0Q+-+DdrMnaeW-Ur=RkHot{gA$iV^e zhD5i@0h zm7}vLuPw0h(61ObILx7&FU%a>Av>}*Q=qvVU%KAzyeEj>5w;H*~Tyv^JS9QCEZFparQ zVY^1~LoO*b(R4dg-F6!B`<0~R@AJ0ER~bq<{yzD(1lJ0_uK;F0`mTIDS-R<~8ei0E#`P;-(Sjs< zj;dF3@0FvJ9g{z+idadD@mq@NU1-K$piMvdskLs-1^1sP=C`|$v)!EEPC{2zvLDIU zrDsFrh>;ASjh3sxZwPtOUFh7Mx(0aq`5gmZi+l@7-}}m9Hhf>*VCzb`&OV#^yV5hI zJUqoY6E7M$?JR-@tyJ;}&2GiEN(_0T-TBy9*b>mS zjsFtxKi6MW{sg|>3iSIhG>q#NAGLb{?S|l6&vDMjPy4&hxpIHV_5oUtyKt1ad<}iM z-S#`5^8afrqC?FY_6V^P_WoX<{>!HrXRf8E?S+1um@mDrx$PX{xjbd8X}cV}F3s~Z z?*w74@pb9!(1J8Sz+Y|iYSDu00|CdaG1_kQI=rfpxfidR&U_LVm2dlI!8P$|eL9b! zF^LDQF=y4$Z~pJ6uwR?zMI9Nus2loFSz>u?IT7N2m_EX^?O}hDd~SA3&TY&^y8cwR zzUD^zpVjc6+tdEF1bDK2`{*@}Ptft(3TKL+d69MMj;Rzph3!35e~ZOD227Uy-6Vf0 z>qb0Ea!YGRG$&kZ-6<|bYhC`?9M*^Mj~bNkwoB}K-_7v74%%*qHXCSnQBn1Z&=hjL z+5E_9S9{)Sx1II*0=%mo+G+vE?mVZ^4QiHK8>H`?+83AK_|(39H}L6QC`~)^JA?1R zm!_CP`@S)FuHX(p8%r6ZbmJPH(dYJBVknx4-zbGXJbbI)>U6Ax=t<*L`C94C-1n|} zHxXCfWjrG`Ef8OB?X@Zm7ob~vu?wiLzF5b^i`1;RhyLW_l3nil!?3ej$ZdBl!Z@q( z3xr<#?1pOAzUsMsu6^qit=GV7g*v9eKY}YD16qEHu+F;9olyFriXs3_=$JF zn@V$~7&FC*NmqC4Y^IKz=K^~)AAFX%dYAY{2lL6DPkok6E7^AJZ#6X14^D$T^E}pG z9ARD?pqF;$qdhHWnusk>TN+ym_@x7AF8f(aL&R3tydNG=^Bj4JGUl|=o>Tw$oJtSY zobF^!GyR(K|KSsVY?-$zx+%XZD*7v8oi(w3oR~*vf4d19bJl_r4|l@qMU<;VOy>tIZb^BQ+N4Zv+NZ+02*5oe8RO!hdKV~xp^w)c~#jY0NzChX|U zWzsjHgJ+?|XH9HkG-OSTzJ_mEF-Z8^*FS73qI)=06~-S%e8S!?{NLU5um9fT{=JKU z*Ijc%&~tjswh!tx{0xdEvhR#wZy@I9j`wu@nc&z>yRvU;B8%V?ic{y#KH%9(JC@-d z6Xa9iI&%r%_FZ?1r=5duEj_+V#HV83$c94X#++%6O|Lo9`*1eCZtglXt2{GB|KV(B zZ!4SmQJstaJ+)bKSnn?2cjnyC-*z|VEr}j{H0@sy-E;PS^H5GrPnf$DE$&eat_qTu z^X847Rqc!QUV3s9F?UYfuj5D1bIsR#wfPI$Ob}nRgmH(l$usM^>*MG66ZE@;_H$3W zkB#+WkC{YI__ueZZO5N+cG#4uZ{c`okBO$*ygkWUeS3O_vBgtuK4M$^J+CUJB*(cI z#^xLTNU;MicP^&;5>AsD?Yr;QHhRzIY{39*XItWsf0x&2y+>;IE2W&psZ=adgnO}`Tv_0hW z>wL(d?j;ZBMh1s{S@zz^WbmVPcUBsoI`8&g^&%PdOuAlPMm_hue`bH~+$6x~(bt{6 zPX5N>h^FLBlQ*WBCv0(Z!F-mNG8Yb7aK3 z+zVnk^q8>gIXp(|3*N&%wkZqdN9ypYBOCjBwiTSBi{~=RtDMS9C$%V#B0>4|87P}Z z(5cxY45Dv@v#l>Ql~jL0u#0ZO*bia$0AqZkhxTRiYk1sXBfd*_oyZ=hBu%`Ol=wf1dIo_CBgEG>3Ds zqxo*#%i7#cVx1amLbT?p#)?LAN0r?3|4{bs@ljP*|NlNSxk17$Tm{O^Btao+RRo1r zo5_HR#&K4k)eC~6_8w$k>? zBp|nF6+{ID=lA}cnF9m0zwh(?V_s*@Is5Fr_S$Q&y>5H$oO-|Uy!+MwGvu%Q4kUiq zbnSd*(fLhw-}4yf3H-jv?*@Li@%uTy`}t{o`aCg4f=j%ji*_dRT<09fW-Nc1)@sf= z8Z>((`k;$_E!Y5;bCs@(4uo%=JXP5JK0b(TmYA0iwn51yqK$vG^PaZvF8>Pj9?_Lx ziLbqJ84doN4}X75AI3jybnILusA3xIU@NxbDrEi5>ApU!-q?d-T*U zLmsJ|>E)Blhqy9*T)pEWOi7ciITJ)5QoGJNul~S~w`q(FH1G=NG=Z0c@jg2;$LVc+@w_*PDnX)_TLW19uX?u z{|5d>*K?OfFUD4pH6+_J&|f7v$8y(nG6tO=mdDSLj4I}kxsby*h5Y`;cZ;#%^nv!q z^B-Po%Qcd7#&TbS-U{Dt$9~hP^*qk2-$=-%r_o55y#x)jk$I2D>8YxS`BEeO0)M;u zVl_{mqBf-uWhmAJ|9e?Jx-#p+jg(=%50_)VD&zmWl&g;Vh&vG7D#qo;{^n>!j%hC^ zE~iX+Ju*-2Ulx94JCNP2XGCaGz?iL~ZQ0xIR@*Bk+q_8jmw28L6TT!EXt~%Fwh%+N z{au&qVZ$Ep&s@XmM`VrI`c4kHhS@7wl4}}-monn^maz}A=0S6$>|uXPko#u#nBB@= zuGudC(ZR$lO`#3>EPTWLN4sZDKC+`67?E9^xX8FoJ8r+y+93E`quC?McocHRpZ`w( z-P3OO-wjRFj}Jdtyvc30y@n5U@ARvVuDmdheYC{B@q6ge=kMw}u(?zEwzZJ`2}9y% z{XL7mk%OOVmvLSo*)THb<&o51N7V1AZ3rB_Fg-^aM>bDB+yFx5wB^HhPmEu^a(pgZXtfmhQx84_jc*whk6VJzc1~rzMP^>4wNP|* z5Ax^t_<6|P6=IG%I#qICa#DyP0GIF3S3Nh;f(FGoLRTmKvyM$y^rLfS|H9aOKI?X2*#et$LLIS#bnEqwPe!+4i|C$R=BeL6dByG0|1=UivRPyL@H^ zR=Rc~m zcY@S$WN_0B@OJxrSTA)2sl#jhQ`XNNbyo*_IXn?;JJ9A|ILoHnmEGWrc^9hJve+m^~wJlEQgO-N7ky|KeLV^lWZ4W&g6r?rR)&ipZRCl<_{gO0 zGs`_lx#~P}u|oetE?!(b@!}PCjR$9f`KWNcF^=nMGqf@Xe67!i-yw7CnEYsQGcnV` zbK|b&yF6y-teOGnsoD2&=qtLWObDI6f1N2q7s`XRqrI8Ym8ksU0S_C=nvZybJIUXV}(zk zcoSF7$Mm!#)h@dJm=F0yG*2FyRqU(5hZNt# zncR*^YDc_Y-!C9fwL?etnDcBOc)A`M!U$!z`66uw!L$7yJlEvBH?nrZ4Ra;es-21W z3X|J8vp>W*EE3J%aL#*;l6%#UrAc)Xf0nQDb7d(X!I0jHM7@vYyw|8XJkfAYQDP2z z=|e>VSBiI0jKb&CeSV_e2dLMZ1Zy&!kM$oqDjCjD5I$h4(`<`Vk(OQ#}9;a3DE{9%6kk^*;q~1_0N$nZ5 ze1v`Be7E&X+Feboy>O{{eJ1@karUm@RJ|eUHHI^?&X9Nhi|^L}lgeJrJZwn7EEog8 zodeuwezTBo)}gOFm?#@TK2v|`OSbSuzU=T@T+jTjmhW5({VneElvm%i)ym4U_a@fy zANsGyCfPeJGOsb^$8q1tN!>4Dt8fpzw#7T34B|T?~q%E5XZ>8c{H=7obqv9y1k`>IDMDaHrB=)M_fgWsB-05;V5}T z>gJRmsh(#JihpzFTm%02HbYtYOi$eNnk}bG233rjgEPsZiZ7Ke5`Zorq>h`32ao$F zL!I!NMH`XtWP9vDF5O6f(xIE5hx{$?n4$H==WmT5tIp%w3ZtAUDZR)BB{v~M$`>PA zl`luKWjQ!S=c?EGPzqk39$EO`A!6jsmrb3+s6QH8eTcE`v@VR>3$(VM#l7-~By#p6 z_i`RjHs_#0L)F8~!4ZS&8Ux6UGYxq?k!PmT#tN6azIr4&Echkg2lid^IssF)%j%s% zd9^S3LA>+}uHqBNxQeDcZgcQeVx(>y5kj(jTh>pw&8mFVrnvBP82cw6A?u!jD<2L?4bE zd~zPdZ6M)g(6a2!UUHKUB!hiK~?<|b5Qo3YVz6#;guoE)Bqd*Csk)zRY77(eEv-Is3GAh`V%Eq>sXZrgBS!*8JpyI;Y~hL1MIb=x4YyMe)d9KRduxiP~VYFM)(F5l8U zXO!7>47-x>63T4YnRpjlcxPXwMf!DI6M7VZU8sK1%`=fpde+s=Po|CD&9 ze)yU*8@{EnIm#YW<}LeB7Jm9W|ItrrYgs^kgCXHd)YmLCu?@Xad%wZMHuD_&4>IVx zV2orotN}*Z&iw2Z)EwF2L2qDx<_+Zc(LDKrGlV1$7|xgQU~?Fn{&?eZY-+Nj6>@fW zfLu0*J|NbdIlpwD)S}Olxm!$u}z}O>Tx3m48$IKQB3ve#i5k zii_*JS6B@J=D)rxebt)sa9_?;rM@6EXYeh(i|UqGxk0{@htHr;2)y>0a0S||4hykXTG?3?)+DK|D9;h&>nDp zY$6xha64_9EPqQgc~zUi^L<~@rrHzEbp76k#Ardcnxn#*=H`ie{$le_z1O(7Is4MZ z8JIV|;$KvD;ViZ@J|2U$3j@S}fO@@1hXA3$JJRxoe9MLfE2f=lv zS>~X04cYGeI$v*unGy0vMtW0^T$+O};=k8DGxgSanb6b>`jGFzt}&hcTmj>nF>ORj zejd4PyySJl)-Z#-5LLIbj;HYaRTt06iQ_zf4jk&a#_>jE648X_P>}zp*@0tAk+l?q zWA#-z(S$$ojm9*X_^-9*d(2nq&$ZYx4YG@59j!I|x9zaa;7EM7;J8&ao>)EcX=hKO z0XGKR_@V29QD~>f{Z}!iAv zn>91{yeqp@X5Ou%CR9*H<${!(M!Bh!E6gxcq^pj-H+yE@v}?PZ@+)s2Jz*YYoo_;X za|Pc_56M3y9@uGc8`PX)o~eoctwT;9VH zy|nu&&o#b|?bWtN)W!LA``@7d1bkX|7hr$#+CJyFO+)jPHGtUVA>lu&EdGns%}!iG z+;)2FpoIN{ao;CfC_0<^rv4mb44tuw5c4Acj@#50m$4T3$myps6}=ezTKeXnN#&J; zC5^hOQ~XtW*Fg-FU?)$s&fpIBOtl*7p%cwbe~R|9q}h8&GROz@41 zrcWD&)NloGtBeCjkoyYaL5_bESZ(+Q8{T(&$oXG=zqR_y&!?@f9z*_Ra`xZ*pJuh4 zpCB8XBm3zpMq)#&Kvw?-@l5Pdy8joh)zu?Imp=`j;&S&}HeVP1#G8}%h}-f^+)tIy z{AW$!oNsV7g7qW9xkKiuP-pfHaeG)Z_pYxA-ca$?2jb&h#yZ@OvPNeV(dT8vX!p^# ztoQPJH$w}B^d%>OgOH2$dLFV5ysz9vEZ_!rgF6I0K&NGorndZiu4xZ0GVM9mh)eZt z&o0yO9QBmdK5XL&7~A~FI4T28z6kCXxG{c@6LdUHvBLc>(Y>vyK$} z-Kr{s2bk&j6kOW>5#Lkb=wbF)Q1%miaoTHApmA}HoM0_bc`JXmD>PCz0r8%(#9wO< zoo|knZLj^zmy`=#4T@U}fzt;P@xj1o#|N)8t_dUfU&H$Xcy$f%)Uvm@hM23qi82GZ z#^c)Kb0916+{L&4FF!{Ih{x?KKFm@KU;?-Hes|1+tZHk4ySc84I92pir zu6@Q|bUnGs7s-ciRR+O(A^Kt&@}3`FulUxjviE?iGUAmJ_V#S~TPn_$?1%ih-ZTi7 zpeazkw1d*GRA*clas2mjUy7OIZ-1NfAH+w;+3_swDTZzxKI+)-<204Xb!g9XSGb#a zU2DM=Ej{NTpUw&42QdeCr8hOAQ*PZg-`!HjIy?@adN;f+kidBZbxX#nrtJCfy>5?S zb`w|W?ycpXT+v&D4_?83dv`r^s|7g>J!)vUAN|{xfG?D&Lw2_^=79@4q(R>_#2NUM z_mQ^vuKm<}++_1D3p_sN8d_f_c^cjn;@olN(GU)feWvj_wFiY*orV(XiTfv9;p-WH z=^@eshU2R-_@KHo*}IHRA1zJc>Z+ID!8QL1?c;H%g)6qxmfbBV`sP6 zb0RoB#H0DJMO$v-#`$Kw_Bue5LG<1Tb<4-eT*nq-ZB>0`$mQmq5OI~tE5JS!@N2;j zz6nkwH^tE3qu2>-xQ@rxR#1-z-0sJI)`kt&GUVIt??2iKubGW}ul{R1B!eQqEfoI< z!TS_jQD%rs&LeL>>z~iM@X{b-+*9!{e6*E*WCs@-_NXa8NeaFXcX&1HxWgYD`PPXc zbS?0=r+`D~dWIW3;+x917MS)X;B01oyYsY9*IplN8V)kXIv+!4Wf*JVrDYFa(P9Qp zTP&Ye*@I4;VLV#Y0J&Jkn;%jFs=p1O-s&bb95>+ zmz&|T^VxI8q+L(0G3{|5XRe!m(D$kE`U-e`oX@)}gFF*2)LJ!lA$!K~nY+?HD zM&O@@Ka=uP@ORlU%gRand+d~F($Vg}+c`jwN>(XEPPllYIXc*wqkDNS7?W);9?qwT zZM1V$7k`QVxZ8d6Q}46SDfWd~4c+D!V1L_>UQmnf6YvsC0m-?AQ+lp%jBt~&~UuaJ)GcIEf5xBmfRxt#CyE`*K(EDc&?v~GQ9 z7SuCE`O#4y3^U(IS;CnawxaUR2+IiYlzv>j;Pu#QYq&-P8s^h1ZokM5f*!ij{ zul4r(wE2S(?3Lh5c?-KAu(rxKqBV2`w2W^jJA_W{MX%Jn844|6lgO{(Sq;x*bEHhM zA3F*CpP20PyBhnGhx0va7{5R|@ksnH#7`$X1T@%rD|@2@e5d+rN?GT4A4GQ(&Elht z(=53R#FN2yWsqkz$e}gZ+togJ-mY8+-k%(~wCyKfeg2K?A1ChFY4drtyG!$gZ`H3L zHrj3U$q#HgQ;)rz4T9g~F@E?ruV(B7r@p%!Sk-0*c+vf|#C>x4anNjtehB_3;NkjCeOCK5v=9G7e6VQW)JJ(=`W!Jw z)L)1ntN?%20>+R%hEqH`BbDcqiIv&)E$MH=!@q?7&ys!(yvR_+dB7Z?tUuE<$o8x{ zL_5E98T*XHIU`N`{rCG_y1Dyp1_@^kR24^}qsJwK0cdi*!4XwXL+wUjZ zA_qqPTH2G3JjfbmB9rU&OfXk)j>}Zym|WO0DyTzwu~b(o_){PHFrKkW(_Y$YRr#5# zU+}*4Ijd?PG+8>`s(PJgS6*w)@B#BVc?&xCW;P%4c{eu}nDhf3(4NYA@!OR)Bgb-W zBBzwjWQta@W`Dq519ERXbR9efooPN-`6^}m7CiAiGO6K}z|sv2F>-yxrm}~RGNl(< zRm3EY)U#5TX*NEe>q#T8@P~P z`U>s>V2i>tN`cGtiNmPA>;OlFLl_sY_~pMXd@=ESGBo;0jPo->o%3hb zc6N9>>~ZsPKBddMptHcZ4%G0!>`?k8!b|CkR+aq@d)6rYJi!E>)=;P3JNGXo?%@O3 z-N2%;Uq)^{SK>P_&oy4pQO+;f02n>OE3~Bjx~WbuQrX8-~Z@8$VWsz zMGw!PhqgQ!<{~e!Q0Lv8IWydT$NCh4oHjK#3q)hufW}` z@rO<&TWOtA{V#GA-j2ejBKVYa7GsqBMA4tRIro8an8|<6IFE96UTiiz0Gv8&5k8O8 z)^CgUySJ20?!a%dYyQ;vnMybMfm&!IbLi$W|-ga$-Sy21ph-%{e8%T zj0jIfj%bbUBnE!Y+3Pmb$DPF2b!%Ml0l<%D8TugJ>poiD#rQYu8VD)$ zwp`w-`lIw`->9ovOu;o5iDlwq1L9L)i}{=7a<5 zG-y?I3C`Drr|)FK7x_W)gKlPchBR-yflP{{9_If;hTE0WS z)vw41)4Hz!xM;h_b9IZ>Px1CWE8@5x==5jBRW0xH&W4@u&=+iZXAd|^r_3Jk_fzD*=W6>@x+$yn1~-YNXk zZ`J*~e^Ynp#aPLHz75cS><9MwUU^)_cYXZOSu4HBnn9CNB$y5LNv8`S7p3sS{#Y+r zM*3@MLcewDsm8Wf$hV2_Z2wIT=YN%QM$0H{h?GWgt)wCI*P5EJfWr+Hr zw$-NK(K{EqAni<{U7ZIrN${5wL#t={E*HLa1#OF3|+0Otl{ zZN>K68-b65&!crFy}p98)v6;_{A@MhoVFF)v7EUsy-IMfPEVmv_+H{?Tz+FWJnsxW zUoeQ)lkJvH-X-+eu#dXRu4+_CM8H) z;=S@c%TKsLXHNOQ))1>R+l9xUaMsk9oFz4t__7>i)PVff*yYZqZD$;>px%w%4UIj# ztDSSx5n{K(b#unrZ3|Z_SBkBo85-~!vo(`(lAJI7>|bCbjwu1#r9sL9hi#jXT~IvG z&~Dt{bNar8W!R{+Hwb;bUiRnN+W*Q&Si0VkT#|RcZ_mO`DSlc%+ zu7cAX4=j$;X58Lp@7;hlHLlcA{4#Oi(NTdG`4V!lFYT%`6UhA@zKK0i#-xe%mn#0J z(2UNmNU;V5j$hGLM$BjpS^w5E2Yuk<6wNrc@KZU>CZZF`jv`w5%$<@io9|TO_{*@F z93iLuC)hTBKwf*DA2kl&1@UC)#;$ywKl=!{pM32PZ5A&epHSY2Y2Tpkl7hq5 zMa}Z%3=WMH{vxa)PJCM2zP!n{f#_^8qxnlNKDFP?I1Z=&C|9+wcGbSxR=ci5+fOFi z-bJ|r_A5gVb{-p29xBIOj*di+vDyeonc6m&Z+ZF!^SYr}9JSLl!(}8FbVQ zP1bPT3H^05KI_Sw03NC|?#=jb_&=yQamGY7>%|+4_kQ{^-nzSMKHte6*X`~DTzzf( zCu>k=%%oLzv))e(F>m-ER5d3*L3UGr+;xJZT@e z(YixCFnu}o{rk2~W&W&h>-K2u5_WBT-G{+x+y-rhFJxYO{Mhl3&6>flXwB&td>ETE zK0J6&O(Opae87G`&(6D|y&FSpxk7nY<}j|ZX~WY`%l}kHJt{Xe(Ux#rN4*u~b0{O9 z(qi~Y;ydy^89Q%Kh&IIWP zHs?cS`VWr0^a)0~Q2AqYX2MYOVsUl?mZKFq$2^`dOMIlUk9lmjdxSkdn3u(vRFG2! zhu_7f>Ej+B6#Cm);YF1DtG$K|=FB?tn4j^O1O98{xF$bMyw5@IS!*Z#JaI3b;NDl_ z{vzlvd$KX@+IJ%UAzy>8iG3(1d5aSh>+o&ikhQA4nm)3Q60d4z2-g}L%{AdW1is_= zjQbi(6K$3w+o|0g+I9SlDkuITzHXw0?K)QkJXb>JCGk2=(dlf;%?_m5{*r5`Q*j$z)C~o6p9N0GZpHsi+U>z|1lK-~-eV5jSk+uzu`Bn0|F%Z08GG=nJMb zROf^)@2*BitFzi;Q#tSZOS8KAON$>d7sO|L))`y!^ghWYq0V0ZcXwG0UdH}HV2sm5 zsMxzPt>K2Gd+j0mp8B#O6?su~6Dr0KvBb~5GjrZ|+M}$?Pv*L}Pfz4ks3A8mIPT?n z$zaN|2lD7=d>bN9qHO&EXki!6guilr!dvb>*5Y06hLR7|mk?*yu&xtN{g-ahQ~Ydu zKfc&Q!2H`xbTz(H-J?S3_8F4UC^FZG@P7i=yTpJaM-AHP8r|!K7esRKH=>)!7p*!L(h5eaC8}!k$h#COZK>4mu8QH7kJdxWN`8a#$hyVdZJdlY-mrCyD?XI zAvf-I_H{(K$vrr}XPS7)TyPA&ItBMG#$+R~laFM$bgH3t`C;KE*I-+oSwr2zjrfS( zUv|d(X5N27Y?H?3AXkmWN4owpzNSU^{~g9vwDAD#B(L30jOK}Z-m}+m<%LwPhk(_5 zNOYH>+y^$F)>&G{&T}FByuus^;$xDGBwxFDcm$pD=lFOT+li8K$0dB{;**S9=OEzk zW^CzuvChunOs;&%0iw6mx8l5^hxg96wb=C4mcB<1{CyOgO+4n4^Xn6NB^6tRZHcui zex|+XkNS!^%k43Zp~kfVSe-d)3rx!kcg=8B}t6TiS1S zXg)Y|_QXB$IIpZ>^EzV{=WRWaI4f9l6g?q79y_A7$+GnbtqY008O6IRh?iJ0fY4L* zdssLhSasHwCjy_Nj}hSPNa|k{I5z-`XhU#{2f^by72mJ*_tTB82ITul&r8?3RF!TWi#U}`!v?O>W)2a^m==NloggXZ&*^(#H zreabb`ysK1@Oa%Pw{4ImliRlUCO{|N5m(P$zIm(bs4%>5O0Orbrst35H4-5!~H_c~GK~=+FTr$$4Oa!}z&kQdz{!tBm{ZACX4}LQkk3RfT_@)1~ zY~3}ppS`QejIN9ffu|!+uEZX2Wxv-(dNW=dY0XO?WlYAo%@NPKkhyy9Jao4$z+MB5 zj88R#Mx0}2j2~~Z#hbe8q`M6ZFGWUX3Fs892N{DW*@y9j#+LW~L$VzWlP#xnD&s63 z=bv`ulJ@raIma7qdu-ypG40==j3L%v`=~T-p55jGW6H;vjOsDJWDIrAvgW?S4{W(P zu}2JkP_Ok&-~FAO=Fw5?Ss*Vi4C*m!bn|zjqav3-+Rr?uc#kJ&A6a-?fPEOz+{opH z$RVt??S<5U<@~x}k zz7GAx-0L2t`}??e+qvT6_jl{QXWq&8KiB=A7Z&HT<~rZp$un#Eg5qxL6irp?*|tT+ z*5FgmZsytN%#SAKs23ZCXzo{BpJeT>$#;PV^&UrJlVD&#jM-cRD$ZxhcN6VKjtjV`)?=Ngk! z_5WhsUA%YddJWxb0pB5;O#HFvZb#0#5$@qdpOb3@x*Ig>n)=Sn#z%{14Q@S@;eVp@ zK3_#KlkFXMf0yEf z&OtUp1})w^E4c2NpQY7v7HPixUVWhTO~Qb!I%EvQCY43>Y@xq%o zQ_o859>-Z{`p}Nz$n9JMT=!9~939c~Yf~k^VPwZI*Lh54Wd?lZp>#9jo?n_7K44Va z;*XybBdGp;o^P_7nAZ`?ZP;g!9G1utIC@~R&dCIvaJ743&e9Yh+vjX?%$`J4Dxj~-$Dd*nbb)+vm zfpVhxwfN_v=P?UPC0&UwumG}1k|D8?Q@wfPUe6`d}o%u#4ve81FvR^`Kt#Wy%F z$VY0fH|#Lo=;&oM{YL;EAaLOtK-{}}NP>}GoR<+-8D z5AtlLaHHpxso>uJPycoQO8>|h3cg}Zvu97<*E-@r!>Dm0UzX6Ts_-mik=WY zfMW?TsUKy~o4Lmj3vMdEO?%?0=xKI*qIj#~W+R?Ie&=!^PYMVI2v9F?=0E0*cp{JKSJ}bU3rtPqmKC29PUE=Dszi~a%z(;eT%*vbaIbPv@Oi3S|H|9A z_n3i|Wx_Lb=#*m|lFALLl+I+&#XZc)wWcbI{NLlDqXW>>K6unU?hMWvA15>RJ5r9Z_&j4hgtj!ten;+4&He4{oeX|x`wE!9r{230^mn7`gs(59 zyj_R?f`-}PQFXX7&0}A(?grV%HfN(XsM>)MuA_+^Q=3>cvnWs)U=}N|F_l)`?s37B@%H}PJ&Ypji*e0h_pZGv74QSL6_z z_HFv&T@*TYQ{E)zgxa(i-+J0)9ljvyitDRe>9b>B?j^=e?JeWGDWxN3HtG7^Av6C= z*MfmFm+E>!*32ikI={Uq)gE8%_$xn%lNe#$+bnK7Pqd)9v^$}dQ+ zYr8D6!IZ>$e4O>z8D|`I_Bi)44#8S{Y zrjg6BgFCW?Edt;(xQU82zt{f>2D(cp~1_0=1Ez2{L!1Y24425;{*AH_

w2giTL1286ir)-u~)-sonR9dC;DfL>vVEm!a5*r!v-q?Hn2!2d($F9gEu{ z6))2Bf@xQ*`7)iAo6O&dqjc7vlkLw#_JF>U%SB)PigMIf2%RUFGiQ`@*5OHvM`UA6 z@h$e+;;dKEji++1S)sLyTq)L!?&|0v_)paxyesAU99Li7hQ_ju>6ftHHpuSv81is8 zb?!sX{qrl-`xP|M*_v#di}RYP@RFJM(PW!j$G0Wq^jStd@ACgJ=kRp!zl;B>FW45B zTTFsKKFf1`C)*r6X<^+54-~scn7g0B_ffiK>WSL79x3kG^!#1OEbX4jW{P62nX@Ne zMK65tiT1mgUlXsm_nD-laAYnx!P(y%3rHg}%@FK5Od_@Dl-V)+t+rK|6fZ2viN9S%KW z^9ldLShgIcdu*a5ctqx^zdx1H=C5qUBF$-8KY$`@Uw`Rh}!gJk2 zGhOK;GcA4r@HZ9yy^B2#H_k<0nOnYY4e_+|fV+a9_@82HM`Zjk)4(6gZkV$!I5)WN z7toE5_eNz{S4)8>2f64;w`*(DRA2Aw(lL#d8N4PqIS9WCvNzX7KMZt{%-@{(@5DC5 zrkd>*d1DMYW4hg;%Xblzn?t@DV{UnhcMTQfnyDaHOn@AHh2)0uk{iYzyTmt6+2nWM z8olg0C+=C-BL3^d4rnYjj$hWfR!eXEtn`YO!{5KMWh61U$aj;v>jJCO;niBRR$|LZ zhWW%jTWsI(C(83PJaYLQa@fo!x6L_P?+tP<`E72@_Rp1#Za4A7%0si0pJG&g{;lg? zRz8|1=#$ojOAD@hImW%#PBi?>NqQ1rpI8&l1E1a0|0%Y%NMFv;27cMByD6jCkiRiD z7XvFib)oXzXieV1JLTO`&YhP9SC2JgH*3@nlgbHZmHiWChf{VKWp`6{UeubQoJxD3 z5&7|!F(Ix z9+VyYmdUQhFZ3bwH3GU5ZiS1X;8A#$o!JLId=Yeqs5MCMRnA9$oiYsx{MA!d-wLM- z1mE!iRsC33tm7Yb7C>+EF?f!6?6%Ai5BwCJ8~cf#b5ad*sg>ywU4H*(q#v#xBRcdP z8E{dhX#e7_@2^JaW?cl-;M{1 z&8O|5l^@E!rSUcs8Wy{T7HJ&+gZ@Ycs&);vZ7I%sz58C`-HO<+vlqMEnJP28^EV@- z)Q2rFvpcr8Q*+XyA7#*{=7NtoHJ$GiXAoZl<1sjDL$QHv(`XA;_xj0&B(uli)P~KSUcP8IfNZU`1B%&eJMN< zTX^Q)W9_Ns%;!tW$lDMcgAIf@nh3bBI9o9j@i-WZz6DLeyJfZh7QGKbuNC>)14_;S z%H&YLVq!mr4z*WaX9Qh}-X+;IIaXG%B)9(z@fm}On|3od(9xI7=KNCCN1S^bGUYbI z`PE%KZzk8W&aK|2xJug5z1kWc_@#KYJu%zwRuRP0Pn~Y(LlHXV`1L z{gQHM-9CZ2Ou)0UUzU(|%G9mS7IO!e<8Ce}6H zXuiFud5D>4-0+Sh{7hqy5NmIP>LU*uFsJsG@m&qLm7Q|h7}ILE@wIpxp-afo!k(27 z^VDZPUNrW2-*7YY6h1g4#GcyPv8K(k`h~kMRPG*czCVCmN4-t51M;nK@$Ip|m1?#t z7PWxB9dP##?}AUSNPB$k8;q;)c*h8G0rWF%-KI~t+rxZJ!G8_j9J~hckEpIJ+O%;x z`Tq&0^*+;Ym3z{H#=1|N)~NrimGqsn9w&;g`4`=I`kaIWb*Lr8X4mGWTZWk%;wqlEaQQa`pdgH*zj(HcF84p7Lsn9^P zNhzxEzS`(zyonJH`}kjK(t5A;niBHe6-lmKNtxWdR~voaP3&)lPHY-+hf}%7M;3PT zPV1NfPofV$b5P^yV$O(fY5Xj7qx|8t4;_dGJBl?$B#kmo7RFG&brMA&)~UDkG-);dGJ%=pIm&MA!tr}W15*uhX10|z+{tMmesNN19#v2 ziY-&S#z1FRQhq0Jb2^7j4?Cf);DzKYwfd~;VeY0|>5aP>v+k7F7Z>!YKGckFQ1}oY zI^-isqW_1Pt7=R2s~y2naeQi*y+=npnmmPqL3}E3Syui{ulQf~Li<-B^NH@G$hzV= zAK+gVkJ6mX0YBy7ufoiDsRtQMb;xfbx=z)+0{`|n418dum*<)<#51-o``Ew8yna`g z?{EhD8%(kL#MCa?v9xz7Cp$i`%Dx_-SJ6qQ=9TyCINra_{J-j=$Jc0H3FpH7)qMYO zPpnNmRCD$ktFY^eDc8Lu*d1OUO%GlEpZMUS>5Rg6MXGg`^iZQZF{|u#+~TG%8^zB4Kv1r^!~$hnfJ$uuV(KH z{GcidowztFHUNDa_OH;!LG}_r({`Lx_*!6h=B0AV8t@SxhrZ#Ies3*KWh~SuXDl=h zg4yABjmD{t`W@bsn8Ozp{Y`s9Oi_jv=hYv;TOD4#0$#lu zUcDY(jSk*u@n1aJ@SHt*;l0Le^)dJD7<>4c_}bKH)7r(f-OOCV?iY45pX$;dU#qzu z+8JxBgf2BU)mHGJ`l2-~)f9GVeAFKY=344-#_$Tp(0OlxKdmG1>ME@tv>zTn@S(;i z{KQ^U68QWn-#dL}9nd_z=war?Yo0j0IP>&>C?|TX9Zs$#_<@zf-h#fSRdWm28?_%o z^FDX%<7>T^J52mk>u%Q5%CsL`V$h4lb1JDrIM=+omH)}SV?Fm8(1`Hwa&9GJSfR;e~g?Z+?0f=iD<#Ea)J>9NA|UuG!68eby{ot6W3n zywf}k98M4C9^SCHkbB=@crRr}Q7`<=?nB+SCh$+(F3+yn8*8)3QRl-B=|xWcCeLyb z^fC7Du{MVgCoi8F>LcDws=)>jkzctV~3pllg*p}b2L`PvHLTM_KSA@EtiI`6}N zGRv4Y-4CPedFl(_iLSf3|KHL?0cG`_>gnQtGTmnp-}e7N_pGPlz3eTSIKQ!OQ`1`6 zUy|X_TtSWrpFi+djhZi-L-BRYgf9l?!k_i->G*SS_dKwPhT`)o!=6{}o;dE$Re$M& z`XOHXTkaj)`OxcxyC^b(@Kys4ne|z$RsBuI*<*aO$ELY7JNCAT^;c^~4=|OHw+#En zX>>R4!zDJ|?fqcMTG5-{h7*p^)LCEpJ%}xu~xO? z@QqJ-rg^CTil5}R#^)lsIr9!(<1d;c23}x=C)2kB`(ieo3m%8Y$5HmDDW>gX_UvZ> z--o~_n9k#@F7?@BZSbgVc)ivOAHN&3USDj$y?CtFjj@xjd-(yY@MVXOW~3}!D?Iw) zNdusfWIm(1KcGI*Lx6UDpT^pN-(K&JfxC3~!nJpP9%~g}%1B+fR^RFy@u>g9cNw27 zS);L0{Xy2m@t>St{~d`jjH2^@_t5F@un7*o?$gtsz0R}O;C*H2c*!)i8(7qCG4*}) zQLJ_E(IsniFZ~`rZ|mL@OV)OS13hb_jy{ZYH+$P&;=SFsf5uw%ZvH;#n`gHiUt;&g z;kSAh;CZtABpD(D-Kr0|m2|y4{*UEt6q&^WOSd*SL(0jn)J1r7Q2tSn3OH zUA!JyQ#Qp8W3B3gtlxZL;j!t+p2XRNk;S)G-sfL5mT#8Lb1!YVFLh~Ijb-aN&Nr)o z#d{Ioe4YG8?#%FWp$xw3v-Qyv!LAETq_Yp#2kv`r>4o<_zjPHaY%hFn>G=D8w{&dn z74;Wb1L{4l)ZYCy4>eZSq}GqGxuPB%^sdMw*Opau?0wdesr;XAWg@Gm{$y-Pyo5Xln-j zx`=ObA9OEWeczy^?izP^z2&xX!Py3@eE8*>FPeI6+@+@EAai$oZJ%(3>9_SF`gA^h z^3k7{_>H4a|L5wKrElN2dTB>pVf|ayp!!nU{$0(ZjTtpoeMe0pHf5_fhkm?8yD0|xThMmrx4HMB*EnNvIkNtrC+6dm*Z%JQ_0pAAu<5TNGOD5C2W4~0IA=2V zx!n37=S)ts6yF#smK{ZKXzutfOUd5|e>3zWH<7d4n`tIFa{U2!WsX_~y{5n^N)1VNdI}>Bg!T&3IhqU_WHBfqY8&z+~0m2G6W8)h~$`U(0Va?}gK6SWjvOn%42)#d|q9 z60*#oXTZy5Njhl@X&hTw$+;Ae!8v@J~hc_IBheiM3f2jajQfIYkFHT)z z9PXfsyL z&RPJ@X1t`jH=}pnL;3Cmy-ODIGuD!Y4D+JnV64s8&%a2p6v#Kr92-B#{C?%c2O6I- z*DiYCi)ZaI_Ao#DVcSrh!lgrJE1rbmCD@Ze7eSuFKuc( z;2vP}o*ve}0%&GGzM@}S16HN>G28V{RJN=HRd1kCnXrshPoEg3brLZ#P~*8TDxo^HqHL74G+dgPru> zOZz+Ne-!!dLF7YnFDznQ7s&=HI*-6ZkV8xUqI+c2le}eM;j!+&$4dSNj6v(W$M*j@ zR)Q^c_)20hbOzs}JkOvn`hP$F^Pq_Xdtz5W*vOwJ$&uQ@x?Q?_$Z5V z$96P|?w>)=Ud}wiM-@({0WY>l(Lj(GX6!d@h1d%<{>_Yk0DIx)i4Qh9{41^h<7>n# zY40TcJLB*08SzSI?7u}{>c~qp9$YlBK5TxBy11^e(&~jv$>*Lg%|Y$mit85xfp^G-w(B#2^E^EfJB%OCT<)!lqr*YlF6&{DY zBb%5Ho0xdz-giqc(YR?$4!jp@)%a+v?D2W;*)<;C%|ccZo!j+5^V4{y{%CA32mXut zn(Y<*zl#6R;EZM9*$2Lp*A*}A#lW#2neJI|oCA(a6LZW5j`uPrRR2C~PJ(L{ZCq{n zyI8wAOQ~0I>%HDR&AW8|qi5PS8GS36PS5atw0*(?B1LCl_7gQ&##$h($78U zz{qddywT608|I3wJBb&{Zc%Tt!t|-Nn(+{it{vn*HV(hId;=;YS#i4^hoN(!J8VB@ zfywF(nZU7b)*nNem`ST_=Krnq@fy*A>Vk$;zwY~zi>Jrt|E}JvDd%C2goU3+ZTG03 z+}A)iy5IT*`Ms#m`QE|7SM~p9`gl8iEdvM7ac$wgng0jq_fFH-J~Ohw>TCOO_Hb6@ ze9INSjccZr65fig_-wjqlP!xrWSf*YycxV>uRU)+o zv%o?R1;=_XoEg(}gZh3BIgT{`*!Ju?EbzLMad?yXfmPr^auIfy64{b~qpEgb;MlIj z^A+Gn&xGF)&&P4EI(7dVd`bV+k5#})yS+Z%t8KNHJa*;rvCBR+cBZ$3xVY~B4Sgly zw*~x$6ZqW+eq)T8q0goCK|Zppz{7*!;|*|kuJB9#l?T59zvFq=hy38ToPpnv)|)f% zr~00uT&b0A=YUbYm9}5+6m90yKR^9;`o}!8alH&Y#-IW6Yn7}5z9!~@a&{);x(=M_ z`3lX8BwUaGim~3z8uTtW6aF_Ji{oE-FSBC|s+<(STPEURY=yq z`q+LhE1VDAPDlUpfoJ)xTwlWn2T!x}0kH*yr&?q9yN%GJMqGiO_0T)Vh2!1IsLsh+xT&C+YXw`}R^nk(x& zvX?FOxcc=L-`~1)Gxa@Eb5;E|{$CUd)E9EU!Oi!fe)T^IJ=M70mDL-dzD=Pk>Wf2* z8sDn>c72l>)Z3qDzR;lhn_Q{&+iDiFPV`%~8~!cb^TTtR8uwCZ0aWw2{=%7cy9)?Qoxv`Js}KJR=cYn6CEdgdyXjZyZaNhzFX>4q7%?A0jkSzl#SB9@VPL6JLUbXchzC)op39%{0b}6rW3{IicUliq7#QcL>t#4`^|&q}OSMjq zGbPJMnYInA)B4U?t2eV&`zF>jmYL=?KIC)NyMg}8mUBD)r+N5>&d%Dr*vI-x?vx?& zp$aDzCbfP!d}@OHpYp7{G2RU~a z`r^$e@##h{+T7>@pM{i5}xUzgA?qv_HwU$xh8t;f~mr|dz$ z+LY>lNjdh_f5nNaJ>o zoMCY~uxWtj&iuH~#;e)pL+^{a`{jG_7eohyE^i{1BsxI8vp5a}gW7lSCi|LGM-H%0 zGbvk}klW9P_hCoEE|Za8Hb8dUTb`;&vOOr@Abzuh{h(*ZPu66tgPff;BL}&D13srK z2R_*7!~eUJzU^V&OJBD8hdp?Fg3b%9>kc;4j{LXwOSzdSleb~eDvUA@g&(2c7VfkIVVZ)opw08!KOFy zDeajQuX<}otfT{)l)fuHQab0u*pH>>s13FEEYDOohtsvH9o4CJ@_8Sj&dul~q7BhW z%g)kEw9m2-odw;ea|Zv5kbwn*>QK9D_$L2zYz)*TJguP&Nd#M^m#8k)qxx>*UNH3i zAl|NM@jnvJX7$GP8>ep(o;&}gqd$oLR0Ho-UBLtAT&jIa$?HRZWTOGdMmbz_xk@hT z#@?WKzmHf~-o);li+qzxtdHV-QuyBw8OO_S)!{(+R^*jEls$0xJK=f!@4;r)4L|yr z@5JlZTdBR$r|044Gp1kBe0W>6N$>S476RFCwI4esxu?smf#CqYdqW&cv`{iB-yz%J zhpoDUb)*hkiS*?FaXuRcJ-!C}&m!WA!vXw@<>O83H0F}xM&>8QjRc4l3EFWZuCQWA z3TXHF3k8dHGH#?C+OA-}RYUWlcjbmu+{g;(-Nm~g_rVKHYqe+}TX-JxP-8L{d^WK@ zc4O~5`y$#;#Era)AJ)fOs&eYT`eD(3Ctm1P7+B`h0^SJp)}$~gr5)j z_QSIi_!%3=PxHkI{9tnwex5&@EAaza1@J+8E}Np1vm=cIbBGzh9-@AgVZgiy+zXHtiw-q2uD~2hEJT%RY@tGQSA9saok%xqXZtPOY^dtEf zU*3=k#%~j`iwB_v_Q__t@t0}LHQqbHyB}K$Ys$oq@j6Q^D;%-oG*a`U#6X4o@mLMn zuS3Wg>eHLpo%i4uQ@{5*QbLeNJA{9Z9Afr+(^8rPc+#wP8~ zijS)khf_ctPLMUkVB7N{<4n!kxOfZxwE%PNCh)NryOjEE@DW7Gby9W&Ki@(Bq9E}r z6^B{dd2hf^IX5MNY?%rX_&x-SNQjDN*9OlxqQXjoLqkh1q z{_SGyH!^m9d+ho`-=;XgxGmznaN$yn81VqYfy(;^o3<5-$z!h9SOa_gR_2cAgvZyo z(SbzMO8!vO`j|E;o@ZOYwH#sKhYVG4rvZwDs zSFT_!acEb4QeW(tD*E#t>rD_onL@W&eyF-iT# zGaraaa_4P48Iu&ZF)Ajh<77-yg&mXR4$E&Uy7MDH`@vBh?`h#_{oXtokL2K9wiK0( za(0DiRB>^l(YL5WG%Q-a=5I}F;r-;*K_>=J;xkjnU_VOeIIIJm;z6=$hrqSsR-v68 zuD*&Pg695&jhT7R`qwErA_Okaj3M%&8@S+g@^7e|%4|xCF^bEU@fafUQ5Up)-N1#7 z;-wCMRsSym2Gyhb1iRppoF|_1@5V*)pkZw8qU~iziAjcj3~`8@(~_T(=Vx1e?APTgo|j`c^hS_tkd4vSVfx5!$!Mtny9SJ|;ZuCREK>J%@^ zIlOUk1wQ$T!>p&woxf9mGOa3}pe`||H@qE>N5})Vn-AErK`%P7K|kc#F884D?}?35 z{ik?c`bl25iMo6V++6W)tj(FXqJ6b3-7JW|!nV7E3ybj+U(>w7H-}GdV0ac~pqGnQ zp{rnHExHDp(fgmr;Y3Er+!5GH+$yrcQGEWnjK5;7E06=L<>P>FR*UyTZz1OUS$ww{ zTcPAR;aFp*=Z!qq{29BA{cZ3D^2-c&


|9@d!nT=wi({)eWuxA6`h)#|{gcLj;~ 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