Skip to content

feat(dev): a run from source is its own app — levelcode-dev:// and its own bundle id - #99

Merged
ndemianc merged 3 commits into
developfrom
feat/dev-editor-identity
Oct 3, 2026
Merged

ndemianc merged 3 commits into
developfrom
feat/dev-editor-identity

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

The bug

Sign in from an editor started by ./scripts/run-dev.sh, and the browser's callback opens the LevelCode in /Applications. The dev editor never hears back, and the installed one is handed a sign-in it did not start.

Why

To macOS they were one app: the same bundle identifier, ai.levelcode.app, and the same levelcode:// scheme. Asked which app opens that link, macOS names the one in /Applications. The dev bundle and the release build folders in the repo root claim it too.

The sign-in code is not at fault, and this PR does not change it. It already builds the callback from vscode.env.uriScheme.

The fix

A run from source gets an identity of its own: scheme levelcode-dev and bundle id ai.levelcode.app.dev. A scheme alone is not enough. With one shared identifier macOS can still hand a launch or a link to whichever copy is running.

File Role
branding/product.dev.json The dev identity, defined once
scripts/editor-identity.mjs dev Puts it in the two places it lives
scripts/run-dev.sh Runs that step after the Electron bundle exists and before the editor starts
scripts/editor-identity.mjs check-release Fails a built app that does not carry the shipped identity
scripts/build-macos.sh Runs that check right after the build

The two places:

  • Runtime: vscode/product.overrides.json. Code-OSS reads it only when running from source and never packages it. Keys a developer keeps there are preserved.
  • macOS: the dev Electron bundle's Info.plist, then lsregister. The bundle is regenerated when Electron changes, so the step runs on every launch.

It is all or nothing, because one half without the other is worse than neither. Everything that can refuse is asked before anything is written. The two files are then replaced as one change: staged beside their targets, renamed into place, and undone if the second rename fails.

The step also waits for macOS to agree. After registering, it asks macOS which app opens levelcode-dev://, and it fails unless the answer is this bundle. run-dev.sh then stops before launching.

product.json is never touched, so a build cannot pick up the dev identity.

The server has to agree

No server accepts levelcode-dev:// unless told. The backend half is systemu-net/thin.ly#440:

LEVELCODE_EXTRA_EDITOR_SCHEMES=levelcode-dev

Until that is set on the backend the dev editor signs in to, the sign-in ends on the account page in the browser and the editor hears nothing. The script prints the setting on every run.

Also in here

run-dev.sh loses its pkill. It targeted the Atom++ binary, a name the app has not had since the rename, so it matched nothing. The reason it gave, dev and packaged builds sharing a bundle identifier, is what this PR removes.

Review

Copilot raised two findings on the first commit. Both were right, and both are fixed in 83209c3.

Finding Outcome
A write that fails could leave only the overrides file changed The two files are replaced as one change, with an undo. Reproduced on the reviewed script with a read-only bundle
A failed lsregister was logged and the launch went on Fatal now. The step also confirms that macOS routes the scheme to this bundle

The second fix goes further than the exit status, because the exit status was not enough. On a bundle under a temporary folder the reviewed script printed "registered" and exited 0, and so did lsregister, while macOS had no app for the scheme. It registers such a bundle and never chooses it.

One more thing turned up while fixing it. The path macOS answers with is spelled as on disk, and the checkout's is spelled as typed into cd. On a Mac's default volume ~/Code and ~/code are one place, so the comparison uses the native realpath. Without it the bundle's own path would have been called "another copy".

Verification

  • On macOS, with the real script, on a clone of the dev bundle and not the checkout's own. LaunchServices resolves levelcode-dev:// to the dev bundle, and levelcode:// to /Applications as before. The checkout's compiled product loader reports levelcode-dev when running from source and levelcode otherwise. A link opened by the system is delivered to the running dev process.
  • The release check passes on the signed v1.3.0 builds of both architectures and fails a bundle carrying the dev identity.
  • test/editorIdentity.test.js, 37 cases. It covers both identities, the overrides merge, the plist changing in two lines and nowhere else, the two files changing as one, a failed or unconfirmed registration, a regenerated bundle, the release check, the command line as the two scripts call it, and accountSignIn() run for real under each scheme.
  • Thirty-two mutations each fail a case: sixteen on the first commit, sixteen on the review fixes.
  • The suite cannot reach LaunchServices. It replaces the script's macOS object with one that throws, so a test that forgets its stand-in fails instead of registering a fixture.
  • The fixed step, against macOS itself, on a clone under the home folder: it reports that macOS opens levelcode-dev:// with the bundle and exits 0. The same clone under a temporary folder exits 1.
  • 49 suites pass on macOS and in a Linux container. The Extension unit tests check on this PR is the run on the real runner.

Not run: run-dev.sh itself, which compiles and launches the editor in the main checkout. Its new lines are covered by the command-line test and an ordering guard.

What you will notice on the first run

  • Quit any dev editor that is already open first. A second launch joins the running one, which still has the old identity.
  • macOS treats the dev editor as a new app, so it may ask again for folder access.
  • The browser's prompt still says LevelCode. The names are unchanged; only the identifier and scheme differ.

Not in this PR

  • The website's IDE link is hard-coded to levelcode://, so in development it still opens the installed app.
  • The editor acts on a callback even when it started no sign-in. Ignoring those would make a misrouted callback harmless.
  • Off macOS the scheme is not registered with the system. The script says so.

…s own bundle id

Sign in from an editor started by run-dev.sh, and the browser's callback opened
the LevelCode in /Applications instead. The dev editor never heard back, and the
installed one was handed a sign-in it had not started.

To macOS they were one app. Asked which app opens levelcode://, it names the one
in /Applications; the dev bundle and the two release build folders in the repo
root claim the same scheme under the same bundle identifier, ai.levelcode.app.

The sign-in code is not at fault and is not changed: it builds its callback from
vscode.env.uriScheme. What was missing is an identity of the dev run's own —
scheme AND bundle identifier, because with one shared identifier macOS can still
hand a launch or a link to whichever copy is running.

  branding/product.dev.json     levelcode-dev, ai.levelcode.app.dev
  scripts/editor-identity.mjs   `dev` puts it in the two places it lives:
      vscode/product.overrides.json   what the editor believes at runtime. Read by
                                      Code-OSS only when running from source, never
                                      packaged. A developer's own keys there are kept.
      the dev bundle's Info.plist     what macOS believes; then lsregister, which is
                                      what routes the link. The bundle is regenerated
                                      when Electron changes, so this runs every launch.
  scripts/run-dev.sh            preLaunch, the identity, then code.sh with
                                VSCODE_SKIP_PRELAUNCH so preLaunch is not run twice.

All or nothing: everything that can refuse is asked before anything is written.
One half without the other is worse than neither — a callback on a scheme
nothing claims, or one that still goes to the installed app.

product.json is never touched, so a build cannot pick the dev identity up. And
build-macos.sh now says so out loud: `editor-identity.mjs check-release` fails a
built app that is not levelcode:// + ai.levelcode.app, or that carries an
overrides file. It passes on the signed v1.3.0 builds of both architectures.

run-dev.sh loses its pkill. It targeted the Atom++ binary, a name the app has
not had since the rename, so it has been matching nothing; and the reason it
gave — dev and packaged builds sharing a bundle identifier — is what this
commit removes.

A server has to be told to accept the scheme: LEVELCODE_EXTRA_EDITOR_SCHEMES
(systemu-net/thin.ly#440), off by default. Until it is, a dev sign-in ends on
the account page in the browser and the editor hears nothing; the script prints
the setting on every run.

Verified on macOS, with the real script, on a clone of the dev bundle (not the
checkout's own): LaunchServices resolves levelcode-dev:// to the dev bundle and
levelcode:// to /Applications as before; the checkout's compiled product loader
reports levelcode-dev under VSCODE_DEV and levelcode without it; and a link
opened by the system is delivered to the running dev process. One thing learnt
doing it: a bundle under a temp folder is registered and never chosen, so the
suite runs on fixtures and registers nothing.

test/editorIdentity.test.js, 25 cases: both identities and the shape a server
accepts; the overrides merge; the plist changing in two lines and nowhere else;
all-or-nothing; a regenerated bundle; the release check; the command line as the
two scripts call it; and accountSignIn() run for real under each scheme. Sixteen
mutations — the script, the launcher's order, the packaging guard, a hard-coded
scheme in sign-in — each fail a case. 49 suites pass on macOS and in a Linux
container.

Not done here: run-dev.sh itself has not been run with this change — it compiles
and launches the editor in the main checkout. Off macOS the scheme is not
registered with the system; the script says so.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Identity updates can remain partially applied or launch after LaunchServices registration fails.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Separates source-run LevelCode from installed builds on macOS, coordinating with the backend’s opt-in development callback scheme.

Changes:

  • Adds and applies a dedicated development bundle ID and URL scheme.
  • Adds a release-build identity guard.
  • Documents and tests identity handling and sign-in callbacks.
File Description
scripts/​run-dev.sh Applies the development identity before launch.
scripts/​editor-identity.mjs Manages development and release identities.
scripts/​build-macos.sh Validates packaged identity.
README.md Documents development sign-in configuration.
extensions/​levelcode-ai/​test/​editorIdentity.test.js Tests identity and callback behavior.
CLAUDE.md Records development identity conventions.
branding/​product.dev.json Defines the development identity.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/editor-identity.mjs Outdated
Comment thread scripts/editor-identity.mjs Outdated
…its for macOS to agree

Two findings, both on scripts/editor-identity.mjs, both right.

1. The two files could still end up half changed. Everything that could REFUSE
   was asked before writing — but the writes themselves can fail, and
   product.overrides.json was written before Info.plist. A bundle that could not
   be written left the editor asking to be called back on a scheme the bundle
   did not own: the half-state the function said it prevented. Reproduced on the
   reviewed script with a read-only bundle.

   The files are now replaced as one change (replaceTogether). Each new text is
   staged in a temporary file beside its target, so a directory that cannot be
   written or a full disk is met while both targets are untouched; then each is
   renamed into place, which either happens or does not. The one way left to be
   half done — a rename failing after an earlier one went through — is undone:
   old contents back, a newly created file removed. If the undo fails too, the
   error names the file left changed. A symlinked overrides file is replaced
   where it really is, and a file keeps its mode.

   A process killed between the two renames can still leave one file ahead. The
   next run finishes it, and run-dev.sh does not launch without a finished run.

2. A registration that failed was logged and passed over, so run-dev.sh went on
   to launch an editor macOS might not route levelcode-dev:// to. It is fatal
   now: the script throws, exits 1, and `set -e` stops the launcher.

   Checking lsregister's exit status turned out not to be enough. Run on a
   bundle under a temporary folder, the reviewed script printed "registered" and
   exited 0 — lsregister had exited 0 too — while macOS had no app for the
   scheme at all: it registers such a bundle and never chooses it. So after
   registering, the script asks macOS what it will do with the link
   (NSWorkspace, through osascript: built in, ~120 ms) and fails unless the
   answer is this bundle. No app, or another copy holding the scheme, are both
   fatal, the second naming the copy and how to unregister it. If macOS cannot
   be asked at all, a registration that succeeded stands and the log says it is
   unconfirmed.

   The two files stay as they are when registration fails: they agree with each
   other, the next run registers again, and undoing them would hand the next
   launch the installed app's scheme.

Found while doing it: the comparison of macOS's answer with the bundle's path
has to use the native realpath. macOS answers with the path as it is on disk;
the checkout's is as someone typed it into `cd`, and on a Mac's default volume
~/Code and ~/code are one place. The JS realpath keeps the case it is given and
would have called the bundle's own path "another copy".

Also: the dev bundle identifier is checked for shape before it is written into
XML; a product.json that is not JSON is a problem the release check reports
rather than a crash; and the suite now replaces the script's macOS object with
one that throws, so a test that forgets its stand-in fails instead of leaving a
temp-folder bundle in LaunchServices.

test/editorIdentity.test.js goes from 25 to 37 cases. The filesystem is handed
in with one step failing only where the failure cannot be provoked for real (a
second rename); the rest use real files. Seventeen mutations of the new code
each fail a case. Checked against macOS itself, on a clone of the dev bundle
under the home folder: success says "macOS opens levelcode-dev:// with this
bundle" and exits 0; the same clone under a temp folder exits 1. 49 suites pass
on macOS and in a Linux container.
@ndemianc
ndemianc merged commit f8b6193 into develop Oct 3, 2026
2 checks passed
@ndemianc
ndemianc deleted the feat/dev-editor-identity branch October 3, 2026 16:37
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.

2 participants