Skip to content

feat: deno desktop subcommand#33441

Open
crowlKats wants to merge 155 commits into
denoland:mainfrom
crowlKats:desktop-framework-hmr
Open

feat: deno desktop subcommand#33441
crowlKats wants to merge 155 commits into
denoland:mainfrom
crowlKats:desktop-framework-hmr

Conversation

@crowlKats

@crowlKats crowlKats commented Apr 24, 2026

Copy link
Copy Markdown
Member

Summary

Adds the deno desktop subcommand for building desktop apps from Deno projects.

Core:

  • deno desktop <entry> compiles a project into a self-contained desktop app, built on WEF (prebuilt backends downloaded from github.com/denoland/wef/releases, pinned via Cargo.lock, SHA256-verified, cached under <deno_dir>/wef/<version>/). WEF_DEV_DIR points at a local wef checkout for development.
  • Backends: cef (default, bundled Chromium), webview (OS webview), raw (winit, no engine).
  • Deno.serve() in the entry auto-binds to the port the webview navigates to (via DENO_SERVE_ADDRESS).

Framework auto-detection (cli/tools/framework.rs): Next.js, Astro, Fresh, Remix, Nuxt, SvelteKit, SolidStart, TanStack Start, Vite SSR. Prod server runs by default; dev server runs under --hmr.

HMR (--hmr): framework projects use the framework's dev server; non-framework apps use file-watch + Debugger.setScriptSource hot-swap with the runtime and CEF staying alive.

Deno.BrowserWindow API (cli/tsc/dts/lib.deno.desktop.d.ts): window lifecycle (show/hide/focus/close/reload), size/position, always-on-top, navigation, bind/unbind RPC to webview JS via bindings.<name>(), executeJs, app/context menus, native window handle, keyboard/mouse/wheel/resize/focus events.

Runtime integration: prompt()/alert()/confirm() become native popups; uncaught errors show a native alert and optionally POST to desktop.errorReporting.url.

Auto-updater: Deno.desktopVersion + Deno.autoUpdate({ url, interval, onUpdateReady, onRollback }). Polls <url>/latest.json, applies bsdiff patches (qbsdiff) to the dylib, stages for next launch, rolls back on failed launch.

Unified DevTools (--inspect / --inspect-brk / --inspect-wait): single DevTools session showing both the Deno runtime V8 and the CEF renderer V8 as attached targets — one Console dropdown (Renderer / Deno), one Sources panel with both threads. Implemented as a CDP multiplexer in cli/tools/desktop_devtools.rs. --inspect-brk pauses both isolates (Deno via its own mechanism, CEF via injected Debugger.enable + Debugger.pause before navigation).

Dock / Tray: Deno.dock (macOS) and Deno.Tray (cross-platform) for status-area icons with tooltips, dark-mode icons, and context menus.

Distribution / cross-compile: --target and --all-targets download prebuilt denort + WEF backends for the target triple. Outputs:

  • macOS: .app bundle (framework under Contents/Frameworks/), .dmg via hdiutil
  • Windows: .exe + DLLs directory
  • Linux: app directory, .AppImage via appimagetool

OS-specific limitations

Several features are currently wired up for a subset of platforms. They degrade gracefully (the rest of the app still works) but are worth calling out:

  • Auto-updater is unix-only. apply_pending_update, get_dylib_path, and AutoUpdateState are all #[cfg(unix)]. On non-unix (Windows) update_rolled_back is hardcoded to false and auto_update_state is None, so the staged-update / patch-apply / rollback path is a no-op there. Bringing it to Windows means implementing the dylib-swap + pending-update application for that platform (likely behind a small platform module so the #[cfg] branches in run_desktop/laufey::main! collapse).
  • NAPI native-addon support is unix-only. promote_dylib_symbols_to_global (re-dlopens the runtime dylib with RTLD_GLOBAL so NAPI symbols are visible to addons like next-swc) is #[cfg(unix)]. On Windows, frameworks that pull native addons may fail to load those addons.
  • Notification permissions are macOS-specific. The LAUFEY backend is launched via disclaim_spawn (posix_spawn with TCC responsibility disclaimed) so it becomes its own permission principal; without it UNUserNotificationCenter.requestAuthorization fails. The non-macOS path is a plain tokio spawn with kill_on_drop.
  • Linux is X11-only (no native Wayland). The generated launcher forces --ozone-platform=x11 + GDK_BACKEND=x11, because the LAUFEY mouse/focus/resize event monitor uses XI2 on X11. On Wayland sessions it runs through XWayland; native Wayland is unsupported.
  • Deno.dock is macOS-only (no-op / unavailable elsewhere).
  • Icon sets are not supported in --hmr mode (any platform). IconConfig::Set errors with "icon sets are not supported in --hmr mode yet"; only a single icon path works under --hmr.

Not yet implemented

  • Notarization / stapling. Codesigning is implemented (macOS, via macos.codesignIdentity in deno.json, with an ad-hoc signature fallback for unsigned builds), but there's no notarytool submission / stapler step yet.
  • Windows MSI, Linux .deb / .rpm installers
  • Clipboard, secureStorage
  • Test coverage (unit + integration)

Closes #3234

Unstripped Linux release binaries (deno, denort, test_server) are several
GB; building the denort_desktop cdylib on top exhausts the ~14 GB runner
disk. Strip between the two cargo build invocations to free ~80% of that
space.
The denort_desktop cdylib (libdenort.so) links the entire Deno+V8 runtime
as a shared library. Building it after the first deno/denort/test_server
release build exceeds the ~7 GB RAM available on GitHub-hosted linux
runners, OOM-killing the runner agent and hanging the CI job indefinitely.

