Skip to content

Fix stale protocol handler registration on app boot#3114

Merged
gcsecsey merged 9 commits intotrunkfrom
gcsecsey/fix-protocol-handler-dev
Apr 22, 2026
Merged

Fix stale protocol handler registration on app boot#3114
gcsecsey merged 9 commits intotrunkfrom
gcsecsey/fix-protocol-handler-dev

Conversation

@gcsecsey
Copy link
Copy Markdown
Contributor

@gcsecsey gcsecsey commented Apr 16, 2026

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 ID com.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, breaking wp-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:

  1. Read CFBundleIdentifier from node_modules/electron/dist/Electron.app/Contents/Info.plist via /usr/libexec/PlistBuddy (built into macOS — no dependencies).
  2. If it's still the default com.github.Electron, patch it to com.studio.dev.<sha1-prefix-of-electronAppPath> — stable and unique per workspace.
  3. Call lsregister -f to register the new identifier with Launch Services.
  4. 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 Print call returns the unique ID, the function no-ops, and boot continues normally. The patch is idempotent; npm install resets the Info.plist, and the next boot re-applies it.

With each workspace owning a unique bundle ID, setAsDefaultProtocolClient from two different workspaces never collide — they register against different identifiers. Launch Services dispatches wp-studio:// to whichever workspace called setAsDefaultProtocolClient last, with no cross-contamination of cached binary paths.

Benefits of this approach

  • Root cause fix: Each workspace owns a genuinely unique bundle ID. The collision is eliminated at its source rather than patched over at the Launch Services cache layer.
  • Loud failure mode: if the path resolution is wrong, PlistBuddy throws immediately on first boot. This caught a subtle require.resolve('electron') path bug that had silently lived through several rounds of testing under the earlier approach (see "Note on process.execPath" below).
  • Scoped side effects: we only touch our own workspace's 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.
  • Simple, readable code: one helper function, three execFileSync calls, no shell pipelines or dump parsing to maintain.
  • No dependencies: uses only /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.execPath

An 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 of node_modules/electron. Because lsregister -u / -f silently no-op on non-existent paths, the earlier lsregister-based implementation produced no errors while also doing nothing useful — which is why stale-routing symptoms persisted. process.execPath always points at the actual running Electron binary, so path.resolve(process.execPath, '..', '..', '..') reliably gives the Electron.app bundle.

Changes

  • Add ensureUniqueDevBundleId() helper in apps/studio/src/index.ts called at the top of appBoot().
  • Keep setAsDefaultProtocolClient in its original simple form — no pre-remove needed since bundle IDs are unique per workspace.
  • This commit replaces an earlier approach in the same PR that parsed lsregister -dump to unregister stale com.github.Electron entries across the system; the plist approach supersedes it for the reasons listed above.

Tradeoffs

  • node_modules/electron/dist/Electron.app/Contents/Info.plist is mutated in dev mode only. The mutation is idempotent and reset by any npm install.
  • First boot per workspace triggers one relaunch (brief window flash). Subsequent boots add a single ~20ms PlistBuddy Print call.
  • process.defaultApp is false in packaged builds, so none of this code runs in production.

Testing Instructions

  1. Check out this branch in a worktree that has not had npm start run recently (or run npm install in a worktree to reset its Electron.app).
  2. Run npm start — observe that Studio relaunches once during first boot (this is expected).
  3. Log in to WordPress.com
  4. Check that the deeplink redirects into the running Studio instance.
  5. Quit Studio. Check out a different branch in a different worktree of the same repo.
  6. Run npm start in the second worktree — again expect one relaunch on first boot.
  7. Log out, then log back in to WordPress.com.
  8. Check that the deeplink redirects into the Studio instance running in the second worktree. — no new Electron window is shown.
  9. Switch back to the first worktree, run npm start, log in
  10. Check that the deep link routes to the first worktree again.

To verify the unique bundle ID was assigned, run:

/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" <path-to-worktree>/node_modules/electron/dist/Electron.app/Contents/Info.plist

Expected output: com.studio.dev.<12-hex-chars>. To reset a workspace for re-testing, delete node_modules/electron and reinstall.

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?

@gcsecsey gcsecsey marked this pull request as ready for review April 16, 2026 15:39
@gcsecsey gcsecsey requested a review from a team April 16, 2026 15:39
@gcsecsey gcsecsey self-assigned this Apr 16, 2026
@wpmobilebot
Copy link
Copy Markdown
Collaborator

wpmobilebot commented Apr 16, 2026

📊 Performance Test Results

Comparing 43998ad vs trunk

app-size

Metric trunk 43998ad Diff Change
App Size (Mac) 1490.96 MB 1490.96 MB +0.00 MB ⚪ 0.0%

site-editor

Metric trunk 43998ad Diff Change
load 1862 ms 1896 ms +34 ms ⚪ 0.0%

site-startup

Metric trunk 43998ad Diff Change
siteCreation 8105 ms 8104 ms 1 ms ⚪ 0.0%
siteStartup 4949 ms 4953 ms +4 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

Copy link
Copy Markdown
Contributor

@bcotrim bcotrim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome change @gcsecsey
I confirmed, and was able to login while running on a worktree
LGTM 👍

Copy link
Copy Markdown
Contributor

@epeicher epeicher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Image

@gcsecsey
Copy link
Copy Markdown
Contributor Author

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
Image

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.

gcsecsey and others added 7 commits April 20, 2026 11:24
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.
@gcsecsey
Copy link
Copy Markdown
Contributor Author

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 com.github.Electron entries from Launch Services, I pivoted to patching CFBundleIdentifier in the workspace’s Electron.app Info.plist to a unique value on first boot.

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 electron-deeplink package, this approach avoids updating Launch Services registrations completely and only patches the local node_modules.

I also updated the PR description with more details. Could you take another look when you get a chance? 🙏

@gcsecsey gcsecsey requested review from bcotrim and epeicher April 21, 2026 13:56
Copy link
Copy Markdown
Contributor

@epeicher epeicher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Image Image Image

@gcsecsey gcsecsey merged commit e8d75cd into trunk Apr 22, 2026
12 checks passed
@gcsecsey gcsecsey deleted the gcsecsey/fix-protocol-handler-dev branch April 22, 2026 09:42
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.

4 participants