Skip to content

fix(desktop): show macOS tray icon in bundled .app launched via Finder#35626

Open
yyq1025 wants to merge 3 commits into
denoland:mainfrom
yyq1025:fix-macos-tray-launcher
Open

fix(desktop): show macOS tray icon in bundled .app launched via Finder#35626
yyq1025 wants to merge 3 commits into
denoland:mainfrom
yyq1025:fix-macos-tray-launcher

Conversation

@yyq1025

@yyq1025 yyq1025 commented Jun 29, 2026

Copy link
Copy Markdown

Problem

A deno desktop app that creates a Deno.Tray shows its menu-bar icon under --hmr, but the same app bundled with --output and launched via Finder / open shows no tray icon. Windows work in both cases — only the tray is affected.

Reproduces on the official crowlKats/deno-desktop-matrix-client example:

deno task bundle && open MatrixClient.app   # ❌ no tray icon
deno task dev                               # ✅ tray icon (--hmr)

Fixes #35619.

Root cause

The bundled .app's CFBundleExecutable is a POSIX shell script that execs the laufey backend:

#!/bin/sh
DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$DIR/laufey_webview" --runtime "$DIR/<App>.dylib" "$@"

Under LaunchServices the /bin/sh process receives the foreground-GUI check-in and then execs laufey_webview. The backend ends up only half-registered: windows still work (they get their own WindowServer connection), but the status bar (SystemUIServer) never accepts the NSStatusItem, so the tray icon is created but never placed in the menu bar. This is why a window-only app is unaffected, and why --hmr / direct-exec (no LaunchServices exec indirection) work.

Evidence — logging trayId + getBounds():

launch trayId getBounds()
--hmr / direct-exec 1 {x: 1045, y: 6} — menu bar ✅
open (LaunchServices) 1 {x: 8, y: 1096} — unanchored default ❌

The item is created both ways; under open it just never attaches to the menu bar.

Fix

Make the laufey backend binary the bundle's CFBundleExecutable directly (no shell-script exec), and copy the runtime dylib as Contents/MacOS/libruntime.dylib — a path laufey already resolves via [NSBundle mainBundle] (webview/src/main_mac.mm). That makes runtime discovery relocation-safe and removes the need for a --runtime argument, so the process LaunchServices launches is the GUI process.

  • removes the shell launcher + chmod
  • render_macos_info_plist now sets CFBundleExecutable to the backend binary
  • no laufey change required; codesigning is unaffected (it already signs every Mach-O in Contents/MacOS/)
  • Linux/Windows launchers are left untouched — they don't have the LaunchServices status-bar issue

Test

  • cargo test macos_info_plist_includes — added an assertion that CFBundleExecutable is the backend binary, not an app-named launcher.
  • Manually rebuilt deno, re-bundled the matrix-client example, and open MatrixClient.app → tray icon now appears in the menu bar. ✅
The bundled .app's CFBundleExecutable was a shell script that exec'd the
laufey backend with `--runtime`. Under LaunchServices (open / Finder /
double-click) that exec breaks the app's foreground-GUI registration, so
an NSStatusItem (Deno.Tray) is created but never attaches to the menu bar.
Windows are unaffected, and the tray works under `--hmr` and direct-exec,
which is why this only shows up for bundled tray apps.

Make the laufey backend binary the bundle's CFBundleExecutable directly
(no shell-script exec) and copy the runtime dylib as
Contents/MacOS/libruntime.dylib, a path laufey already resolves via
[NSBundle mainBundle]. This is relocation-safe and needs no `--runtime`
argument, so the GUI process is the one LaunchServices launches.

Fixes denoland#35619

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@deno-cla-assistant

deno-cla-assistant Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deno Individual Contributor License Agreement

All contributors have signed the CLA. Thank you!

Re-run CLA check


This is an automated message from CLA Assistant

@crowlKats

Copy link
Copy Markdown
Member

Reviewed the diff. Nice root-cause writeup — making the backend the CFBundleExecutable directly is the right call. A few things worth a look before merge:

1. Dropping validate_launcher_name(&app_name) can corrupt Info.plist (regression).
The deleted launcher block also removed validate_launcher_name(&app_name, "app name")?, which previously rejected any name outside [A-Za-z0-9 ._-]+. But app_name (derived from the --output file stem) still flows unescaped into the Info.plist XML — CFBundleName, the five NS*UsageDescription strings, and bundle_id via the slug. With the guard gone, e.g. deno desktop --output 'My&App.dylib' now emits malformed XML (<string>My&App …</string>) and the bundle fails to launch, instead of the previous clear "invalid app name" error. Suggest re-adding the app_name validation, or XML-escaping the plist interpolations.

