/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/rt/desktop.rs
1 443 строки
50 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 for `deno compile --desktop`. //! //! 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. 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; 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#" (() => { const internals = Deno[Deno.internal]; const { BrowserWindow, Dock, Tray, Notification: NotificationNative, 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_request_notification_permission, op_desktop_query_notification_permission, } = internals.core.ops; const BrowserWindowPrototype = BrowserWindow.prototype; Object.setPrototypeOf(BrowserWindowPrototype, EventTarget.prototype); const privateDesktopBind = Symbol.for("Deno_privateDesktopBind"); const privateDesktopUnbind = Symbol.for("Deno_privateDesktopUnbind"); 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; #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; } } } class MouseEvent extends UIEvent { #button = 0; #clientX = 0; #clientY = 0; #ctrlKey = false; #shiftKey = false; #altKey = false; #metaKey = false; 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; } 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; } 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; } } op_desktop_init( internals.webidlBrand, internals.setEventTargetData, ); // Window registry: windowId -> BrowserWindow instance. const windows = new Map(); const nativeConstructor = BrowserWindow; const OrigBW = function(...args) { const instance = new nativeConstructor(...args); const windowId = instance.windowId; windows.set(windowId, instance); return instance; }; Object.setPrototypeOf(OrigBW, nativeConstructor); Object.setPrototypeOf(OrigBW.prototype, nativeConstructor.prototype); Deno.BrowserWindow = OrigBW; 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"); 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, "load"); internals.defineEventHandler(BrowserWindowPrototype, "close"); internals.defineEventHandler(BrowserWindowPrototype, "menuclick"); internals.defineEventHandler(BrowserWindowPrototype, "contextmenuclick"); // Per-window bind callback registry: windowId -> Map<name, fn> 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. // bindingTrace is only useful under --inspect (where DENO_DESKTOP_MUX_WS // is set by the parent). `Deno.env.get` THROWS `NotCapable` if the // runtime wasn't compiled with --allow-env, which aborts DESKTOP_JS // execution before the event-loop IIFE below has a chance to register. // That's the "nothing works — no mouse, no keyboard, no alerts" // failure mode: events fire on the wef side and pile up in the mpsc // channel, but the JS side never reads them because this throw kills // the script. Catch the env-permission error and disable tracing. let bindingTrace = false; try { bindingTrace = typeof Deno.env?.get === "function" && Deno.env.get("DENO_DESKTOP_MUX_WS") != null; } catch (_) { // No env access — fine, we just don't trace binding calls. } BrowserWindowPrototype.bind = function(name, fn) { const windowId = this.windowId; if (!windowBindCallbacks.has(windowId)) { windowBindCallbacks.set(windowId, new Map()); } windowBindCallbacks.get(windowId).set(name, fn.bind(this)); BrowserWindowPrototype[privateDesktopBind].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); // Cap retries at ~2s (200 × 10ms). CEF registers bindings via async // IPC; missing them after that long means navigation tore down the // page or the binding will never appear, and an unbounded // setTimeout loop would otherwise leak forever per such call. this.executeJs(`(function() { var n = ${escapedName}; var seq = 0; var attempts = 0; function tryWrap() { if (typeof window.bindings === "undefined" || typeof window.bindings[n] !== "function") { if (++attempts >= 200) return; 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(); })();`); } }; BrowserWindowPrototype.unbind = function(name) { const windowId = this.windowId; const callbacks = windowBindCallbacks.get(windowId); if (callbacks) callbacks.delete(name); BrowserWindowPrototype[privateDesktopUnbind].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), }); 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)); const TrayPrototype = Tray.prototype; Object.setPrototypeOf(TrayPrototype, EventTarget.prototype); const privateDesktopTrayDestroy = Symbol.for( "Deno_privateDesktopTrayDestroy", ); 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; TrayPrototype.destroy = function() { trays.delete(this.trayId); TrayPrototype[privateDesktopTrayDestroy].call(this); }; TrayPrototype[Symbol.dispose] = function() { this.destroy(); }; internals.defineEventHandler(TrayPrototype, "click"); internals.defineEventHandler(TrayPrototype, "dblclick"); internals.defineEventHandler(TrayPrototype, "menuclick"); // High-level convenience: wire a frameless, non-activating popover window // to this tray icon (the classic menu-bar-app pattern). Built entirely on // the primitives — `new BrowserWindow({ frameless, noActivate })`, // `tray.getBounds()`, the tray "click" event and the window "blur" event. TrayPrototype.attachPanel = function(options) { if (typeof options === "string") options = { url: options }; options = options ?? {}; const width = options.width ?? 360; const height = options.height ?? 480; const hideOnBlur = options.hideOnBlur ?? true; const positionFn = options.position; const tray = this; const window = new Deno.BrowserWindow({ width, height, frameless: true, noActivate: true, resizable: false, }); window.hide(); if (options.url != null) window.navigate(options.url); let visible = false; // Guards the click -> blur -> click toggle race: a tray click on a // focused panel blurs it (hiding via the blur handler) *before* the // tray "click" fires, which would otherwise immediately re-show it. let suppressNextShow = false; const place = () => { const bounds = tray.getBounds(); // No bounds (e.g. Linux, where the tray protocol has no geometry): // leave the window at its current position. if (!bounds) return; const pos = positionFn ? positionFn(bounds, { width, height }) : { x: Math.round(bounds.x + bounds.width / 2 - width / 2), y: Math.round(bounds.y + bounds.height), }; window.setPosition(pos.x, pos.y); }; const show = () => { place(); window.show(); // Take key focus so the panel is interactive and so losing focus // (clicking elsewhere) dismisses it via the blur handler. window.focus(); visible = true; }; const hide = () => { window.hide(); visible = false; }; const toggle = () => { if (visible) hide(); else show(); }; const onTrayClick = () => { if (suppressNextShow) { suppressNextShow = false; return; } toggle(); }; tray.addEventListener("click", onTrayClick); let onBlur = null; if (hideOnBlur) { onBlur = () => { if (!visible) return; hide(); // If this blur was caused by clicking the tray icon, the tray // "click" is about to fire — tell it to stay hidden. suppressNextShow = true; setTimeout(() => { suppressNextShow = false; }, 250); }; window.addEventListener("blur", onBlur); } return { window, get visible() { return visible; }, show, hide, toggle, destroy() { tray.removeEventListener("click", onTrayClick); if (onBlur) window.removeEventListener("blur", onBlur); window.close(); }, }; }; // --- Web Notifications API --- // // Backend wants raw PNG bytes for the icon while the Web Notifications // API specifies icon as a URL string. We synchronously decode `data:` // URLs (the only form a sync constructor can resolve without I/O) and // ignore other schemes — the URL is still stored verbatim on the // instance so `notification.icon` round-trips per spec. function decodeDataUrlSync(url) { if (typeof url !== "string" || !url.startsWith("data:")) return null; const comma = url.indexOf(","); if (comma === -1) return null; const meta = url.slice(5, comma); const isBase64 = meta.endsWith(";base64"); const payload = url.slice(comma + 1); try { if (isBase64) { const bin = atob(payload); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } return new TextEncoder().encode(decodeURIComponent(payload)); } catch { return null; } } const NotificationPrototype = NotificationNative.prototype; Object.setPrototypeOf(NotificationPrototype, EventTarget.prototype); const notifications = new Map(); // The Web Notifications API constructor is `new Notification(title, options?)` // and shows the notification immediately. The native constructor takes // a third arg for pre-decoded icon bytes so the icon URL → bytes step // happens on the JS side (sync data: URL decoding only). const Notification = function Notification(title, options) { if (arguments.length < 1) { throw new TypeError( "Failed to construct 'Notification': 1 argument required, but only 0 present.", ); } const t = String(title); const opts = options ?? {}; const iconBytes = decodeDataUrlSync(opts.icon); const instance = new NotificationNative(t, opts, iconBytes ?? undefined); if (instance.notificationId !== 0) { notifications.set(instance.notificationId, instance); } else { // Backend didn't show it (no support / failure). The native side // already emitted a NotificationError event; nothing to track here. } return instance; }; Object.setPrototypeOf(Notification, NotificationNative); Object.setPrototypeOf(Notification.prototype, NotificationPrototype); Notification.prototype.constructor = Notification; // Cache of the last status the OS reported. `Notification.permission` // is a *synchronous* getter per spec, but the underlying laufey call is // async (it's serviced on the UI thread). The cache starts at // "default" and updates as `requestPermission()` / permissions.query() // resolve. We deliberately do NOT do a startup query — that op // dispatches into the laufey backend's UI thread, which may not be // pumping when DESKTOP_JS first runs; a hung promise there shouldn't // be possible to interfere with the event loop below, but the cost // of being defensive is also zero (apps generally read `.permission` // only after a user-driven request anyway). // // Web spec maps laufey's "prompt" status (no decision yet) to "default" // for `Notification`, and "prompt" for `navigator.permissions`. The // laufey "unsupported" status — emitted when the backend or platform // has no permission model — surfaces to JS as a thrown error from // requestPermission (most honest) and as "denied" from // permissions.query (spec doesn't have an "unsupported" state). let cachedNotificationPermission = "default"; function laufeyToNotificationPermission(s) { // "prompt" → "default" per the Notifications spec; "unsupported" // is handled by the caller (throws on requestPermission). switch (s) { case "granted": return "granted"; case "denied": return "denied"; case "prompt": return "default"; default: return "default"; } } // Wrap every new descriptor mutation in a try/catch. Anything that // throws here would otherwise abort DESKTOP_JS execution and prevent // the event-loop IIFE at the bottom of this script from registering, // which manifests as "nothing works" (no mouse, no keyboard, no // alerts). The catch is logged via console.error so a regression is // visible but doesn't take the whole desktop runtime down with it. try { Object.defineProperties(Notification, { permission: { get() { return cachedNotificationPermission; }, enumerable: true, configurable: true, }, maxActions: internals.core.propReadOnly(0), requestPermission: internals.core.propWritable(function requestPermission( cb, ) { // The Web Notifications spec gates `requestPermission` on a // transient user activation. The desktop runtime can't observe // renderer activations cleanly (the OS-level UN dialog lives // outside Chromium's activation tracking), so we don't enforce. const promise = (async () => { const s = await op_desktop_request_notification_permission(); if (s === "unsupported") { // Honest signaling: this OS / backend has no notification // permission model. Throw rather than silently returning a // misleading "denied" or "granted". throw new TypeError( "Notification.requestPermission: not supported by this platform/backend", ); } const perm = laufeyToNotificationPermission(s); cachedNotificationPermission = perm; return perm; })(); if (typeof cb === "function") { // Deprecated callback form. Per spec, the callback is invoked // with the resolved permission *and* the promise still resolves. promise.then( (perm) => { try { cb(perm); } catch (_) {} }, () => { try { cb("denied"); } catch (_) {} }, ); } return promise; }), }); } catch (e) { console.error("[deno desktop] failed to install Notification permission API:", e); } internals.defineEventHandler(NotificationPrototype, "show"); internals.defineEventHandler(NotificationPrototype, "click"); internals.defineEventHandler(NotificationPrototype, "close"); internals.defineEventHandler(NotificationPrototype, "error"); Object.defineProperty(globalThis, "Notification", { value: Notification, writable: true, enumerable: false, configurable: true, }); // --- navigator.permissions.query (minimal) --- // // Spec surface: `navigator.permissions.query({name})` returns a // Promise<PermissionStatus> where `PermissionStatus` extends EventTarget // and exposes a readonly `state` plus an `onchange` slot. The desktop // runtime today only routes `notifications` through laufey; other names // resolve to "denied" (Chrome's behavior for unknown / unsupported // names — closer to honest than "prompt" for things we can't fulfill). // // Note: we don't fire `change` events. laufey has no change-notification // channel for permissions, and the cached decision only flips when the // user goes through System Settings (rare, manual, not worth polling). class PermissionStatus extends EventTarget { #name; #state; #onchange = null; constructor(name, state) { super(); this.#name = name; this.#state = state; } get name() { return this.#name; } get state() { return this.#state; } get status() { return this.#state; } // legacy alias kept by some libs get onchange() { return this.#onchange; } set onchange(v) { this.#onchange = typeof v === "function" ? v : null; } } function laufeyToPermissionsApiState(s) { // Spec maps "prompt" through verbatim; "unsupported" has no spec // analog so we return "denied" — query() shouldn't throw, but we // shouldn't lie and say "granted" either. switch (s) { case "granted": return "granted"; case "denied": return "denied"; case "prompt": return "prompt"; default: return "denied"; } } const permissionsImpl = { async query(descriptor) { if (descriptor == null || typeof descriptor !== "object") { throw new TypeError( "Failed to execute 'query' on 'Permissions': descriptor required", ); } const name = String(descriptor.name); if (name === "notifications") { // No side effects per spec — never call request_*. const s = await op_desktop_query_notification_permission(); // Keep Notification.permission's cache in sync: a permissions.query // result is authoritative and lets the synchronous getter report // a current value without us needing a second roundtrip. if (s !== "unsupported") { cachedNotificationPermission = laufeyToNotificationPermission(s); } return new PermissionStatus(name, laufeyToPermissionsApiState(s)); } // Unknown / unrouted name. Chrome returns "denied" for descriptors // it doesn't recognize; mimic that rather than throwing. return new PermissionStatus(name, "denied"); }, }; // Plug into globalThis.navigator. The base Deno runtime defines // `navigator` without a `permissions` slot — add ours, but don't // clobber the object if it's missing entirely (defensive against // future-Deno changes). Wrapped: a failure here must not abort the // event-loop IIFE below, because that's what drives all input. try { if (typeof navigator === "object" && navigator != null) { Object.defineProperty(navigator, "permissions", { value: permissionsImpl, writable: true, enumerable: true, configurable: true, }); } Object.defineProperty(globalThis, "PermissionStatus", { value: PermissionStatus, writable: true, enumerable: false, configurable: true, }); } catch (e) { console.error("[deno desktop] failed to install navigator.permissions:", e); } // 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; // Single polling loop for all native desktop events. (async () => { while (true) { 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; } 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); break; } // Run async so it doesn't block the event loop (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)); } })(); 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 "pageLoad": { const target = windows.get(ev.windowId); if (!target) break; target.dispatchEvent(new Event("load")); 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; } 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; } 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; } case "notificationShow": { const target = notifications.get(ev.notificationId); if (!target) break; target.dispatchEvent(new Event("show")); break; } case "notificationClick": { const target = notifications.get(ev.notificationId); if (!target) break; target.dispatchEvent(new Event("click")); break; } case "notificationClose": { const target = notifications.get(ev.notificationId); notifications.delete(ev.notificationId); if (!target) break; target.dispatchEvent(new Event("close")); break; } case "notificationError": { // notificationId === 0 means the backend rejected the show // before any instance was registered. Per spec, errors only // fire on the instance — best-effort dispatch to whichever // instance is registered under that id (or none for id 0). const target = notifications.get(ev.notificationId); if (!target) break; target.dispatchEvent(new Event("error")); break; } } } catch (e) { console.error("Desktop event loop error:", e?.stack ?? e); } } })(); })(); "#; /// 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, release_base_url: Option<&str>, ) -> String { format!( r#"(() => {{ const {{ op_desktop_apply_patch, op_desktop_verify_ed25519, op_desktop_confirm_update, }} = Deno[Deno.internal].core.ops; const {{ propReadOnly, propWritable }} = Deno[Deno.internal].core; const _version = {version}; const _rolledBack = {rolled_back}; const _releaseBaseUrl = {release_base_url}; const ROLLBACK_REASON = "Update failed to start, rolled back."; if (!_rolledBack) {{ op_desktop_confirm_update(); }} let autoUpdateTimer = null; function isHttpsUrl(u) {{ try {{ const parsed = new URL(u); return parsed.protocol === "https:"; }} catch {{ return false; }} }} function autoUpdate(urlOrOpts) {{ const opts = typeof urlOrOpts === "string" ? {{ url: urlOrOpts }} : (urlOrOpts ?? {{}}); const {{ url = _releaseBaseUrl, interval, onUpdateReady, onRollback, publicKey, }} = 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; }} if (!isHttpsUrl(url)) {{ console.error( "Deno.autoUpdate: refusing non-https url (got %s); ignoring.", url, ); return; }} const base = url.replace(/\/$/, ""); const te = new TextEncoder(); const check = async () => {{ try {{ const resp = await fetch(base + "/latest.json", {{ cache: "no-store", redirect: "error", }}); if (!resp.ok) return; const manifestText = await resp.text(); let manifest; try {{ manifest = JSON.parse(manifestText); }} catch {{ console.warn("Deno.autoUpdate: latest.json is not valid JSON"); return; }} if (manifest.version === _version) return; if (publicKey) {{ const sig = manifest.signature; if (typeof sig !== "string" || !sig) {{ console.error( "Deno.autoUpdate: publicKey configured but manifest has no signature", ); return; }} // Signature is computed over the manifest with the `signature` field // removed, serialized canonically. To avoid depending on a JCS // implementation, signers must put the signature on a top-level // `signature` field and include the rest of the manifest verbatim // under a `signed` field (string). We then verify over that string. const signed = manifest.signed; if (typeof signed !== "string") {{ console.error( "Deno.autoUpdate: signed manifest must include a `signed` string field", ); return; }} if (!op_desktop_verify_ed25519(publicKey, sig, te.encode(signed))) {{ console.error("Deno.autoUpdate: manifest signature verification failed"); return; }} // Re-parse the signed payload — only its contents are trusted. try {{ manifest = JSON.parse(signed); }} catch {{ console.error("Deno.autoUpdate: signed payload is not valid JSON"); return; }} if (manifest.version === _version) return; }} const patchEntry = manifest.patches?.[_version]; if (!patchEntry) {{ console.warn("Deno.autoUpdate: no patch available for", _version, "->", manifest.version); return; }} // Accept either a string (legacy/unsafe) or {{ name, sha256 }}. The // SHA-256 is required — Rust will reject the patch otherwise. const patchName = typeof patchEntry === "string" ? patchEntry : patchEntry?.name; const patchSha256 = typeof patchEntry === "object" ? patchEntry?.sha256 : undefined; if (!patchName) {{ console.error("Deno.autoUpdate: malformed patch entry"); return; }} if (typeof patchSha256 !== "string" || patchSha256.length !== 64) {{ console.error( "Deno.autoUpdate: manifest patch entry must include sha256", ); return; }} const patchResp = await fetch(base + "/" + patchName, {{ cache: "no-store", redirect: "error", }}); if (!patchResp.ok) return; const patchBytes = new Uint8Array(await patchResp.arrayBuffer()); op_desktop_apply_patch(patchBytes, patchSha256); if (typeof onUpdateReady === "function") {{ try {{ onUpdateReady(manifest.version); }} catch (e) {{ console.error("Deno.autoUpdate onUpdateReady threw:", e); }} }} if (autoUpdateTimer) {{ clearInterval(autoUpdateTimer); autoUpdateTimer = null; }} }} 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 = serde_json::to_string(&version).unwrap(), rolled_back = if rolled_back { "true" } else { "false" }, release_base_url = serde_json::to_string(&release_base_url).unwrap(), ) } /// 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, }}); // The destination is not passed from JS — the op reads the // operator-configured `error_reporting_url` from native state so an // untrusted caller can't retarget it. `_errorReportingUrl` here only // gates whether there's anything to report. op_desktop_send_error_report(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 = serde_json::to_string(&url).unwrap(), version = serde_json::to_string(&version).unwrap(), ) } 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::DesktopEventTx; 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; 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 /// the runtime implementation. pub fn init_desktop_state( state: &mut OpState, api: Box<dyn DesktopApi>, auto_update: Option<AutoUpdateState>, ) { let api: Arc<dyn DesktopApi> = Arc::from(api); state.put::<Arc<dyn DesktopApi>>(api); if let Some(au) = auto_update { state.put::<AutoUpdateState>(au); } } pub use deno_lib::util::net::allocate_random_port; #[cfg(test)] mod tests { use super::DESKTOP_JS; use super::desktop_auto_update_js; use super::desktop_error_reporting_js; // --- DESKTOP_JS structural invariants --- // // DESKTOP_JS is an 800+ line string baked into the binary. We can't // cheaply exec it in a v8 isolate from here, but the asserts below // pin the regressions that motivated this whole fix: the "Deno.env // throws NotCapable and aborts the IIFE" bug from May 2026. #[test] fn desktop_js_wraps_binding_trace_env_read_in_try_catch() { // The original bug: `Deno.env.get("DENO_DESKTOP_MUX_WS")` threw // NotCapable when --allow-env wasn't granted, aborting the rest of // DESKTOP_JS. The fix is to wrap that read in try/catch and let it // soft-fail. A regression that removed the try/catch would // reintroduce the "blank window where nothing works" failure mode. let near = locate_around(DESKTOP_JS, "DENO_DESKTOP_MUX_WS"); assert!( near.contains("try {") || near.contains("try{"), "DENO_DESKTOP_MUX_WS read must be wrapped in try/catch; got:\n{near}" ); } #[test] fn desktop_js_installs_alert_confirm_prompt_overrides() { // The renderer reaches `op_desktop_alert/confirm/prompt` through // these globalThis overrides; the assignment lines must survive. // Each must reference its op so a stub/no-op replacement would // fail the check. assert!(DESKTOP_JS.contains("op_desktop_alert")); assert!(DESKTOP_JS.contains("op_desktop_confirm")); assert!(DESKTOP_JS.contains("op_desktop_prompt")); assert!(DESKTOP_JS.contains("globalThis")); } #[test] fn desktop_js_installs_recv_event_loop() { // The event-loop IIFE at the bottom polls `op_desktop_recv_event` // and dispatches to BrowserWindow listeners. Without this code path // mouse / keyboard / focus / resize events would never reach JS, // exactly the symptom from the original bug report. assert!( DESKTOP_JS.contains("op_desktop_recv_event"), "DESKTOP_JS must call op_desktop_recv_event" ); } #[test] fn desktop_js_installs_notification_permission_getter() { // Notification.permission is a synchronous spec-mandated getter. // A regression that turned the property definition into a plain // value would break feature-detection in user code. assert!(DESKTOP_JS.contains("Notification")); assert!(DESKTOP_JS.contains("permission")); assert!(DESKTOP_JS.contains("requestPermission")); } #[test] fn desktop_js_installs_navigator_permissions() { assert!(DESKTOP_JS.contains("navigator")); assert!(DESKTOP_JS.contains("permissions")); assert!(DESKTOP_JS.contains("PermissionStatus")); } #[test] fn desktop_js_installs_browser_window_constructor() { assert!(DESKTOP_JS.contains("Deno.BrowserWindow")); // The original BrowserWindow is wrapped so per-window state is // recorded. assert!(DESKTOP_JS.contains("windows.set")); assert!(DESKTOP_JS.contains( "internals.defineEventHandler(BrowserWindowPrototype, \"load\")" )); assert!(DESKTOP_JS.contains("case \"pageLoad\"")); assert!(DESKTOP_JS.contains("dispatchEvent(new Event(\"load\"))")); } #[test] fn desktop_js_interposes_on_native_registry_methods() { assert!( DESKTOP_JS.contains("BrowserWindowPrototype.bind = function(name, fn)") ); assert!( DESKTOP_JS.contains("BrowserWindowPrototype.unbind = function(name)") ); assert!(DESKTOP_JS.contains("TrayPrototype.destroy = function()")); assert!(DESKTOP_JS.contains( "const privateDesktopBind = Symbol.for(\"Deno_privateDesktopBind\")" )); assert!(DESKTOP_JS.contains( "const privateDesktopUnbind = Symbol.for(\"Deno_privateDesktopUnbind\")" )); assert!( DESKTOP_JS.contains( "BrowserWindowPrototype[privateDesktopBind].call(this, name)" ) ); assert!(DESKTOP_JS.contains( "BrowserWindowPrototype[privateDesktopUnbind].call(this, name)" )); assert!(DESKTOP_JS.contains("Deno_privateDesktopTrayDestroy")); assert!( DESKTOP_JS .contains("TrayPrototype[privateDesktopTrayDestroy].call(this)") ); } // --- desktop_auto_update_js --- #[test] fn auto_update_js_inlines_version_as_json_literal() { let js = desktop_auto_update_js(Some("1.2.3"), false, None); // The version must be a JSON-quoted string, not a bare identifier: // we feed it through serde_json::to_string. A regression that // dropped the quoting would produce invalid JS for any non-trivial // version (e.g. `1.2.3-alpha`). assert!( js.contains(r#""1.2.3""#), "version must be quoted; got: {js}" ); assert!(js.contains("const _version =")); assert!(js.contains("const _rolledBack = false")); } #[test] fn auto_update_js_serializes_none_as_null_literal() { let js = desktop_auto_update_js(None, true, None); assert!(js.contains("const _version = null")); assert!(js.contains("const _rolledBack = true")); assert!(js.contains("const _releaseBaseUrl = null")); } #[test] fn auto_update_js_inlines_release_base_url() { // The configured `desktop.release.baseUrl` is baked in as the default // `url` for `Deno.autoUpdate`, so a no-arg call uses it. let js = desktop_auto_update_js( Some("1.0.0"), false, Some("https://releases.example/app"), ); assert!( js.contains(r#"const _releaseBaseUrl = "https://releases.example/app""#), "release base url must be quoted; got: {js}" ); assert!(js.contains("url = _releaseBaseUrl")); } #[test] fn auto_update_js_blocks_non_https_manifest_url() { // Anti-downgrade defence: the auto-update path must refuse to // fetch its manifest over http://, gopher://, file://, etc. A // change that loosened this check is a security regression. let js = desktop_auto_update_js(Some("1.0.0"), false, None); assert!(js.contains("isHttpsUrl")); assert!(js.contains("https:")); } // --- desktop_error_reporting_js --- #[test] fn error_reporting_js_quotes_url_and_version() { let js = desktop_error_reporting_js(Some("https://err.example/r"), Some("0.1.0")); assert!(js.contains(r#""https://err.example/r""#)); assert!(js.contains(r#""0.1.0""#)); // The URL must be referenced by the script body — otherwise the // emitted code would silently never POST. assert!(js.contains("_errorReportingUrl")); } #[test] fn error_reporting_js_handles_none_url() { let js = desktop_error_reporting_js(None, None); // null both — the handler short-circuits the POST but still shows // the alert. assert!(js.contains("const _errorReportingUrl = null")); assert!(js.contains("const _appVersion = null")); } #[test] fn error_reporting_js_listens_for_unhandledrejection() { // Both `error` and `unhandledrejection` events must be hooked // — missing either would let half of all user-code failures fall // out the bottom of the runtime without notification. let js = desktop_error_reporting_js(None, None); assert!(js.contains("\"error\"")); assert!(js.contains("\"unhandledrejection\"")); } // --- helpers --- /// Return a window of DESKTOP_JS around the first occurrence of `needle`, /// covering ~10 lines on each side. Useful for asserting "the region /// near this token contains a try/catch" without coupling the test /// to a precise line range. fn locate_around(hay: &str, needle: &str) -> String { // The first occurrence of DENO_DESKTOP_MUX_WS in DESKTOP_JS is // inside a comment block; the *code* occurrence is the second. We // want the window around the code path, so search after the first. let first = hay.find(needle).unwrap_or_else(|| { panic!("needle {needle:?} not found in DESKTOP_JS"); }); let idx = hay[first + needle.len()..] .find(needle) .map(|i| first + needle.len() + i) .unwrap_or(first); let start = idx.saturating_sub(500); let end = (idx + needle.len() + 500).min(hay.len()); hay[start..end].to_string() } }