DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One line removes our app menu, and on macOS that is also the line that removes Cmd+V

Notifio is a one-window desktop app that watches rental searches and tells you when a listing appears. It lives in the tray, it has no documents, no tabs and no zoom, so the first thing it does after ready is throw away the menu Electron gives you for free:

// Remove the default application menu
app.on('ready', () => Menu.setApplicationMenu(null));
Enter fullscreen mode Exit fullscreen mode

I wrote that line for a good reason. Electron's default menu is built for an editor: File, Edit, View with Reload and Toggle Developer Tools in it, Window, Help. On Windows it renders as a visible strip of chrome across the top of a window that is one panel of status rows. Both windows also carry the matching option for the platforms where that strip exists:

mainWindow = new BrowserWindow({
  width: 1000,
  height: 800,
  autoHideMenuBar: true,
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Two settings aimed at two platforms, both in service of the same thing: an app that looks like a utility rather than a word processor.

On macOS that line does something I did not think about, because the Mac does not put the menu in the window. It puts it in the system menu bar, and the menu bar is where the keyboard owns its commands.

What the Edit menu actually is on a Mac

Cut, Copy, Paste, Select All, Undo and Redo on macOS are not keyboard handling in the usual sense. They are menu items with key equivalents. The system matches the keystroke against the menu, and the menu sends the command on. Electron knows this, which is why its menu roles exist at all:

role?: ('undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'pasteAndMatchStyle' | 'delete'
  | 'selectAll' | 'reload' | 'forceReload' | 'toggleDevTools' | ...
  | 'appMenu' | 'fileMenu' | 'editMenu' | 'viewMenu' | ...);
Enter fullscreen mode Exit fullscreen mode

That list is from electron.d.ts in our own node_modules. The whole reason there is a bundled editMenu role, one word that expands into the standard six items, is that an app with no menu has no clipboard shortcuts on macOS and the usual fix is to put exactly that submenu back.

Set the application menu to null and there is nothing to match the keystroke against. On Windows and Linux the same keys are delivered by Chromium inside the web contents, so the shortcuts keep working there. This is a macOS-only hole, which is also why it can sit in a codebase for months without anyone noticing: it works on the machine you tested it on, and it works for the half of your users who are on the other platform.

Six text fields, and one of them says Paste

I went and counted what is actually on screen. The renderer has eight <input> elements across four components. Two are checkboxes. The other six are text fields, and they are not decorative:

// ActivationScreen.tsx, the first screen any new user sees
<input id="email" type="email" placeholder="you@example.com" ... />
<input id="token" type="text" placeholder="NTFIO-XXXX-XXXX-XXXX" ... />
Enter fullscreen mode Exit fullscreen mode

The activation token arrives by email as NTFIO- and twelve characters from a deliberately unambiguous alphabet, which is a design I have written about before. The reason that alphabet has no O, 0, I or 1 in it is to make the token safe to read and retype. It should not have to be retyped. It is in an email, two keystrokes away from being in the field.

And then there is this one, in the form where you add a search:

<input
  placeholder="Paste your search results page URL"
  value={newUrl}
  onChange={(e) => setNewUrl(e.target.value)}
/>
Enter fullscreen mode Exit fullscreen mode

The product's own instruction to the user is the word "paste". A search URL from a rental portal with filters on it is sixty to two hundred characters of query string. Nobody is typing that.

There is no second route either

The usual fallback for a missing shortcut is right-click, and Electron does not give you a context menu for free. A text field in a BrowserWindow has no Cut, Copy or Paste item unless the app handles the context-menu event on webContents and builds one. I grepped ours:

$ grep -rn "context-menu\|before-input-event\|globalShortcut" src/
Enter fullscreen mode Exit fullscreen mode

Nothing. No context menu, no before-input-event interception mapping keys onto webContents.paste(), no registered shortcuts. The menu was the only path and the menu is null.

The window where this stings most is not even the main one. Notifio opens a real browser window with no automation in it so you can sign in to a rental site yourself, by hand, in a session the app then reuses. There are no browser extensions in an Electron window, so a password manager cannot fill that form. Paste is how a long generated password gets into it, and that window inherits the same empty application menu as every other window in the app.

The fix is one template, and it is platform-shaped

app.on('ready', () => {
  // macOS keeps the menu in the system bar, and the Edit submenu is where the
  // clipboard shortcuts come from. Windows and Linux get no menu at all.
  Menu.setApplicationMenu(
    process.platform === 'darwin'
      ? Menu.buildFromTemplate([{ role: 'appMenu' }, { role: 'editMenu' }])
      : null
  );
});
Enter fullscreen mode Exit fullscreen mode

Two roles, no items of my own, and the original goal survives: there is still no File menu, no View menu and no Toggle Developer Tools in a shipped build. The window chrome on Windows stays exactly as bare as it was.

The second half is a context menu, which is worth adding on every platform because right-click on a text field doing nothing is its own small wrongness:

contents.on('context-menu', (_event, props) => {
  if (!props.isEditable) return;
  Menu.buildFromTemplate([
    { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { type: 'separator' }, { role: 'selectAll' },
  ]).popup();
});
Enter fullscreen mode Exit fullscreen mode

What makes this one uncomfortable is that Menu was already imported and already in use. The tray menu a few lines further down is built with Menu.buildFromTemplate, with a Show Window item and a Quit item, because the window's close button hides rather than quits. The API I needed was on screen while I wrote the line that broke the clipboard.

I found this by reading, not from a support email, and the fix is not shipped yet, so the honest status is: if you are on a Mac, this is the current behaviour. It is also the cheapest thing in the world to check in your own app. Launch it, click into a text field, press Cmd+V. If nothing happens, your menu is the reason, and it is two roles away from being fixed.

The app itself is on the download page as a Mac DMG for each architecture and a Windows installer, the first-run walkthrough is in the help pages, and the search-adding step with that placeholder in it is described per site on the supported sites pages, for example the Kamernet one. It is a one-time licence, which means the activation screen with the two text fields on it is a screen every single customer sees exactly once, on their first launch, and it is the screen this bug lands on hardest.

Top comments (0)