2. --runtime is dropped for all macOS backends, but the fix is only verified for webview.
The dylib is unconditionally renamed to libruntime.dylib with no --runtime arg, yet the relocation-safe [NSBundle mainBundle] resolution is cited only from webview/src/main_mac.mm. This path also serves --backend cef. If the CEF backend doesn't resolve Contents/MacOS/libruntime.dylib the same way, deno desktop --backend cef --output app bundles would launch but fail to find the runtime. Worth confirming CEF has the same fallback (or gating the rename on backend).

3. Stale doc comment.
The /// Bundle structure: diagram in package_macos_app_bundle still shows AppName (launcher script) and libapp.dylib; after this change there's no launcher and the dylib is libruntime.dylib.

…tion

Address review feedback on denoland#35626:

- The cef backend resolves the runtime dylib as <executable>.dylib next to
  the binary (LaufeyFindColocatedRuntime), not the hardcoded libruntime.dylib
  the webview backend searches via [NSBundle mainBundle]. Name the dylib per
  backend through the new macos_runtime_dylib_name helper so `--backend cef`
  bundles can find their runtime; add a unit test.
- Restore validate_launcher_name(&app_name): it was removed together with the
  shell launcher, but app_name still flows unescaped into the Info.plist XML
  (CFBundleName, the NS*UsageDescription strings) and the synthesized bundle
  id, so an out-of-charset name now yields a clear error instead of a
  malformed plist that fails to launch.
- Update the stale bundle-structure doc comment (no launcher script; the dylib
  is libruntime.dylib / <backend>.dylib, not libapp.dylib).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yyq1025

yyq1025 commented Jun 30, 2026

Copy link
Copy Markdown
Author

Thanks for the review — pushed fixes for all three:

  • (1) validate_launcher_name(&app_name) — restored, moved up so it also guards the synthesized bundle id, not just the plist strings.
  • (3) Stale doc comment — updated the bundle-structure diagram.

(2) --runtime dropped for all backends (only verified on webview) — this is the substantive one, and it turned out deeper than a naming gate. CEF needs the same launcher-free treatment (the tray-registration bug isn't backend-specific); it resolves the runtime via laufey's co-located discovery (littledivy/laufey#15). The catch: #15 only exists at laufey API 27, while we pin laufey 0.4.1 (API 26) — so a launcher-free CEF bundle loads the dylib but then fails to init against the pinned runtime (API version mismatch: expected 26, got 27).

Locally I confirmed the co-located path does find and load the runtime launcher-free (laufey logs Runtime loaded successfully from …laufey.dylib), and the tray code is shared with the webview backend (working launcher-free here) — so I expect CEF to work once both sides are on API 27, but it can't be fully verified on the current pin.

Given that, I'd suggest holding this PR until we bump the laufey crate to an API-27 release that includes #15 — then I'll rebase, verify CEF end-to-end, and it can land as one complete fix rather than a webview-only partial.

@yyq1025

yyq1025 commented Jul 1, 2026

Copy link
Copy Markdown
Author

Update on the CEF backend now that we're on laufey 0.5.0:

Tray — works on both backends now. 0.5.0 ships the co-located runtime
discovery, so the launcher-free bundle in this PR resolves the runtime under
--backend cef too, and the tray icon appears.

Window content (separate, pre-existing laufey bug) — CEF windows that load
app content over the app:// scheme came up blank. I traced it to laufey's CEF
backend declaring its custom standard scheme only in the browser process:
LaufeyRendererApp (the CefApp used by CefExecuteProcess for every
sub-process, including the network service) didn't override
OnRegisterCustomSchemes, so the network service rejected the navigation
(VALIDATION_ERROR_DESERIALIZATION_FAILED on network.mojom.NetworkContext)
and the page never committed. Same class as electron/electron#22867.

Fixed upstream in littledivy/laufey#34.

This is independent of this PR: it's a laufey-side sub-process IPC bug, unrelated to the launcher change here (which only affects how the main process registers with LaunchServices, not CEF's browser↔sub-process Mojo).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants