Fix stale protocol handler registration on app boot#3114
Conversation
📊 Performance Test ResultsComparing 43998ad vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
There was a problem hiding this comment.
Thanks @gcsecsey for improving this! I have been affected by this bug a lot of times! However, when using the branch on the main repo, I'm still having issues logging in in development. Could it be something with my environment?
| Log in in development |
|---|
![]() |
Thanks for testing @epeicher, I could reproduce this. I think we have an uncovered edge case with the root repo, where the path isn't matching other worktrees, I'll check this and add a fix. |
In dev mode, all Electron instances share the bundle ID "com.github.Electron". When multiple workspaces exist (e.g. Conductor), macOS Launch Services may cache a stale binary path, routing wp-studio:// callbacks to the wrong instance. Use lsregister -f to force-register the current workspace's Electron.app before setting the protocol handler, so macOS resolves to the correct binary.
lsregister -f alone doesn't remove competing Electron.app binaries with the same bundle ID. Parse lsregister -dump to find all registered com.github.Electron entries, unregister each stale one, then force-register the current workspace's Electron.app.
The lsregister -dump output appends hex IDs to paths like "Electron.app (0x8218)". The awk script was passing these suffixed paths to lsregister -u, which silently failed. Also tighten the filter to only match Electron.app bundles with exactly com.github.Electron (not helpers or Cursor).
require.resolve('electron') in the electron-vite-bundled main process
resolves to the bundled output location (apps/studio/dist) instead of
node_modules/electron. The lsregister -u and -f calls were therefore
operating on a non-existent path and silently no-op'ing, leaving stale
registrations in place across workspaces.
Switching to process.execPath gives the actual running Electron binary,
so path.resolve(process.execPath, '..', '..', '..') reliably points at
the correct Electron.app regardless of how main.js was bundled.
Replaces the lsregister enumerate-and-unregister workaround with a
root-cause fix: on first boot in dev mode, patch Electron.app's
Info.plist so each workspace gets a unique CFBundleIdentifier
("com.studio.dev.<sha1-prefix>"), then relaunch. Subsequent boots are
a single PlistBuddy Print before the existing boot continues.
Why the switch:
- Root cause vs. symptom: each workspace owns a unique bundle ID,
so protocol handlers can't collide in Launch Services.
- No side effects on other Electron dev projects (the previous
approach actively unregistered their binaries at Studio boot).
- Loud failure mode: PlistBuddy throws on bad paths. lsregister
silently no-ops, which is how the require.resolve('electron')
path bug went undetected through several rounds of testing.
- Simpler to read and maintain: no awk dump parsing, no streaming
~10MB through a shell pipeline.
Tradeoffs:
- Mutates node_modules/electron/dist/Electron.app/Contents/Info.plist
(idempotent; reset by npm install; re-applied on next boot).
- One brief relaunch on the first boot per workspace.
- /usr/libexec/PlistBuddy is a built-in macOS tool, no dependencies.
|
Thanks for testing earlier @bcotrim and @epeicher! After going down a bit of a rabbit-hole I switched the approach on this PR. Instead of enumerating and unregistering stale The previous approach was more brittle because we needed to parse the dump from Lauch Services and identify the bundles to de-register. Inspired by the I also updated the PR description with more details. Could you take another look when you get a chance? 🙏 |
epeicher
left a comment
There was a problem hiding this comment.
Thanks for solving this @gcsecsey! I have tested it, and it works as expected, I am able to login from the main repo and from a worktree in development. I have also tested on Windows, and it all works as expected. LGTM!
| About to log in | After Log `In | ` Windows |
|---|---|---|
![]() |
![]() |
![]() |





Related issues
How AI was used in this PR
AI-assisted implementation and testing. All changes reviewed manually.
Proposed Changes
In development mode, every unpackaged Electron binary (
node_modules/electron/dist/Electron.app) ships with the default bundle IDcom.github.Electron. All dev Studio instances — across worktrees, the main checkout, any branch — share this identifier.When an instance calls
app.setAsDefaultProtocolClient('wp-studio', ...), macOS registers the bundle ID as the handler for the scheme, not the binary path. Launch Services then resolves which binary to dispatch to by consulting its own cache. With multiple workspaces all advertising the same bundle ID, the dispatch target becomes non-deterministic: the OAuth callback may open the wrong Electron instance, breakingwp-studio://deep links between workspaces.Approach: unique bundle ID per workspace
This PR fixes the root cause by giving each workspace its own unique bundle ID. On first dev boot, before any initialization runs:
CFBundleIdentifierfromnode_modules/electron/dist/Electron.app/Contents/Info.plistvia/usr/libexec/PlistBuddy(built into macOS — no dependencies).com.github.Electron, patch it tocom.studio.dev.<sha1-prefix-of-electronAppPath>— stable and unique per workspace.lsregister -fto register the new identifier with Launch Services.app.relaunch(); app.exit(0)— the current process dies, a fresh Electron boots with the new bundle ID baked into memory.Subsequent boots: the
PlistBuddy Printcall returns the unique ID, the function no-ops, and boot continues normally. The patch is idempotent;npm installresets the Info.plist, and the next boot re-applies it.With each workspace owning a unique bundle ID,
setAsDefaultProtocolClientfrom two different workspaces never collide — they register against different identifiers. Launch Services dispatcheswp-studio://to whichever workspace calledsetAsDefaultProtocolClientlast, with no cross-contamination of cached binary paths.Benefits of this approach
require.resolve('electron')path bug that had silently lived through several rounds of testing under the earlier approach (see "Note onprocess.execPath" below).node_modules. The system-wide Launch Services database is only updated for our own Electron.app binary — we don't enumerate or deregister binaries belonging to other Electron dev projects on your machine.execFileSynccalls, no shell pipelines or dump parsing to maintain./usr/libexec/PlistBuddy(ships with macOS) — no native modules, no Info.plist XML parsing library, no patches to the electron package itself.Note on
process.execPathAn earlier iteration of this PR computed the Electron.app path via
path.dirname(require.resolve('electron'))+'dist/Electron.app'. That works in plain Node but fails under electron-vite's bundled main process: Rollup rewrites the call during bundling, resolving it against the bundled output path (apps/studio/dist) instead ofnode_modules/electron. Becauselsregister -u/-fsilently no-op on non-existent paths, the earlierlsregister-based implementation produced no errors while also doing nothing useful — which is why stale-routing symptoms persisted.process.execPathalways points at the actual running Electron binary, sopath.resolve(process.execPath, '..', '..', '..')reliably gives the Electron.app bundle.Changes
ensureUniqueDevBundleId()helper inapps/studio/src/index.tscalled at the top ofappBoot().setAsDefaultProtocolClientin its original simple form — no pre-remove needed since bundle IDs are unique per workspace.lsregister -dumpto unregister stalecom.github.Electronentries across the system; the plist approach supersedes it for the reasons listed above.Tradeoffs
node_modules/electron/dist/Electron.app/Contents/Info.plistis mutated in dev mode only. The mutation is idempotent and reset by anynpm install.PlistBuddy Printcall.process.defaultAppis false in packaged builds, so none of this code runs in production.Testing Instructions
npm startrun recently (or runnpm installin a worktree to reset its Electron.app).npm start— observe that Studio relaunches once during first boot (this is expected).npm startin the second worktree — again expect one relaunch on first boot.npm start, log inTo verify the unique bundle ID was assigned, run:
Expected output:
com.studio.dev.<12-hex-chars>. To reset a workspace for re-testing, deletenode_modules/electronand reinstall.Pre-merge Checklist