Skip to content
Development

Adding a native macOS menu to Greenhouse broke Cmd+Q, and my test harness couldn't press Cmd+N either

By Victor Da Luz
rusttaurimacosdev-loggreenhouse

Greenhouse (my creative-project manager, Tauri v2 + Svelte) had no native macOS menu at all - just whatever Tauri shows by default. No File menu, no keyboard shortcuts beyond a rename input. The fix looked like a couple hours of straightforward Rust: build a menu, wire five keyboard shortcuts, done.

It wasn’t quite that simple.

The default menu isn’t a base you build on

I assumed calling app.set_menu(my_menu) would layer my custom items onto whatever Tauri already shows. It doesn’t. The moment you set any menu, the automatic default is gone completely - not merged, replaced.

I found this out by building exactly what the issue asked for: a File menu with “New Idea” and a View menu with Board/Vault/Harvest. Looked complete. Then I thought about what I was replacing, and it clicked: the default menu is also where Cmd+Q, Cmd+H, and Undo/Cut/Copy/Paste/Select All for every text field live. My rename input would have gone dead the moment this shipped, and so would quitting the app normally.

The fix was to build the whole menu, not just the new part: an App submenu (About/Services/Hide/Quit), an Edit submenu (Undo/Redo/Cut/Copy/Paste/Select All), then my actual new stuff. SubmenuBuilder has one-liners for all of this (.quit(), .select_all(), etc.) so it’s not much code - the trap was in not realizing I needed it at all until I stopped and asked what I was throwing away.

Then my own test harness couldn’t verify any of it

I drive Greenhouse’s UI for verification through a WebDriver plugin - real IPC, real Svelte state, no mocking. Great for clicking buttons. Turns out to have zero path to a native menu bar or a physical key combo: it only ever sends JS execution and IPC calls into the webview, nothing at the OS input level.

That much I expected going in. What surprised me was a second, unrelated gap in the same territory. The plugin also exposes a W3C “actions” endpoint that’s supposed to simulate key presses - keyDown/keyUp events sent through the WebDriver protocol properly, not a JS hack. I assumed that at least would work for testing my new Escape-to-go-back handler. It didn’t - sending an Escape through it had zero effect on the app, confirmed by comparing against a synthetic DOM event that worked instantly. I’d actually hit a version of this exact issue before, in an earlier session, and only remembered why partway through re-debugging it.

The workaround split into two levels, once I stopped trying to force one tool to do both jobs. For testing my own JS keydown handler’s logic: dispatch a synthetic KeyboardEvent straight into the page - a JS listener doesn’t care whether an event came from a trusted source, so this genuinely exercises the handler. For testing the menu-to-frontend event wiring: skip the menu and the key entirely, and invoke the same low-level Tauri event command my Rust code emits (plugin:event|emit) directly. This round-trips through the real event system and proves the frontend’s listener works, without needing a native menu click I had no way to fake.

What I couldn’t fake, and didn’t try to: a synthetic event will never trigger a browser’s own default action, like a native <dialog> closing on Escape. That only fires for a “trusted” event - real user input. So closing a dialog in a test script needs an actual click on its own close button, not a fake keypress. And the real ⌘N-through-⌘4 shortcuts still need an actual human hand on an actual keyboard before I call the feature done - no way around that one, and I’ve stopped trying to find a clever way around it.

The pattern underneath both of these

Both surprises came from the same mistake: assuming a tool’s job description covered more than it actually does. “Sets the app menu” sounded additive. “Simulates key presses” sounded literal. Neither assumption was wrong on the surface - the API names are honest - I just hadn’t asked what the absence of an explicit guarantee implied. The fix for both was the same: stop, list out what the current (default, working) behavior actually provides, and check the replacement covers all of it before assuming it does.

Related reading

Development

The fix I talked myself out of

A 'don't build this' recommendation built on two unverified assumptions, and the one-line Tauri activation-policy fix that was sitting there the whole time.

Read
Development

Two notes nobody ever saw

Greenhouse saved a capture note and a handoff note faithfully, and showed neither: one had no field in the wire type, the other had a working command nobody called.

Read