Gate the Build denort_desktop step on main/tags or ci-full PRs only.
PR builds only need the deno binary (for WPT tests); they don't need the
cdylib artifact. Also guard the Pre-release (linux) libdenort.so zip step
with an existence check so it skips gracefully when the cdylib wasn't built.
Remove large pre-installed tool suites (Android SDK, PowerShell,
dotnet) and prune Docker images before restoring the Cargo cache and
building. Frees ~10-20 GB on ubuntu-24.04 runners, preventing
OOM/disk exhaustion during ThinLTO V8 linking for the release build.
Deno's JSON.stringify writes matched surrogate pairs as raw UTF-8
(e.g. 🔥 instead of 🔥) and writes U+007F as a raw byte
rather than the � escape sequence. Update url.json, urlpattern.json,
and xhr.json to match the format the WPT runner actually produces, so
the file-diff check passes.

Lone surrogates (e.g. \ud83d \udeb2 separated by a space) remain as
\uXXXX escapes since they cannot be encoded as UTF-8.
- mimesniff.json: replace escaped � with raw DEL byte; JS
  JSON.stringify does not escape U+007F so the WPT tool writes raw bytes
- url.json: fix sort order for non-BMP chars; JS sorts by UTF-16 code
  units so U+1FFFE (high surrogate 0xD83F=55359) sorts before U+FEFF
  (65279) and U+FFFA (65530), opposite of Python codepoint order
The test consistently fails with 'Blob for the given URL not found' when
the worker tries to load its script from a revoked blob URL. Deno does
not retain blob URL references after Worker creation, which is the
behavior that test 2 relies on. This matches the main branch expectation.
spawn_blocking+Command::output() left OS threads alive when the
10-second timeout fired on a hung compiled binary. kill_on_drop(true)
sends SIGKILL when the future is cancelled, preventing thread
accumulation and test-binary-exit deadlock on release linux-x86_64.
Prior fix (43ce6ed) only added #[nofast] to methods that previously
had explicit #[fast]. Remaining cppgc methods (getters, tuple-return
methods, serde methods) were left without #[nofast], allowing op2 to
generate fast calls for them. Under fat LTO on release linux-x86_64
this causes upgrade_snapshotted_ops_with_fast_calls to hang.

Add #[nofast] to all instance methods and getters in BrowserWindow,
Dock, Tray, and Notification cppgc impl blocks.
build_fast on release linux-x86_64 with fat LTO hangs for new desktop
ops. Mark all desktop ops nofast — they call heavyweight OS APIs, so
the JS-to-Rust fast-call overhead is irrelevant.
…t creation

op_ctx_plain_function was introduced to avoid managed-resource serialization
issues in V8 14.9, but op_ctx_function with will_snapshot=true already prevents
build_fast from being called (gated by !will_snapshot in op_ctx_template), so
no managed resources are ever created during snapshot building.

Using op_ctx_plain_function for non-constructable ops during snapshot creation
produces a V8 internal state that causes build_fast to hang at runtime in
upgrade_snapshotted_ops_with_fast_calls on release linux-x86_64 with fat LTO.
Reverting to the same op_ctx_function path used on main restores the snapshot
structure that build_fast expects, fixing the compile_watch_test hang.
…d_ops

Changing ConstructorBehavior from Allow to Throw for the top-level ops loop in
upgrade_snapshotted_ops_with_fast_calls causes build_fast to deadlock on release
linux-x86_64 with fat LTO. This affected all compiled binaries, not just desktop
ones: every deno compile test hung before producing output.

Keep ConstructorBehavior::Allow in the upgrade path (matching main), which is
safe since ops are not constructors at runtime and this path is only hit once
during startup. ConstructorBehavior::Throw is still used correctly in
initialize_deno_core_ops_bindings via op_ctx_constructor_behavior.
…shot

build_fast() crashes (SIGABRT/__cxa_guard_acquire mutex) on release
linux-x86_64 with fat LTO when called after deserializing a snapshot
that contains cppgc objects. Skip upgrade_snapshotted_ops_with_fast_calls
entirely when op_method_decls is non-empty (indicating cppgc types are
registered). Desktop ops are all nofast anyway so no functionality is lost.

Also fixes dprint formatting for the op_ctx_function call site.
…tion warning

Deno.serve now emits a deprecation warning when request.signal is used
with legacy abort behavior. The test expectation must include this warning.
Compiled binaries (denort) were using skip_op_registration=true which
skips initialize_deno_core_ops_bindings. The CLI snapshot now includes
the desktop extension with cppgc class types (BrowserWindow, Dock, Tray,
Notification). With skip_op_registration=true these cppgc templates are
deserialized from the snapshot but their GCInfo is never pre-initialized,
which triggers a __cxa_guard_acquire recursive-initialization SIGABRT
on release linux-x86_64 with fat LTO at first startup.

Setting skip_op_registration=false forces initialize_deno_core_ops_bindings
to run, matching the deno run code path which does not crash.
@littledivy littledivy marked this pull request as draft June 13, 2026 10:42
@littledivy littledivy marked this pull request as ready for review June 13, 2026 10:42
@littledivy littledivy closed this Jun 13, 2026
@littledivy littledivy reopened this Jun 13, 2026
# Conflicts:
#	tests/specs/serve/request_signal_streaming/main.out
… with non-empty op_method_decls

With the crypto cppgc rewrite (f93c548) now in the branch,
op_method_decls is non-empty, causing the upgrade_snapshotted_ops_with_fast_calls
early-return to actually fire and prevent build_fast() calls that were
triggering recursive C++ static-init (SIGABRT) on release linux-x86_64 with
fat LTO. The skip_op_registration=false change made initialize_deno_core_ops_bindings
run instead, which also called build_fast() for crypto cppgc ops causing hangs.
@littledivy littledivy closed this Jun 13, 2026
@littledivy littledivy reopened this Jun 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants