/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
runtime/ops/desktop.rs
2 435 строк
72 KB
Divy Srivastava
fix(desktop): handle colored HMR URLs and page loads (#36316)
26 июл 2026, 15:05
Не верифицирован
26 июл 2026, 15:05
34c4661
Код
Авторство
О чём код?
// 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::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use std::sync::OnceLock; use std::sync::atomic::AtomicU32; 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<DesktopValue>), Dict(Vec<(String, DesktopValue)>), Binary(Vec<u8>), } impl<'a> ToV8<'a> for DesktopValue { type Error = std::convert::Infallible; fn to_v8( self, scope: &mut v8::PinScope<'a, '_>, ) -> Result<v8::Local<'a, v8::Value>, 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::Value> = 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() } }) } } /// Wraps a `Result<DesktopValue, DesktopValue>` from `execute_js`. /// Converts to `{ ok: true, value }` or `{ ok: false, value }`. pub struct ExecuteJsResult(pub Result<DesktopValue, DesktopValue>); impl<'a> ToV8<'a> for ExecuteJsResult { type Error = std::convert::Infallible; fn to_v8( self, scope: &mut v8::PinScope<'a, '_>, ) -> Result<v8::Local<'a, v8::Value>, Self::Error> { let obj = v8::Object::new(scope); let ok_key: v8::Local<v8::Value> = v8::String::new(scope, "ok").unwrap().into(); let value_key: v8::Local<v8::Value> = 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()) } } #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenDevtoolsOptions { pub renderer: Option<bool>, pub deno: Option<bool>, } /// A single event type that flows from the laufey backend to the Deno runtime. #[derive(Debug, serde::Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum DesktopEvent { #[serde(rename_all = "camelCase")] AppMenuClick { window_id: u32, id: String }, #[serde(rename_all = "camelCase")] ContextMenuClick { window_id: u32, id: String }, #[serde(rename_all = "camelCase")] KeyboardEvent { window_id: u32, r#type: String, key: String, code: String, shift: bool, control: bool, alt: bool, meta: bool, repeat: bool, }, #[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, client_y: f64, shift: bool, control: bool, alt: bool, meta: bool, click_count: i32, }, #[serde(rename_all = "camelCase")] MouseMove { window_id: u32, client_x: f64, client_y: f64, shift: bool, control: bool, alt: bool, meta: bool, }, #[serde(rename_all = "camelCase")] Wheel { window_id: u32, delta_x: f64, delta_y: f64, delta_mode: i32, client_x: f64, client_y: f64, shift: bool, control: bool, 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")] PageLoad { window_id: u32 }, #[serde(rename_all = "camelCase")] CloseRequested { window_id: u32 }, #[serde(rename_all = "camelCase")] RuntimeError { message: String, stack: Option<String>, }, #[serde(rename_all = "camelCase")] DockMenuClick { id: String }, #[serde(rename_all = "camelCase")] DockReopen { has_visible_windows: bool }, /// A deep link (`<scheme>://...`) was opened and routed to this app, either /// at launch (cold start) or while already running. Carries the full URL. #[serde(rename_all = "camelCase")] OpenUrl { url: String }, #[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 }, #[serde(rename_all = "camelCase")] NotificationShow { notification_id: u32 }, #[serde(rename_all = "camelCase")] NotificationClick { notification_id: u32 }, #[serde(rename_all = "camelCase")] NotificationClose { notification_id: u32 }, #[serde(rename_all = "camelCase")] NotificationError { notification_id: u32 }, } /// Capacity of the runtime-bound event channel. A misbehaving renderer could /// otherwise flood mouse-move / wheel events fast enough to OOM the runtime /// (the channel was previously unbounded). When full, low-priority events /// (motion / wheel) are dropped via `try_send` and a warning is logged. const DESKTOP_EVENT_CHANNEL_CAPACITY: usize = 1024; type DesktopEventRx = tokio::sync::Mutex<tokio::sync::mpsc::Receiver<DesktopEvent>>; pub type DesktopEventTx = tokio::sync::mpsc::Sender<DesktopEvent>; pub struct DesktopEventReceiver(pub Arc<DesktopEventRx>); #[derive(Clone)] pub struct DesktopEventSender(pub DesktopEventTx); impl DesktopEventSender { /// Send an event, dropping it on backpressure rather than blocking or /// allocating. Use this for high-frequency events (mouse move, wheel). pub fn try_send(&self, event: DesktopEvent) { if let Err(tokio::sync::mpsc::error::TrySendError::Full(_)) = self.0.try_send(event) { // Log once per overflow burst would be ideal, but a plain warn is fine // here — this only fires on pathological event rates. log::warn!( "desktop event channel full; dropping event (renderer producing events faster than runtime can drain)" ); } } } pub fn create_desktop_event_channel() -> (DesktopEventSender, DesktopEventReceiver) { let (tx, rx) = tokio::sync::mpsc::channel(DESKTOP_EVENT_CHANNEL_CAPACITY); ( DesktopEventSender(tx), DesktopEventReceiver(Arc::new(tokio::sync::Mutex::new(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<Result<serde_json::Value, String>>, } type PendingBindResponsesMap = HashMap<u32, tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>>; #[derive(Clone)] pub struct PendingBindResponses( pub Arc<std::sync::Mutex<PendingBindResponsesMap>>, ); impl PendingBindResponses { pub fn new() -> Self { Self::default() } } impl Default for PendingBindResponses { fn default() -> Self { 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<Result<serde_json::Value, String>>, ) -> 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 laufey backend. /// /// All per-window methods take a `window_id` identifying the target window. pub trait DesktopApi: Send + Sync + 'static { /// Create a new window with the given dimensions and return its ID. /// /// `frameless` drops the title bar and standard window chrome. /// `no_activate` makes the window a floating, non-activating utility panel /// (used for tray / menu-bar popovers): it floats above normal windows and /// does not steal key focus from the foreground app when shown. /// `transparent` gives the window a transparent background so the page's own /// alpha composites against whatever is behind the window. These are all /// creation-time properties and cannot be changed afterwards. fn create_window( &self, width: i32, height: i32, frameless: bool, no_activate: bool, transparent_titlebar: bool, transparent: bool, ) -> 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); 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, window_id: u32) -> (i32, i32); fn set_window_position(&self, window_id: u32, x: i32, y: i32); fn is_resizable(&self, window_id: u32) -> bool; fn set_resizable(&self, window_id: u32, resizable: bool); fn is_always_on_top(&self, window_id: u32) -> bool; fn set_always_on_top(&self, window_id: u32, always_on_top: bool); /// Overall window opacity in `0.0..=1.0` (1.0 == fully opaque). Fades the /// whole window uniformly (chrome included), unlike the `transparent` /// creation flag which honors the page's per-pixel alpha. fn get_window_opacity(&self, window_id: u32) -> f64; fn set_window_opacity(&self, window_id: u32, opacity: f64); 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, window_id: u32, name: &str); fn unbind(&self, window_id: u32, name: &str); fn navigate(&self, window_id: u32, url: &str); fn quit(&self); fn set_application_menu(&self, window_id: u32, menu: Vec<MenuItem>); fn show_context_menu( &self, window_id: u32, x: i32, y: i32, menu: Vec<MenuItem>, ); /// Best-effort fetch of the OS-level window/display handles for the /// given window. Returning `Err` instead of panicking matters because /// this trait method is reachable from a v8 op handler but its /// implementation is invoked across the laufey C ABI; an unwind through /// that boundary would be UB. fn get_raw_window_handle( &self, window_id: u32, ) -> Result< ( raw_window_handle::RawWindowHandle, raw_window_handle::RawDisplayHandle, ), deno_error::JsErrorBox, >; fn open_devtools(&self, window_id: u32, renderer: bool, deno: bool); fn execute_js( &self, window_id: u32, script: &str, callback: Box< dyn FnOnce(Result<DesktopValue, DesktopValue>) + Send + 'static, >, ); fn alert(&self, title: &str, message: &str); /// Show a modal confirm dialog. Blocks the calling thread until the /// user dismisses it; the platform's modal run loop pumps OS events /// while the dialog is up so other windows continue to render and /// respond. fn confirm(&self, title: &str, message: &str) -> bool; /// Show a modal prompt dialog. Returns the entered text on confirm, /// `None` on cancel. Blocking semantics as `confirm`. fn prompt( &self, title: &str, message: &str, default_value: &str, ) -> Option<String>; /// 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). /// `None` clears any menu previously set. fn set_dock_menu(&self, menu: Option<Vec<MenuItem>>); /// 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<Vec<MenuItem>>); /// The tray icon's screen rectangle `(x, y, width, height)` in the same /// top-left-origin coordinate space as window positions, or `None` if the /// icon has no on-screen position yet or the backend can't report it. Used /// to anchor a popover window under the icon. fn get_tray_bounds(&self, tray_id: u32) -> Option<(i32, i32, i32, i32)>; /// Show an OS notification. Returns the notification id (`0` if the /// backend doesn't support system notifications). Events for this /// notification (`Show`, `Click`, `Close`, `Error`) are delivered via /// the desktop event channel keyed by the returned id. fn show_notification( &self, title: &str, body: Option<&str>, icon: Option<&[u8]>, tag: Option<&str>, silent: Option<bool>, require_interaction: Option<bool>, ) -> u32; /// Dismiss a notification previously shown via `show_notification`. /// No-op if the id is unknown or already dismissed. fn close_notification(&self, notification_id: u32); /// Request OS authorization to show notifications. If the user has not /// yet decided, this triggers a system prompt; otherwise the cached /// decision is returned without a re-prompt. The callback fires on the /// UI thread with one of [`PermissionState::Granted`], /// [`PermissionState::Denied`], [`PermissionState::Prompt`] (rare — /// happens if the user dismissed the prompt without deciding) or /// [`PermissionState::Unsupported`] (backend / platform has no /// permission model — e.g. an unbundled macOS process, Linux libnotify). fn request_notification_permission( &self, cb: Box<dyn FnOnce(PermissionState) + Send + 'static>, ); /// Query the current authorization state without prompting. Same status /// codes as [`request_notification_permission`]. fn query_notification_permission( &self, cb: Box<dyn FnOnce(PermissionState) + Send + 'static>, ); } /// Authorization state for a capability that the OS (or a runtime /// component) gates. Mirrors the Web Permissions API state set with an /// extra `Unsupported` variant for environments where the capability has /// no permission model at all. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionState { Granted, Denied, Prompt, Unsupported, } /// 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<Option<u32>>); /// The compiled app's name (from deno.json `desktop.app.name`, falling back to /// the output file name). Used as the default window title so a window the app /// doesn't explicitly title shows the app name instead of the backend's /// internal default (`laufey_webview`). pub struct DesktopAppName(pub String); struct BrowserWindow { api: Arc<dyn DesktopApi>, window_id: u32, surface: SameObject<deno_canvas::byow::UnsafeWindowSurface>, /// Set when JS has taken a `getNativeWindow()` surface. Once a webgpu /// surface holds the underlying raw window handles, destroying the OS /// window underneath it would dangle those handles in wgpu-internal state /// (use-after-free at present). We refuse to destroy the window in that /// case and only hide it; the surface keeps the window alive until JS /// releases the BrowserWindow (cppgc) and with it the surface. surface_taken: std::cell::Cell<bool>, } // 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" } } impl deno_core::Resource for BrowserWindow { fn name(&self) -> Cow<'_, str> { "BrowserWindow".into() } } struct EventTargetSetup { brand: v8::Global<v8::Value>, set_event_target_data: v8::Global<v8::Value>, } #[op2] impl BrowserWindow { #[constructor] fn new( state: &OpState, scope: &mut v8::PinScope<'_, '_>, #[scoped] options: Option<BrowserWindowOptions>, ) -> v8::Global<v8::Value> { let api = state .try_borrow::<Arc<dyn DesktopApi>>() .expect("desktop mode enabled") .clone(); // Use the initial window if this is the first BrowserWindow, // otherwise create a new one. let window_id = state .try_borrow::<InitialWindowId>() .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); let frameless = options.as_ref().and_then(|o| o.frameless).unwrap_or(false); let no_activate = options .as_ref() .and_then(|o| o.no_activate) .unwrap_or(false); let transparent_titlebar = options .as_ref() .and_then(|o| o.transparent_titlebar) .unwrap_or(false); let transparent = options .as_ref() .and_then(|o| o.transparent) .unwrap_or(false); api.create_window( width, height, frameless, no_activate, transparent_titlebar, transparent, ) }); // Default the window title to the app name when the app doesn't set one, // so the window shows e.g. "MyApp" instead of the backend's internal // default (`laufey_webview`). An explicit `title` option below overrides // this, and a page that sets `document.title` overrides it at the OS level. if options.as_ref().and_then(|o| o.title.as_ref()).is_none() && let Some(name) = state.try_borrow::<DesktopAppName>() && !name.0.is_empty() { api.set_title(window_id, &name.0); } 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_resizable(window_id, resizable); } if let Some(always_on_top) = options.always_on_top { api.set_always_on_top(window_id, always_on_top); } if let Some(opacity) = options.opacity { api.set_window_opacity(window_id, opacity); } } let window = BrowserWindow { api, window_id, surface: SameObject::new(), surface_taken: std::cell::Cell::new(false), }; let window = deno_core::cppgc::make_cppgc_object(scope, window); let event_target_setup = state.borrow::<EventTargetSetup>(); 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::<v8::Function>(); let null = v8::null(scope); set_event_target_data.call(scope, null.into(), &[window.into()]); let window = window.cast::<v8::Value>(); v8::Global::new(scope, window) } #[getter] fn window_id(&self) -> u32 { self.window_id } // Keep the native primitive separate from DESKTOP_JS's public `bind` // wrapper. The deferred fast-call upgrade may replace this symbol-backed // method without overwriting the wrapper. #[fast] #[symbol("Deno_privateDesktopBind")] fn bind(&self, #[string] name: &str) { self.api.bind(self.window_id, name); } #[fast] #[symbol("Deno_privateDesktopUnbind")] fn unbind(&self, #[string] name: &str) { self.api.unbind(self.window_id, name); } #[fast] fn set_title(&self, #[string] title: &str) { self.api.set_title(self.window_id, title); } fn get_size(&self) -> (i32, i32) { self.api.get_window_size(self.window_id) } #[fast] fn set_size(&self, #[smi] width: i32, #[smi] height: i32) { self.api.set_window_size(self.window_id, width, height); } fn get_position(&self) -> (i32, i32) { self.api.get_window_position(self.window_id) } #[fast] fn set_position(&self, #[smi] x: i32, #[smi] y: i32) { self.api.set_window_position(self.window_id, x, y); } #[fast] fn is_resizable(&self) -> bool { self.api.is_resizable(self.window_id) } #[fast] fn set_resizable(&self, resizable: bool) { self.api.set_resizable(self.window_id, resizable); } #[fast] fn is_always_on_top(&self) -> bool { 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(self.window_id, always_on_top); } #[fast] fn get_opacity(&self) -> f64 { self.api.get_window_opacity(self.window_id) } #[fast] fn set_opacity(&self, opacity: f64) { self.api.set_window_opacity(self.window_id, opacity); } #[fast] fn is_closed(&self) -> bool { self.api.is_closed(self.window_id) } #[fast] fn close(&self) { if self.surface_taken.get() { // A WebGPU surface is referencing this window's native handles. // Destroying the OS window now would dangle those handles. Hide // instead; cleanup happens when the BrowserWindow is GC'd. log::warn!( "BrowserWindow.close(): a WebGPU surface is still attached; hiding window instead of destroying it" ); self.api.hide(self.window_id); return; } self.api.close_window(self.window_id); } #[fast] fn is_visible(&self) -> bool { self.api.is_visible(self.window_id) } #[fast] fn show(&self) { self.api.show(self.window_id); } #[fast] fn hide(&self) { self.api.hide(self.window_id); } #[fast] fn focus(&self) { self.api.focus(self.window_id); } #[fast] fn navigate(&self, #[string] url: &str) { self.api.navigate(self.window_id, url); } fn open_devtools( &self, #[serde] options: Option<OpenDevtoolsOptions>, ) -> 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] fn reload(&self) { self .api .execute_js(self.window_id, "location.reload()", Box::new(|_| {})); } async fn execute_js( &self, #[string] script: String, ) -> Result<ExecuteJsResult, deno_error::JsErrorBox> { let (tx, rx) = tokio::sync::oneshot::channel(); self.api.execute_js( self.window_id, &script, Box::new(move |result| { let _ = tx.send(result); }), ); 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<MenuItem>) { self.api.set_application_menu(self.window_id, menu); } fn show_context_menu( &self, #[smi] x: i32, #[smi] y: i32, #[serde] menu: Vec<MenuItem>, ) { self.api.show_context_menu(self.window_id, x, y, menu); } fn get_native_window( &self, state: &OpState, scope: &mut v8::PinScope<'_, '_>, ) -> Result<v8::Global<v8::Object>, deno_error::JsErrorBox> { let instance = state .try_borrow::<deno_webgpu::Instance>() .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; // Hoisted out of the `surface.try_get` closure so the // `get_raw_window_handle` failure path can bubble before we ever // touch wgpu (and can't unwind across the laufey C ABI). let (win_handle, display_handle) = api.get_raw_window_handle(window_id)?; let result = self.surface.try_get(scope, move |_| { // SAFETY: The raw handles are valid for the lifetime of the OS window. // `BrowserWindow.close()` is suppressed (downgraded to hide) once a // surface has been taken (`surface_taken`), and the OS window outlives // both the cached `SameObject<UnsafeWindowSurface>` and the // BrowserWindow itself, so the handles remain valid for the surface's // lifetime. let surface_id = unsafe { instance .instance_create_surface(Some(display_handle), win_handle, None) .map_err(|e| { deno_error::JsErrorBox::generic(format!( "failed to create wgpu surface: {e}" )) })? }; let (width, height) = api.get_window_size(window_id); Ok::<_, deno_error::JsErrorBox>(deno_canvas::byow::UnsafeWindowSurface { data: std::rc::Rc::new(RefCell::new( deno_webgpu::canvas::SurfaceData { id: surface_id, width: width as u32, height: height as u32, instance, }, )), active_context: Default::default(), }) })?; // Only suppress close() once the surface is actually live. If // surface creation failed above, the window is still safe to close. self.surface_taken.set(true); Ok(result) } } #[derive(FromV8)] struct BrowserWindowOptions { title: Option<String>, width: Option<i32>, height: Option<i32>, x: Option<i32>, y: Option<i32>, resizable: Option<bool>, always_on_top: Option<bool>, opacity: Option<f64>, frameless: Option<bool>, no_activate: Option<bool>, transparent_titlebar: Option<bool>, transparent: Option<bool>, } #[derive(Clone, Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub enum MenuItem { Item { label: String, id: Option<String>, accelerator: Option<String>, enabled: bool, }, Submenu { label: String, items: Vec<MenuItem>, }, 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. pub dylib_path: std::path::PathBuf, /// App version from metadata (deno.json `version` field). pub app_version: Option<String>, /// Whether we rolled back from a failed update on this launch. pub rolled_back: bool, } /// Hex-decoded length of a SHA-256 digest. const SHA256_HEX_LEN: usize = 64; fn dylib_magic_ok(bytes: &[u8]) -> bool { if bytes.len() < 4 { return false; } let m = &bytes[..4]; // Mach-O (32/64 BE/LE), Mach-O fat, ELF, PE/COFF (MZ). matches!( m, [0xFE, 0xED, 0xFA, 0xCE] | [0xFE, 0xED, 0xFA, 0xCF] | [0xCE, 0xFA, 0xED, 0xFE] | [0xCF, 0xFA, 0xED, 0xFE] | [0xCA, 0xFE, 0xBA, 0xBE] | [0xCA, 0xFE, 0xBA, 0xBF] | [0x7F, b'E', b'L', b'F'] ) || m.starts_with(b"MZ") } #[allow( clippy::disallowed_methods, reason = "privileged auto-update op writes the live dylib outside any user's sandbox by design" )] #[op2(fast)] pub fn op_desktop_apply_patch( state: &mut OpState, #[buffer] patch_bytes: &[u8], #[string] expected_sha256: &str, ) -> Result<(), deno_error::JsErrorBox> { let update_state = state.try_borrow::<AutoUpdateState>().ok_or_else(|| { deno_error::JsErrorBox::generic("Auto-update state not initialized") })?; let dylib_path = &update_state.dylib_path; // Verify the patch bytes against the SHA-256 declared in the manifest before // we trust them with `bspatch`. Without this, anyone who can MITM the patch // download (or compromise the release host) could deliver arbitrary native // code. The hash itself is only as trustworthy as the manifest delivery // (TLS) and, when configured, the manifest signature checked in JS. let expected_sha256 = expected_sha256.trim().to_ascii_lowercase(); if expected_sha256.len() != SHA256_HEX_LEN || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) { return Err(deno_error::JsErrorBox::generic( "Auto-update: manifest is missing a valid SHA-256 patch hash", )); } let actual_sha256 = { use sha2::Digest; faster_hex::hex_string(&sha2::Sha256::digest(patch_bytes)).to_lowercase() }; if actual_sha256 != expected_sha256 { return Err(deno_error::JsErrorBox::generic(format!( "Auto-update: patch SHA-256 mismatch (expected {expected_sha256}, got {actual_sha256})" ))); } 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)) })?; // Sanity-check the patched bytes look like a real native binary. This // doesn't make the file safe to load (the hash check above does that), but // it catches a malformed or empty payload before we stage the swap and // shrinks the window where rename(2) into place could fail and leave the // app without a working dylib. if !dylib_magic_ok(&patched) { return Err(deno_error::JsErrorBox::generic( "Auto-update: patched dylib does not look like a native binary", )); } 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(()) } /// Verify an Ed25519 signature over `message` using the base64-encoded /// 32-byte public key and base64-encoded 64-byte signature. Inner pure /// function so it's directly callable from unit tests (the `#[op2]` /// wrapper replaces the surface name with an `OpDecl`). fn verify_ed25519_b64( public_key_b64: &str, signature_b64: &str, message: &[u8], ) -> bool { use base64::Engine; let engine = base64::engine::general_purpose::STANDARD; let Ok(pk_bytes) = engine.decode(public_key_b64.trim()) else { return false; }; let Ok(sig_bytes) = engine.decode(signature_b64.trim()) else { return false; }; let Ok(pk_arr): Result<[u8; 32], _> = pk_bytes.as_slice().try_into() else { return false; }; let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.as_slice().try_into() else { return false; }; let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr) else { return false; }; let signature = ed25519_dalek::Signature::from_bytes(&sig_arr); use ed25519_dalek::Verifier; verifying_key.verify(message, &signature).is_ok() } /// Verify an Ed25519 signature over `message` using the base64-encoded /// 32-byte public key and base64-encoded 64-byte signature. Used by the JS /// auto-update path to validate `latest.json` before fetching any patch. #[op2(fast)] pub fn op_desktop_verify_ed25519( #[string] public_key_b64: &str, #[string] signature_b64: &str, #[buffer] message: &[u8], ) -> bool { verify_ed25519_b64(public_key_b64, signature_b64, message) } #[op2] #[serde] async fn op_desktop_recv_event( state: std::rc::Rc<std::cell::RefCell<OpState>>, ) -> Option<DesktopEvent> { let rx = { let s = state.borrow(); s.try_borrow::<DesktopEventReceiver>().map(|r| r.0.clone()) }; if let Some(rx) = rx { rx.lock().await.recv().await } else { std::future::pending().await } } #[allow( clippy::disallowed_methods, reason = "privileged auto-update sentinel write next to the dylib, outside any user sandbox" )] #[op2(fast)] pub fn op_desktop_confirm_update(state: &mut OpState) { if let Some(s) = state.try_borrow::<AutoUpdateState>() { 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"); } } #[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::<PendingBindResponses>() && let Some(tx) = responses.0.lock().unwrap().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::<PendingBindResponses>() && let Some(tx) = responses.0.lock().unwrap().remove(&call_id) { let _ = tx.send(Err(error)); } } #[op2(fast)] pub fn op_desktop_init( state: &mut OpState, scope: &mut v8::PinScope<'_, '_>, webidl_brand: v8::Local<v8::Value>, set_event_target_data: v8::Local<v8::Value>, ) { state.put(EventTargetSetup { brand: v8::Global::new(scope, webidl_brand), set_event_target_data: v8::Global::new(scope, set_event_target_data), }); } #[op2(fast)] fn op_desktop_alert( state: &mut OpState, #[string] title: &str, #[string] message: &str, ) { if let Some(api) = state.try_borrow::<Arc<dyn DesktopApi>>() { api.alert(title, message); } } struct ErrorReportConfig { url: String, app_version: Option<String>, } static ERROR_REPORT_CONFIG: OnceLock<ErrorReportConfig> = 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<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())) } /// Stash of the `OpState` HTTP client for the panic-hook path. The panic /// hook can't reach `OpState`, so we capture a client when the runtime /// initializes error reporting and reuse it from both code paths. This /// keeps a single TLS configuration (the user's roots) — earlier the panic /// path constructed an ad-hoc `reqwest`/`fetch` client that bypassed it. static ERROR_REPORT_CLIENT: OnceLock<deno_fetch::Client> = OnceLock::new(); /// Capture the OpState HTTP client for use by the panic hook. pub fn set_error_report_client(client: deno_fetch::Client) { let _ = ERROR_REPORT_CLIENT.set(client); } #[allow( clippy::disallowed_methods, reason = "best-effort panic-hook error-report append; path is operator-configured via `error_reporting_url` and FileSystem trait isn't reachable from a panic hook" )] fn append_to_file(path: &Path, body: &str) { let mut line = body.to_string(); 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())); } fn post_error_report(client: deno_fetch::Client, url: String, body: 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.parse::<http::Uri>() 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(); } /// Send a JSON error report to the given URL. Best-effort — never panics. /// Accepts only `file://` and `https://`. Plain `http://` is rejected: /// error reports usually carry stack traces and runtime context, so /// anyone on-path could read them. A bare path (or any unparseable /// string) is also rejected — previously such inputs were silently /// treated as a local file path, which let a malformed metadata field /// land error reports at an attacker-chosen location on disk. pub fn send_error_report(url: &str, body: &str) { let Ok(parsed) = deno_core::url::Url::parse(url) else { log::warn!( "desktop: error_reporting_url is not a valid URL ({:?}); dropping report", url, ); return; }; match parsed.scheme() { "file" => { // `url_to_file_path` rejects `file://host/...` URLs (non-local), // so a local path is the only way to reach `append_to_file`. let Ok(path) = deno_path_util::url_to_file_path(&parsed) else { log::warn!( "desktop: error_reporting_url file:// URL is not a local path ({:?}); dropping report", url, ); return; }; append_to_file(&path, body); } "https" => { let Some(client) = ERROR_REPORT_CLIENT.get().cloned() else { log::warn!( "desktop: error-report HTTP client not initialized; dropping report" ); return; }; post_error_report(client, parsed.to_string(), body.to_string()); } other => { log::warn!( "desktop: refusing to send error report over '{other}' (file:// or https:// only); dropping report", ); } } } #[op2(fast)] fn op_desktop_send_error_report(state: &mut OpState, #[string] body: &str) { // The report destination is operator config — it is baked into the app at // build time (`error_reporting_url`) and stored in `ERROR_REPORT_CONFIG`. // It is deliberately NOT accepted from JS: this op is exposed on // `core.ops` and survives `removeImportedOps()`, so any (untrusted) code // in the runtime can call it. Trusting a caller-supplied URL would turn // this into an unrestricted file-append (`file://`) or network-POST // (`https://`) primitive that bypasses the `--allow-write`/`--allow-net` // permission checks every other fs/net op performs. let Some((url, _)) = error_report_config() else { // No reporting URL configured (e.g. plain `deno run`, or a desktop app // that didn't set one) — there is nowhere to send, so do nothing. return; }; // Make sure the panic-hook path has a client too. The OpState client is // the one configured with the user's TLS roots/permissions, so we share // it across both code paths instead of creating an ad-hoc client. if ERROR_REPORT_CLIENT.get().is_none() && let Ok(client) = deno_fetch::get_or_create_client_from_state(state) { set_error_report_client(client); } send_error_report(url, body); } #[op2(fast)] fn op_desktop_confirm(state: &mut OpState, #[string] message: &str) -> bool { // Sync op: web `confirm()` returns a boolean, not a Promise. The // backend's `confirm` blocks the calling thread inside the platform's // modal run loop (NSAlert runModal / MessageBoxW / gtk_dialog_run / // rfd) which itself pumps OS events, so other windows stay responsive // while the dialog is up. match state.try_borrow::<Arc<dyn DesktopApi>>() { Some(api) => api.confirm("", message), None => false, } } #[op2] #[string] fn op_desktop_prompt( state: &mut OpState, #[string] message: &str, #[string] default_value: Option<String>, ) -> Option<String> { // See `op_desktop_confirm` for the sync-blocking rationale. match state.try_borrow::<Arc<dyn DesktopApi>>() { Some(api) => { api.prompt("", message, default_value.as_deref().unwrap_or("")) } None => None, } } fn permission_state_to_web_string(state: PermissionState) -> &'static str { // Web Permissions API state values; `Notification.requestPermission` // additionally maps `Prompt` → `"default"` per the Notifications spec. match state { PermissionState::Granted => "granted", PermissionState::Denied => "denied", PermissionState::Prompt => "prompt", PermissionState::Unsupported => "unsupported", } } #[op2] #[string] async fn op_desktop_request_notification_permission( state: std::rc::Rc<std::cell::RefCell<OpState>>, ) -> String { let api = { let s = state.borrow(); s.try_borrow::<Arc<dyn DesktopApi>>().cloned() }; let Some(api) = api else { // No backend wired up (snapshot build or non-desktop runtime). return "unsupported".to_string(); }; let (tx, rx) = tokio::sync::oneshot::channel::<PermissionState>(); api.request_notification_permission(Box::new(move |state| { let _ = tx.send(state); })); // If the backend forgets to invoke the callback (programmer error in a // hypothetical custom backend), the channel drops and `recv` returns // `Err` — surface that as "unsupported" so JS gets a stable result. permission_state_to_web_string( rx.await.unwrap_or(PermissionState::Unsupported), ) .to_string() } #[op2] #[string] async fn op_desktop_query_notification_permission( state: std::rc::Rc<std::cell::RefCell<OpState>>, ) -> String { let api = { let s = state.borrow(); s.try_borrow::<Arc<dyn DesktopApi>>().cloned() }; let Some(api) = api else { return "unsupported".to_string(); }; let (tx, rx) = tokio::sync::oneshot::channel::<PermissionState>(); api.query_notification_permission(Box::new(move |state| { let _ = tx.send(state); })); permission_state_to_web_string( rx.await.unwrap_or(PermissionState::Unsupported), ) .to_string() } struct Dock { api: Arc<dyn DesktopApi>, } // 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<v8::Value> { let api = state .try_borrow::<Arc<dyn DesktopApi>>() .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::<EventTargetSetup>(); 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::<v8::Function>(); let null = v8::null(scope); set_event_target_data.call(scope, null.into(), &[dock.into()]); let dock = dock.cast::<v8::Value>(); 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: Option<Vec<MenuItem>>) { self.api.set_dock_menu(menu); } #[fast] fn set_visible(&self, visible: bool) { self.api.set_dock_visible(visible); } } struct Tray { api: Arc<dyn DesktopApi>, 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<v8::Value> { let api = state .try_borrow::<Arc<dyn DesktopApi>>() .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::<EventTargetSetup>(); 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::<v8::Function>(); let null = v8::null(scope); set_event_target_data.call(scope, null.into(), &[tray.into()]); let tray = tray.cast::<v8::Value>(); v8::Global::new(scope, tray) } #[getter] fn tray_id(&self) -> u32 { self.tray_id } #[fast] 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<String>) { self.api.set_tray_tooltip(self.tray_id, text.as_deref()); } fn set_menu(&self, #[serde] menu: Option<Vec<MenuItem>>) { self.api.set_tray_menu(self.tray_id, menu); } #[serde] fn get_bounds(&self) -> Option<TrayBounds> { self .api .get_tray_bounds(self.tray_id) .map(|(x, y, width, height)| TrayBounds { x, y, width, height, }) } // DESKTOP_JS exposes the public `destroy` wrapper that also updates its tray // registry. Keep the native primitive on a distinct symbol-backed slot. #[fast] #[symbol("Deno_privateDesktopTrayDestroy")] fn destroy(&self) { self.api.destroy_tray(self.tray_id); } } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct TrayBounds { x: i32, y: i32, width: i32, height: i32, } struct Notification { api: Arc<dyn DesktopApi>, notification_id: u32, title: String, body: String, icon: String, tag: String, dir: String, lang: String, badge: String, silent: Option<bool>, require_interaction: bool, data: v8::Global<v8::Value>, } // SAFETY: we're sure this can be GCed unsafe impl deno_core::GarbageCollected for Notification { fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} fn get_name(&self) -> &'static std::ffi::CStr { c"Notification" } } impl deno_core::Resource for Notification { fn name(&self) -> Cow<'_, str> { "Notification".into() } } #[derive(FromV8)] struct NotificationConstructorOptions { body: Option<String>, icon: Option<String>, tag: Option<String>, dir: Option<String>, lang: Option<String>, badge: Option<String>, silent: Option<bool>, require_interaction: Option<bool>, data: Option<v8::Global<v8::Value>>, } #[op2] impl Notification { #[constructor] fn new( state: &OpState, scope: &mut v8::PinScope<'_, '_>, #[string] title: String, #[scoped] options: Option<NotificationConstructorOptions>, #[buffer] icon_bytes: Option<&[u8]>, ) -> v8::Global<v8::Value> { let api = state .try_borrow::<Arc<dyn DesktopApi>>() .expect("desktop mode enabled") .clone(); let options = options.unwrap_or(NotificationConstructorOptions { body: None, icon: None, tag: None, dir: None, lang: None, badge: None, silent: None, require_interaction: None, data: None, }); let notification_id = api.show_notification( &title, options.body.as_deref(), icon_bytes, options.tag.as_deref(), options.silent, options.require_interaction, ); let data = options.data.unwrap_or_else(|| { let null: v8::Local<v8::Value> = v8::null(scope).into(); v8::Global::new(scope, null) }); let notification = Notification { api, notification_id, title, body: options.body.unwrap_or_default(), icon: options.icon.unwrap_or_default(), tag: options.tag.unwrap_or_default(), dir: options.dir.unwrap_or_else(|| "auto".to_string()), lang: options.lang.unwrap_or_default(), badge: options.badge.unwrap_or_default(), silent: options.silent, require_interaction: options.require_interaction.unwrap_or(false), data, }; let notification = deno_core::cppgc::make_cppgc_object(scope, notification); let event_target_setup = state.borrow::<EventTargetSetup>(); let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); notification.set(scope, webidl_brand, webidl_brand); let set_event_target_data = v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) .cast::<v8::Function>(); let null = v8::null(scope); set_event_target_data.call(scope, null.into(), &[notification.into()]); let notification = notification.cast::<v8::Value>(); v8::Global::new(scope, notification) } #[getter] fn notification_id(&self) -> u32 { self.notification_id } #[getter] #[string] fn title(&self) -> String { self.title.clone() } #[getter] #[string] fn body(&self) -> String { self.body.clone() } #[getter] #[string] fn icon(&self) -> String { self.icon.clone() } #[getter] #[string] fn tag(&self) -> String { self.tag.clone() } #[getter] #[string] fn dir(&self) -> String { self.dir.clone() } #[getter] #[string] fn lang(&self) -> String { self.lang.clone() } #[getter] #[string] fn badge(&self) -> String { self.badge.clone() } #[getter] fn silent<'a>( &self, scope: &mut v8::PinScope<'a, '_>, ) -> v8::Local<'a, v8::Value> { match self.silent { Some(b) => v8::Boolean::new(scope, b).into(), None => v8::null(scope).into(), } } #[fast] #[getter] fn require_interaction(&self) -> bool { self.require_interaction } #[getter] fn data(&self) -> v8::Global<v8::Value> { self.data.clone() } #[fast] fn close(&self) { if self.notification_id != 0 { self.api.close_notification(self.notification_id); } } } deno_core::extension!( deno_desktop, ops = [ op_desktop_apply_patch, op_desktop_verify_ed25519, op_desktop_confirm_update, op_desktop_init, op_desktop_recv_event, op_desktop_resolve_bind_call, op_desktop_reject_bind_call, op_desktop_alert, op_desktop_confirm, op_desktop_prompt, op_desktop_send_error_report, op_desktop_request_notification_permission, op_desktop_query_notification_permission, ], objects = [BrowserWindow, Dock, Tray, Notification], ); #[cfg(test)] mod tests { use deno_core::serde_json; use deno_core::serde_json::json; use super::BrowserWindow; use super::DesktopEvent; use super::PendingBindResponses; use super::PermissionState; use super::Tray; use super::dylib_magic_ok; use super::permission_state_to_web_string; use super::register_bind_call; use super::verify_ed25519_b64; // These tests pin the wire format that the DESKTOP_JS event-loop // IIFE consumes. Changing any of these shapes is a breaking change // for in-renderer event listeners — the assertions below should // fail if you change a field name or remove a `#[serde(rename_all = // "camelCase")]` so you find out at test time, not at runtime in the // packaged app. #[test] fn js_wrapped_desktop_methods_use_private_symbols() { for (object, methods) in [ ( BrowserWindow::DECL, &["Deno_privateDesktopBind", "Deno_privateDesktopUnbind"][..], ), (Tray::DECL, &["Deno_privateDesktopTrayDestroy"][..]), ] { for name in methods { let method = object .methods .iter() .find(|method| method.name == *name) .unwrap_or_else(|| panic!("missing method {name}")); assert!( method.symbol_for, "{name} must not share a string property with its DESKTOP_JS wrapper" ); let _ = method.fast_fn(); } } for (object, public_names) in [ (BrowserWindow::DECL, &["bind", "unbind"][..]), (Tray::DECL, &["destroy"][..]), ] { for name in public_names { assert!( object.methods.iter().all(|method| method.name != *name), "native method must not collide with the public {name} wrapper" ); } } } #[test] fn app_menu_click_wire_shape() { let v = serde_json::to_value(DesktopEvent::AppMenuClick { window_id: 7, id: "file.quit".to_string(), }) .unwrap(); assert_eq!( v, json!({ "kind": "appMenuClick", "windowId": 7, "id": "file.quit", }) ); } #[test] fn keyboard_event_camelcases_and_keeps_type() { let v = serde_json::to_value(DesktopEvent::KeyboardEvent { window_id: 1, r#type: "keydown".to_string(), key: "a".to_string(), code: "KeyA".to_string(), shift: true, control: false, alt: false, meta: true, repeat: false, }) .unwrap(); // `type` (a Rust keyword, written `r#type`) must serialize as // `"type"` — the renderer reads it as `e.type` per Web spec. assert_eq!(v["type"], "keydown"); assert_eq!(v["kind"], "keyboardEvent"); assert_eq!(v["windowId"], 1); assert_eq!(v["shift"], true); assert_eq!(v["meta"], true); } #[test] fn mouse_click_uses_client_xy() { let v = serde_json::to_value(DesktopEvent::MouseClick { window_id: 1, state: "released".to_string(), button: 0, client_x: 10.5, client_y: 20.25, shift: false, control: false, alt: false, meta: false, click_count: 1, }) .unwrap(); assert_eq!(v["kind"], "mouseClick"); // The renderer reads `e.clientX` / `e.clientY` per the Web spec. // Snake-case names here would silently break the JS side. assert_eq!(v["clientX"], 10.5); assert_eq!(v["clientY"], 20.25); assert_eq!(v["clickCount"], 1); } #[test] fn window_resize_wire_shape() { let v = serde_json::to_value(DesktopEvent::WindowResize { window_id: 1, width: 800, height: 600, }) .unwrap(); assert_eq!( v, json!({ "kind": "windowResize", "windowId": 1, "width": 800, "height": 600, }) ); } #[test] fn dock_reopen_camelcase_payload() { let v = serde_json::to_value(DesktopEvent::DockReopen { has_visible_windows: true, }) .unwrap(); assert_eq!(v["kind"], "dockReopen"); assert_eq!(v["hasVisibleWindows"], true); // The snake_case variant must NOT exist — DESKTOP_JS reads the // camelCased name. assert!(v.get("has_visible_windows").is_none()); } #[test] fn runtime_error_omits_stack_when_none() { let with_stack = serde_json::to_value(DesktopEvent::RuntimeError { message: "boom".to_string(), stack: Some("at foo".to_string()), }) .unwrap(); assert_eq!(with_stack["message"], "boom"); assert_eq!(with_stack["stack"], "at foo"); let no_stack = serde_json::to_value(DesktopEvent::RuntimeError { message: "boom".to_string(), stack: None, }) .unwrap(); // None should serialize as JSON null (not be omitted), matching // what the JS handler currently expects. assert_eq!(no_stack["stack"], serde_json::Value::Null); } #[test] fn notification_variants_share_field_name() { // All four notification variants must use the same `notificationId` // key so the JS handler can route by `kind` alone. for ev in [ DesktopEvent::NotificationShow { notification_id: 42, }, DesktopEvent::NotificationClick { notification_id: 42, }, DesktopEvent::NotificationClose { notification_id: 42, }, DesktopEvent::NotificationError { notification_id: 42, }, ] { let v = serde_json::to_value(&ev).unwrap(); assert_eq!(v["notificationId"], 42, "for variant {ev:?}"); } } // --- Every remaining DesktopEvent variant gets a kind pin --- fn kind_of(ev: DesktopEvent) -> String { serde_json::to_value(ev).unwrap()["kind"] .as_str() .expect("kind must be a string") .to_string() } #[test] fn every_variant_has_camelcase_kind() { // The kind discriminator is what DESKTOP_JS switches on. A // misspelled or accidentally renamed variant would make its events // silently no-op in the renderer. Pin every kind name. assert_eq!( kind_of(DesktopEvent::AppMenuClick { window_id: 0, id: "".into() }), "appMenuClick" ); assert_eq!( kind_of(DesktopEvent::ContextMenuClick { window_id: 0, id: "".into() }), "contextMenuClick" ); assert_eq!( kind_of(DesktopEvent::KeyboardEvent { window_id: 0, r#type: "".into(), key: "".into(), code: "".into(), shift: false, control: false, alt: false, meta: false, repeat: false, }), "keyboardEvent" ); assert_eq!( kind_of(DesktopEvent::BindCall { window_id: 0, name: "".into(), args: serde_json::Value::Null, call_id: 0, }), "bindCall" ); assert_eq!( kind_of(DesktopEvent::MouseClick { window_id: 0, state: "".into(), button: 0, client_x: 0.0, client_y: 0.0, shift: false, control: false, alt: false, meta: false, click_count: 0, }), "mouseClick" ); assert_eq!( kind_of(DesktopEvent::MouseMove { window_id: 0, client_x: 0.0, client_y: 0.0, shift: false, control: false, alt: false, meta: false, }), "mouseMove" ); assert_eq!( kind_of(DesktopEvent::Wheel { window_id: 0, delta_x: 0.0, delta_y: 0.0, delta_mode: 0, client_x: 0.0, client_y: 0.0, shift: false, control: false, alt: false, meta: false, }), "wheel" ); assert_eq!( kind_of(DesktopEvent::CursorEnterLeave { window_id: 0, entered: false, client_x: 0.0, client_y: 0.0, shift: false, control: false, alt: false, meta: false, }), "cursorEnterLeave" ); assert_eq!( kind_of(DesktopEvent::FocusChanged { window_id: 0, focused: false }), "focusChanged" ); assert_eq!( kind_of(DesktopEvent::WindowResize { window_id: 0, width: 0, height: 0 }), "windowResize" ); assert_eq!( kind_of(DesktopEvent::WindowMove { window_id: 0, x: 0, y: 0 }), "windowMove" ); assert_eq!(kind_of(DesktopEvent::PageLoad { window_id: 0 }), "pageLoad"); assert_eq!( kind_of(DesktopEvent::CloseRequested { window_id: 0 }), "closeRequested" ); assert_eq!( kind_of(DesktopEvent::RuntimeError { message: "".into(), stack: None }), "runtimeError" ); assert_eq!( kind_of(DesktopEvent::DockMenuClick { id: "".into() }), "dockMenuClick" ); assert_eq!( kind_of(DesktopEvent::DockReopen { has_visible_windows: false }), "dockReopen" ); assert_eq!(kind_of(DesktopEvent::TrayClick { tray_id: 0 }), "trayClick"); assert_eq!( kind_of(DesktopEvent::TrayDoubleClick { tray_id: 0 }), "trayDoubleClick" ); assert_eq!( kind_of(DesktopEvent::TrayMenuClick { tray_id: 0, id: "".into() }), "trayMenuClick" ); assert_eq!( kind_of(DesktopEvent::NotificationShow { notification_id: 0 }), "notificationShow" ); assert_eq!( kind_of(DesktopEvent::NotificationClick { notification_id: 0 }), "notificationClick" ); assert_eq!( kind_of(DesktopEvent::NotificationClose { notification_id: 0 }), "notificationClose" ); assert_eq!( kind_of(DesktopEvent::NotificationError { notification_id: 0 }), "notificationError" ); } // --- BindCall.args round-trip --- #[test] fn bind_call_args_passes_through_arbitrary_json() { // BindCall carries a serde_json::Value as `args`. We must serialize // it transparently (not nested under "args.value" or with a Some() // wrapper) so the renderer sees exactly what was passed. let ev = DesktopEvent::BindCall { window_id: 1, name: "greet".into(), args: json!([{"name": "ada", "n": 42}]), call_id: 7, }; let v = serde_json::to_value(&ev).unwrap(); assert_eq!(v["args"][0]["name"], "ada"); assert_eq!(v["args"][0]["n"], 42); assert_eq!(v["callId"], 7); assert_eq!(v["windowId"], 1); } // --- permission_state_to_web_string --- #[test] fn permission_state_strings_match_web_api() { // These exact strings are surfaced to JS via `Notification.permission` // and `navigator.permissions.query(...).state`. The Web Permissions // API specifies "granted" / "denied" / "prompt" verbatim; renaming // any of them silently breaks feature detection in user code. assert_eq!( permission_state_to_web_string(PermissionState::Granted), "granted" ); assert_eq!( permission_state_to_web_string(PermissionState::Denied), "denied" ); assert_eq!( permission_state_to_web_string(PermissionState::Prompt), "prompt" ); // "unsupported" is wef-specific (the spec has no such state); DESKTOP_JS // maps it to a TypeError throw from requestPermission. assert_eq!( permission_state_to_web_string(PermissionState::Unsupported), "unsupported" ); } // --- dylib_magic_ok --- #[test] fn dylib_magic_accepts_native_formats() { // 32/64-bit Mach-O, both endians. assert!(dylib_magic_ok(&[0xFE, 0xED, 0xFA, 0xCE])); assert!(dylib_magic_ok(&[0xFE, 0xED, 0xFA, 0xCF])); assert!(dylib_magic_ok(&[0xCE, 0xFA, 0xED, 0xFE])); assert!(dylib_magic_ok(&[0xCF, 0xFA, 0xED, 0xFE])); // Fat Mach-O (universal binary). assert!(dylib_magic_ok(&[0xCA, 0xFE, 0xBA, 0xBE])); assert!(dylib_magic_ok(&[0xCA, 0xFE, 0xBA, 0xBF])); // ELF (Linux). assert!(dylib_magic_ok(&[0x7F, b'E', b'L', b'F'])); // PE/COFF (Windows): starts with "MZ". assert!(dylib_magic_ok(b"MZ\x90\x00rest_of_pe_header")); } #[test] fn dylib_magic_rejects_non_binaries() { // Plain text — what a malformed bspatch result might decode to. assert!(!dylib_magic_ok(b"not a dylib")); // Empty / too short. assert!(!dylib_magic_ok(b"")); assert!(!dylib_magic_ok(b"M")); assert!(!dylib_magic_ok(b"MZ")); assert!(!dylib_magic_ok(b"MZ\x90")); // Random gibberish. assert!(!dylib_magic_ok(&[0xDE, 0xAD, 0xBE, 0xEF])); // The wrapper check is the last line of defence — failure here means // we'd write garbage as the staged dylib. } // --- op_desktop_verify_ed25519 --- // // The op is the trust anchor for auto-update: the manifest is signed // and verified against a baked-in pubkey before any patch hash is // trusted. A regression that returns true for invalid input would // turn the whole auto-update path into "fetch + apply arbitrary code". fn keypair_from_seed( seed: &[u8; 32], ) -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) { let sk = ed25519_dalek::SigningKey::from_bytes(seed); let vk = sk.verifying_key(); (sk, vk) } fn b64(bytes: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::STANDARD.encode(bytes) } #[test] fn verify_ed25519_accepts_real_signature() { use ed25519_dalek::Signer; let (sk, vk) = keypair_from_seed(&[1u8; 32]); let message = b"deno desktop update v1.2.3"; let sig = sk.sign(message); let ok = verify_ed25519_b64(&b64(&vk.to_bytes()), &b64(&sig.to_bytes()), message); assert!(ok, "signature over message must verify"); } #[test] fn verify_ed25519_rejects_tampered_message() { use ed25519_dalek::Signer; let (sk, vk) = keypair_from_seed(&[1u8; 32]); let original = b"deno desktop update v1.2.3"; let sig = sk.sign(original); // Flip a single byte of the message — a correct verifier must reject. let tampered = b"deno desktop update v1.2.4"; let ok = verify_ed25519_b64(&b64(&vk.to_bytes()), &b64(&sig.to_bytes()), tampered); assert!(!ok, "tampered message must fail verification"); } #[test] fn verify_ed25519_rejects_wrong_key() { use ed25519_dalek::Signer; let (sk_a, _) = keypair_from_seed(&[1u8; 32]); let (_, vk_b) = keypair_from_seed(&[2u8; 32]); let message = b"hi"; let sig = sk_a.sign(message); let ok = verify_ed25519_b64( &b64(&vk_b.to_bytes()), &b64(&sig.to_bytes()), message, ); assert!(!ok, "signature from key A must NOT verify under key B"); } #[test] fn verify_ed25519_rejects_malformed_inputs() { let message = b"hi"; // Empty key. assert!(!verify_ed25519_b64("", &b64(&[0u8; 64]), message)); // Empty sig. assert!(!verify_ed25519_b64(&b64(&[0u8; 32]), "", message)); // Wrong-length key. assert!(!verify_ed25519_b64( &b64(&[0u8; 31]), &b64(&[0u8; 64]), message )); assert!(!verify_ed25519_b64( &b64(&[0u8; 33]), &b64(&[0u8; 64]), message )); // Wrong-length sig. assert!(!verify_ed25519_b64( &b64(&[0u8; 32]), &b64(&[0u8; 63]), message )); assert!(!verify_ed25519_b64( &b64(&[0u8; 32]), &b64(&[0u8; 65]), message )); // Invalid base64. assert!(!verify_ed25519_b64( "!!! not base64 !!!", &b64(&[0u8; 64]), message )); assert!(!verify_ed25519_b64( &b64(&[0u8; 32]), "@@@ not base64 @@@", message )); } // --- register_bind_call + PendingBindResponses --- // The op_desktop_resolve_bind_call / op_desktop_reject_bind_call ops // both reduce to map.remove(&call_id).map(|tx| tx.send(...)). We // exercise the underlying state machine directly here — the ops // themselves are #[op2(fast)] wrappers and aren't callable from a // unit test, but the bug surface is the map manipulation, not the // tiny op2 wrapper. fn resolve(responses: &PendingBindResponses, id: u32, v: serde_json::Value) { if let Some(tx) = responses.0.lock().unwrap().remove(&id) { let _ = tx.send(Ok(v)); } } fn reject(responses: &PendingBindResponses, id: u32, e: String) { if let Some(tx) = responses.0.lock().unwrap().remove(&id) { let _ = tx.send(Err(e)); } } #[tokio::test] async fn bind_call_resolve_round_trips_value() { let responses = PendingBindResponses::new(); let (tx, rx) = tokio::sync::oneshot::channel(); let id = register_bind_call(&responses, tx); // The renderer resolves with a JSON value. resolve(&responses, id, serde_json::json!({"ok": true, "n": 42})); let v = rx.await.expect("oneshot recv").expect("Ok variant"); assert_eq!(v["ok"], true); assert_eq!(v["n"], 42); // After resolve, the map entry is gone. assert!( responses.0.lock().unwrap().is_empty(), "responses map must be drained after resolve" ); } #[tokio::test] async fn bind_call_reject_delivers_error_string() { let responses = PendingBindResponses::new(); let (tx, rx) = tokio::sync::oneshot::channel(); let id = register_bind_call(&responses, tx); reject(&responses, id, "binding threw".to_string()); let e = rx.await.unwrap().expect_err("must be Err"); assert_eq!(e, "binding threw"); assert!(responses.0.lock().unwrap().is_empty()); } #[test] fn bind_call_ids_are_unique_across_concurrent_registers() { // The id counter is a single AtomicU32 shared across calls. // Registering many at once must produce distinct ids — duplicates // would silently route a renderer response to the wrong pending // call. let responses = PendingBindResponses::new(); let ids: Vec<u32> = (0..50) .map(|_| { let (tx, _rx) = tokio::sync::oneshot::channel(); register_bind_call(&responses, tx) }) .collect(); let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new(); for id in &ids { assert!(seen.insert(*id), "duplicate bind call id: {id}"); } // All 50 are registered in the map. assert_eq!(responses.0.lock().unwrap().len(), 50); } #[test] fn bind_call_unknown_id_resolve_is_noop() { let responses = PendingBindResponses::new(); // No entry registered — resolve with a random id must not panic // and must not affect any state. resolve(&responses, 999_999, serde_json::Value::Null); reject(&responses, 999_999, "x".to_string()); assert!(responses.0.lock().unwrap().is_empty()); } #[tokio::test] async fn bind_call_dropped_receiver_doesnt_panic_resolve() { // The renderer may give up on a bind call before the Deno side // resolves it (window closed). The resolve path uses `let _ = tx.send(...)` // explicitly because the receiver might be gone; we pin that // behaviour here so a future refactor doesn't reintroduce a // .unwrap() that would crash the runtime. let responses = PendingBindResponses::new(); let (tx, rx) = tokio::sync::oneshot::channel::<Result<serde_json::Value, String>>(); let id = register_bind_call(&responses, tx); drop(rx); resolve(&responses, id, serde_json::Value::Null); // If we reach this line without panicking, the test passes. } #[test] fn verify_ed25519_trims_whitespace_on_b64_inputs() { use ed25519_dalek::Signer; let (sk, vk) = keypair_from_seed(&[1u8; 32]); let message = b"trim me"; let sig = sk.sign(message); let pk = format!(" {}\n", b64(&vk.to_bytes())); let sg = format!("\t{}\n", b64(&sig.to_bytes())); // The op trims the base64 before decoding so manifest JSON with // pretty-printed whitespace (or trailing newlines from `\n` literals) // still verifies. assert!(verify_ed25519_b64(&pk, &sg, message)); } }