From 791735e6278efb0526116b9e38847dc1aeafe74d Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 12:04:47 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(dev):=20a=20run=20from=20source=20is?= =?UTF-8?q?=20its=20own=20app=20=E2=80=94=20levelcode-dev://=20and=20its?= =?UTF-8?q?=20own=20bundle=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 25 +- README.md | 2 + branding/product.dev.json | 6 + .../levelcode-ai/test/editorIdentity.test.js | 421 ++++++++++++++++++ scripts/build-macos.sh | 6 + scripts/editor-identity.mjs | 246 ++++++++++ scripts/run-dev.sh | 20 +- 7 files changed, 718 insertions(+), 8 deletions(-) create mode 100644 branding/product.dev.json create mode 100644 extensions/levelcode-ai/test/editorIdentity.test.js create mode 100755 scripts/editor-identity.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 4cdc4cf..ae04396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ extensions/levelcode-hackability/ user init script + Atom/NPP keymap presets + p extensions/levelcode-sync/ 'levelcode' auth provider that lights up the built-in Settings Sync (LevelCode Sync, S0) extensions/levelcode-updater/ notify-only update checker (polls the update feed; never auto-applies) patches/levelcode-core.patch our core source edits, applied on bootstrap -scripts/ bootstrap.sh, apply-branding.mjs, run-dev.sh, build-macos.sh, make-dmg.sh, make-icon.sh; atom (CLI launcher) + install-level.sh +scripts/ bootstrap.sh, apply-branding.mjs, run-dev.sh, editor-identity.mjs, build-macos.sh, make-dmg.sh, make-icon.sh; atom (CLI launcher) + install-level.sh tools/ dependency-free reference servers: sync-server (/v1 Settings-Sync), update-server (/api/update feed) vscode/ GITIGNORED upstream Code-OSS checkout (generated) ``` @@ -55,6 +55,29 @@ vscode/ GITIGNORED upstream Code-OSS checkout (generated) ./scripts/make-icon.sh # regenerate .icns from branding/icons/levelcode-source.png (sips+iconutil) ``` +## The dev editor is its own app (keep it that way) + +To macOS a run from source and the installed LevelCode used to be ONE app — same bundle id, same +`levelcode://` scheme — so a sign-in started in the dev editor was handed back to the app in +/Applications. `run-dev.sh` now gives the dev run its own identity (`scripts/editor-identity.mjs dev`): + +- **Identity:** `branding/product.dev.json` — scheme `levelcode-dev`, bundle id `ai.levelcode.app.dev`. + Both must differ from the shipped ones; a scheme alone still lets macOS confuse the two apps. +- **Two halves, both required.** Runtime: `vscode/product.overrides.json` (Code-OSS reads it only when + running from source, never packages it). macOS: the dev Electron bundle's `Info.plist`, then + `lsregister`. The bundle is regenerated when Electron changes, so the step runs on every launch. +- **`branding/product.overlay.json` is the product that ships — never put a dev value in it.** + `build-macos.sh` runs `editor-identity.mjs check-release` and fails a build that is not + `levelcode://` + `ai.levelcode.app`, or that carries an overrides file. +- **Auth code never spells a scheme.** `accountSignIn()` builds the callback from + `vscode.env.uriScheme`; that is why the dev identity needed no auth change. Keep it so. +- **The server must be told:** `LEVELCODE_EXTRA_EDITOR_SCHEMES=levelcode-dev` on the backend the dev + editor signs in to (thin.ly `Levelcode::EditorCallback`). Off by default, and it only ever accepts + `levelcode-`. Symptom when missing: the browser lands on the account page and the editor + hears nothing. +- A LaunchServices handler must live outside temp folders — a bundle under `/tmp` is registered but + never chosen. Tests therefore run on fixtures and do not register anything (`test/editorIdentity.test.js`). + ## Toolchain (hard requirements — these bit us) - **Node = `vscode/.nvmrc` (currently 24.15.0)**. Older majors fail to compile native modules. diff --git a/README.md b/README.md index 64f7ca6..f731685 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ LevelCode is a **clean overlay on top of Code-OSS**, not a vendored copy of the ./scripts/make-dmg.sh # wrap it into a distributable .dmg ``` +**A run from source is its own app.** `run-dev.sh` gives the dev editor its own macOS identity — bundle id `ai.levelcode.app.dev` and the `levelcode-dev://` scheme (`branding/product.dev.json`) — so it can sit beside an installed LevelCode without the two answering each other's links. Signing in from it needs a server that accepts that scheme: set `LEVELCODE_EXTRA_EDITOR_SCHEMES=levelcode-dev` on the backend it signs in to. No server accepts it otherwise, and a sign-in that ends on the account page in the browser, with the editor hearing nothing, is the sign that it is not set. + See [`CLAUDE.md`](./CLAUDE.md) for the full repo map + build details and [`PLAN.md`](./PLAN.md) for the roadmap. ## Status diff --git a/branding/product.dev.json b/branding/product.dev.json new file mode 100644 index 0000000..554b0e1 --- /dev/null +++ b/branding/product.dev.json @@ -0,0 +1,6 @@ +{ + "_comment": "The identity of LevelCode RUN FROM SOURCE (scripts/run-dev.sh) — never of a build. To macOS the dev editor and the installed app were one app: same bundle id, same levelcode:// scheme, so a sign-in started in the dev editor was handed back to the app in /Applications. scripts/editor-identity.mjs gives the dev run these two values instead: at runtime through vscode/product.overrides.json (read only when running from source), and on the dev Electron bundle's Info.plist. branding/product.overlay.json — the product that ships — is not touched by any of it. The scheme must be levelcode-: that is the only shape a server can be told to accept (LEVELCODE_EXTRA_EDITOR_SCHEMES, thin.ly).", + + "urlProtocol": "levelcode-dev", + "darwinBundleIdentifier": "ai.levelcode.app.dev" +} diff --git a/extensions/levelcode-ai/test/editorIdentity.test.js b/extensions/levelcode-ai/test/editorIdentity.test.js new file mode 100644 index 0000000..a9ce8ab --- /dev/null +++ b/extensions/levelcode-ai/test/editorIdentity.test.js @@ -0,0 +1,421 @@ +/*--------------------------------------------------------------------------------------------- + * Which app the editor is, to the operating system — run: node test/editorIdentity.test.js + * + * A LevelCode run from source and the LevelCode in /Applications were one app to macOS: the same + * bundle identifier, the same levelcode:// scheme. A sign-in started in the dev editor was + * therefore handed back to the installed one. scripts/editor-identity.mjs gives the dev run an + * identity of its own, and refuses to let that identity be packaged. Pinned here: + * + * - the two identities differ in BOTH parts, and the dev scheme is one a server can accept + * - product.overrides.json gains the identity and keeps whatever else the developer put there + * - the dev bundle's Info.plist changes in its identifier and its URL scheme — and nowhere else + * - doing it twice changes nothing + * - a built app with a dev identity, or with an overrides file in it, fails the release check + * - the sign-in callback is built from the editor's OWN scheme: the reason no auth code changed + * + * Everything runs on fixtures in a temp directory, on any OS — nothing here touches a real + * checkout, a real bundle, or LaunchServices. That last step (the system routing a + * levelcode-dev:// link to the dev bundle) is macOS's, and is not exercised by this file. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { pathToFileURL } = require('url'); + +const EXT_DIR = path.join(__dirname, '..'); +const REPO = path.join(EXT_DIR, '..', '..'); +const SCRIPT = path.join(REPO, 'scripts', 'editor-identity.mjs'); +const read = (...p) => fs.readFileSync(path.join(REPO, ...p), 'utf8'); + +let n = 0; +async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name); } + +// ── fixtures ───────────────────────────────────────────────────────────────────────────────────── +const SHIPPED = { urlProtocol: 'levelcode', darwinBundleIdentifier: 'ai.levelcode.app' }; +const DEV = { urlProtocol: 'levelcode-dev', darwinBundleIdentifier: 'ai.levelcode.app.dev' }; + +/** An Info.plist shaped like the one the Electron bundle is generated with: other bundle keys, a + * document-type list full of arrays and strings BEFORE the URL types, and one URL type. */ +function infoPlist(identity = SHIPPED, schemes = [identity.urlProtocol]) { + return [ + '', + '', + '', + ' ', + ' CFBundleDisplayName', + ' LevelCode', + ' CFBundleExecutable', + ' LevelCode', + ' CFBundleIdentifier', + ' ' + identity.darwinBundleIdentifier + '', + ' CFBundleName', + ' LevelCode', + ' CFBundleDocumentTypes', + ' ', + ' ', + ' CFBundleTypeExtensions', + ' ', + ' js', + ' levelcode', + ' ', + ' CFBundleTypeName', + ' levelcode document', + ' ', + ' ', + ' CFBundleURLTypes', + ' ', + ' ', + ' CFBundleTypeRole', + ' Viewer', + ' CFBundleURLName', + ' LevelCode', + ' CFBundleURLSchemes', + ' ', + ...schemes.map((s) => ' ' + s + ''), + ' ', + ' ', + ' ', + ' ', + '', + '' + ].join('\n'); +} + +const made = []; +function tmp() { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'levelcode-identity-')); made.push(d); return d; } +function write(file, text) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); return file; } + +/** A stand-in Code-OSS checkout: product.json, and the dev Electron bundle run-dev.sh launches. */ +function checkout({ bundle = true, overrides = null } = {}) { + const dir = tmp(); + write(path.join(dir, 'product.json'), JSON.stringify({ nameLong: 'LevelCode', nameShort: 'LevelCode', ...SHIPPED }, null, '\t')); + const plist = path.join(dir, '.build', 'electron', 'LevelCode.app', 'Contents', 'Info.plist'); + if (bundle) { write(plist, infoPlist()); } + if (overrides !== null) { write(path.join(dir, 'product.overrides.json'), overrides); } + return { dir, plist, overrides: path.join(dir, 'product.overrides.json') }; +} + +/** A stand-in BUILT app, as scripts/build-macos.sh leaves it. */ +function builtApp({ plist = SHIPPED, product = SHIPPED, overrides = false } = {}) { + const app = path.join(tmp(), 'LevelCode.app'); + write(path.join(app, 'Contents', 'Info.plist'), infoPlist(plist)); + if (product) { write(path.join(app, 'Contents', 'Resources', 'app', 'product.json'), JSON.stringify({ nameLong: 'LevelCode', ...product })); } + if (overrides) { write(path.join(app, 'Contents', 'Resources', 'app', 'product.overrides.json'), JSON.stringify(DEV)); } + return app; +} + +const changedLines = (before, after) => { + const a = before.split('\n'), b = after.split('\n'); + assert.strictEqual(a.length, b.length, 'the file has the same number of lines'); + return a.map((line, i) => (line === b[i] ? null : [line.trim(), b[i].trim()])).filter(Boolean); +}; + +// ── the sign-in function, sliced out of extension.js ──────────────────────────────────────────── +const src = fs.readFileSync(path.join(EXT_DIR, 'extension.js'), 'utf8'); +function extract(name) { + let start = src.indexOf('function ' + name + '('); + assert.ok(start >= 0, 'extension.js no longer defines ' + name + '()'); + const open = src.indexOf('{', start); + if (src.slice(start - 6, start) === 'async ') { start -= 6; } + let depth = 0, str = '', comment = ''; + for (let i = open; i < src.length; i++) { + const ch = src[i], next = src[i + 1]; + if (comment === 'line') { if (ch === '\n') { comment = ''; } continue; } + if (comment === 'block') { if (ch === '*' && next === '/') { comment = ''; i++; } continue; } + if (str) { if (ch === '\\') { i++; } else if (ch === str) { str = ''; } continue; } + if (ch === '/' && next === '/') { comment = 'line'; i++; continue; } + if (ch === '/' && next === '*') { comment = 'block'; i++; continue; } + if (ch === '"' || ch === "'" || ch === '`') { str = ch; continue; } + if (ch === '{') { depth++; } + else if (ch === '}' && --depth === 0) { return src.slice(start, i + 1); } + } + assert.fail('no matching closing brace found for ' + name + '()'); +} +function decl(name) { + const m = new RegExp('^(?:const|let) ' + name + ' = [^\\n]*', 'm').exec(src); + assert.ok(m, 'extension.js no longer declares ' + name); + return m[0]; +} +/** A function written on ONE line. extract() matches braces and skips comments, and would read the + * `//` inside a regex literal such as /\//g as the start of one. */ +function oneLine(name) { + const m = new RegExp('^function ' + name + '\\([^\\n]*\\}$', 'm').exec(src); + assert.ok(m, 'extension.js no longer defines ' + name + '() on one line'); + return m[0]; +} +// eslint-disable-next-line no-new-func +const makeSignIn = new Function('env', [ + "'use strict';", + 'const { vscode, crypto, ctx, dbg, postAccount } = env;', + decl('ACCOUNT_VERIFIER_KEY'), + oneLine('b64url'), extract('pkcePair'), oneLine('cloudEndpoint'), extract('accountSignIn'), + 'return { accountSignIn };' +].join('\n')); + +/** Run the real accountSignIn() in an editor whose product scheme is `uriScheme`; returns the URL it opens. */ +async function signInUrl(uriScheme) { + const opened = []; + const Uri = { parse: (s) => ({ toString: () => String(s) }) }; + const host = makeSignIn({ + crypto: require('crypto'), + ctx: { secrets: { store: async () => { } } }, + dbg: () => { }, + postAccount: async () => { }, + vscode: { + Uri, + workspace: { getConfiguration: () => ({ get: (key, fallback) => (key === 'endpoint' ? 'https://cloud.test' : fallback) }) }, + window: { showInformationMessage: () => { } }, + env: { + uriScheme, + // As the editor does it: the same address, with the window to route the callback to. + asExternalUri: async (uri) => Uri.parse(uri.toString() + '?windowId=1'), + openExternal: async (uri) => { opened.push(uri.toString()); return true; } + } + } + }); + await host.accountSignIn(); + assert.strictEqual(opened.length, 1, 'one browser page is opened'); + return new URL(opened[0]); +} + +(async () => { + const identity = await import(pathToFileURL(SCRIPT).href); + + // ── the two identities ─────────────────────────────────────────────────────────────────────── + await test('the product that ships is levelcode:// and ai.levelcode.app — the dev identity changes neither', () => { + assert.deepStrictEqual(identity.shippedIdentity(), SHIPPED); + const overlay = JSON.parse(read('branding', 'product.overlay.json')); + assert.strictEqual(overlay.urlProtocol, 'levelcode'); + assert.strictEqual(overlay.darwinBundleIdentifier, 'ai.levelcode.app'); + }); + + await test('a run from source differs from it in BOTH parts: its own scheme, its own bundle identifier', () => { + assert.deepStrictEqual(identity.devIdentity(), DEV); + }); + + await test('the dev scheme has the one shape a server can be told to accept: levelcode-', () => { + // thin.ly's Levelcode::EditorCallback::EXTRA_SCHEME is the same expression. A dev scheme that + // does not fit it can be set here and still never complete a sign-in. + assert.strictEqual(String(identity.DEV_SCHEME), String(/^levelcode-[a-z0-9]+(?:[.-][a-z0-9]+)*$/)); + assert.ok(identity.DEV_SCHEME.test(identity.devIdentity().urlProtocol)); + for (const no of ['levelcode', 'levelcode-', 'levelcode_dev', 'https', 'dev', 'LevelCode-Dev', 'levelcode--dev']) { + assert.ok(!identity.DEV_SCHEME.test(no), no); + } + }); + + await test('a dev identity that shares either part with the shipped one is refused — sharing one IS the bug', () => { + const dir = tmp(); + const file = (json) => write(path.join(dir, 'dev-' + Math.random().toString(36).slice(2) + '.json'), JSON.stringify(json)); + assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode-dev', darwinBundleIdentifier: 'ai.levelcode.app' }), SHIPPED), /must differ/); + assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode', darwinBundleIdentifier: 'ai.levelcode.app.dev' }), SHIPPED), /levelcode-dev/); + assert.throws(() => identity.devIdentity(file({ urlProtocol: 'https', darwinBundleIdentifier: 'ai.levelcode.app.dev' }), SHIPPED), /levelcode-dev/); + assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode-dev' }), SHIPPED), /must name both/); + assert.deepStrictEqual(identity.devIdentity(file(DEV), SHIPPED), DEV); + }); + + // ── what the editor believes at runtime: product.overrides.json ────────────────────────────── + await test('overrides: with no file yet, one is written holding the identity', () => { + const r = identity.mergeOverrides(null, DEV); + assert.strictEqual(r.changed, true); + assert.deepStrictEqual(JSON.parse(r.text), DEV); + }); + + await test('overrides: whatever else the developer keeps there stays', () => { + const theirs = JSON.stringify({ extensionsGallery: { serviceUrl: 'https://example.test' }, urlProtocol: 'something-old' }); + const r = identity.mergeOverrides(theirs, DEV); + assert.strictEqual(r.changed, true); + assert.deepStrictEqual(JSON.parse(r.text), { extensionsGallery: { serviceUrl: 'https://example.test' }, ...DEV }); + }); + + await test('overrides: already carrying the identity, the file is left exactly as it is', () => { + const theirs = '{ "darwinBundleIdentifier": "ai.levelcode.app.dev",\n\n "urlProtocol": "levelcode-dev", "x": 1 }\n'; + const r = identity.mergeOverrides(theirs, DEV); + assert.deepStrictEqual(r, { text: theirs, changed: false }); + }); + + await test('overrides: a file that is not a JSON object is the developer\'s to fix, not ours to overwrite', () => { + assert.throws(() => identity.mergeOverrides('{ "urlProtocol": ', DEV), /not valid JSON/); + assert.throws(() => identity.mergeOverrides('[]', DEV), /JSON object/); + assert.deepStrictEqual(JSON.parse(identity.mergeOverrides(' \n', DEV).text), DEV, 'an empty file is no file'); + }); + + // ── what macOS believes: the bundle's Info.plist ───────────────────────────────────────────── + await test('plist: the bundle identifier and the URL schemes are read from where they are, not from look-alikes', () => { + // "levelcode" also appears as a document extension and inside a type name. + assert.deepStrictEqual(identity.plistIdentity(infoPlist()), { darwinBundleIdentifier: 'ai.levelcode.app', urlSchemes: ['levelcode'] }); + }); + + await test('plist: the dev identity changes two lines — the identifier and the scheme — and nothing else', () => { + const before = infoPlist(); + const r = identity.withIdentity(before, DEV); + assert.strictEqual(r.changed, true); + assert.deepStrictEqual(r.was, { darwinBundleIdentifier: 'ai.levelcode.app', urlSchemes: ['levelcode'] }); + assert.deepStrictEqual(changedLines(before, r.xml), [ + ['ai.levelcode.app', 'ai.levelcode.app.dev'], + ['levelcode', 'levelcode-dev'] + ]); + assert.deepStrictEqual(identity.plistIdentity(r.xml), { darwinBundleIdentifier: DEV.darwinBundleIdentifier, urlSchemes: [DEV.urlProtocol] }); + assert.strictEqual(r.xml, infoPlist(DEV), 'exactly the file a bundle generated with that identity would have'); + }); + + await test('plist: done twice, the second changes nothing', () => { + const once = identity.withIdentity(infoPlist(), DEV).xml; + const twice = identity.withIdentity(once, DEV); + assert.strictEqual(twice.changed, false); + assert.strictEqual(twice.xml, once); + }); + + await test('plist: a bundle that claims the shipped scheme AS WELL ends up claiming the dev one alone', () => { + // Claiming both would put the dev bundle back in the running for levelcode:// links. + const r = identity.withIdentity(infoPlist(DEV, ['levelcode', 'levelcode-dev']), DEV); + assert.strictEqual(r.changed, true); + assert.deepStrictEqual(identity.plistIdentity(r.xml).urlSchemes, ['levelcode-dev']); + assert.strictEqual(r.xml, infoPlist(DEV)); + }); + + await test('plist: a file that is not the shape it is generated in is a reason to stop, not to guess', () => { + assert.throws(() => identity.plistIdentity('bplist00Ô\u0001'), /expected one CFBundleIdentifier, found 0/); + const noUrlTypes = infoPlist().replace(/CFBundleURLTypes<\/key>[\s\S]*?<\/array>\s*<\/dict>\s*<\/array>\n/, ''); + assert.throws(() => identity.withIdentity(noUrlTypes, DEV), /expected one CFBundleURLSchemes list, found 0/); + const twoUrlTypes = infoPlist().replace('CFBundleURLTypes', 'CFBundleURLSchemes\n \n other\n \n CFBundleURLTypes'); + assert.throws(() => identity.withIdentity(twoUrlTypes, DEV), /found 2/); + }); + + // ── both halves, on a checkout ─────────────────────────────────────────────────────────────── + await test('dev: a checkout gets both halves — the overrides file and the bundle — and a second run touches neither', () => { + const c = checkout({ overrides: JSON.stringify({ extensionsGallery: { serviceUrl: 'https://example.test' } }) }); + const log = []; + const first = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false, log: (l) => log.push(l) }); + assert.deepStrictEqual({ overridesChanged: first.overridesChanged, bundleChanged: first.bundleChanged, registered: first.registered }, { overridesChanged: true, bundleChanged: true, registered: false }); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(c.overrides, 'utf8')), { extensionsGallery: { serviceUrl: 'https://example.test' }, ...DEV }); + assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist(DEV)); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(c.dir, 'product.json'), 'utf8')).urlProtocol, 'levelcode', 'product.json is still the product that ships'); + assert.ok(log.some((l) => /was ai\.levelcode\.app, levelcode:\/\//.test(l)), log.join(' | ')); + + const stamp = [c.overrides, c.plist].map((f) => fs.statSync(f).mtimeMs); + const second = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false }); + assert.deepStrictEqual({ overridesChanged: second.overridesChanged, bundleChanged: second.bundleChanged }, { overridesChanged: false, bundleChanged: false }); + assert.deepStrictEqual([c.overrides, c.plist].map((f) => fs.statSync(f).mtimeMs), stamp, 'neither file was rewritten'); + }); + + await test('dev: a regenerated bundle (a new Electron) is given the identity again', () => { + const c = checkout(); + identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false }); + write(c.plist, infoPlist()); // npm run electron wrote a fresh one, from product.json + const again = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false }); + assert.deepStrictEqual({ overridesChanged: again.overridesChanged, bundleChanged: again.bundleChanged }, { overridesChanged: false, bundleChanged: true }); + assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist(DEV)); + }); + + await test('dev: all or nothing — when either half cannot be done, nothing is written', () => { + // One half without the other is worse than neither: the editor would ask to be called back on + // a scheme nothing claims, or go on asking for the installed app's. + const noBundle = checkout({ bundle: false }); + assert.throws(() => identity.applyDevIdentity({ vscodeDir: noBundle.dir, platform: 'darwin', register: false }), /no dev Electron bundle at .*preLaunch/); + assert.strictEqual(fs.existsSync(noBundle.overrides), false, 'no overrides file is left behind'); + + const oddBundle = checkout(); + write(oddBundle.plist, 'bplist00'); // not the generated XML + assert.throws(() => identity.applyDevIdentity({ vscodeDir: oddBundle.dir, platform: 'darwin', register: false }), /expected one CFBundleIdentifier/); + assert.strictEqual(fs.existsSync(oddBundle.overrides), false); + + const badOverrides = checkout({ overrides: '{ not json' }); + assert.throws(() => identity.applyDevIdentity({ vscodeDir: badOverrides.dir, platform: 'darwin', register: false }), /not valid JSON/); + assert.strictEqual(fs.readFileSync(badOverrides.plist, 'utf8'), infoPlist(), 'the bundle is left as it was'); + assert.strictEqual(fs.readFileSync(badOverrides.overrides, 'utf8'), '{ not json', 'and so is their file'); + + assert.throws(() => identity.applyDevIdentity({ vscodeDir: tmp(), platform: 'darwin', register: false }), /no product\.json/); + }); + + await test('dev: off macOS the runtime half is written and the log says the callback will not arrive', () => { + const c = checkout(); + const log = []; + const r = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'linux', register: false, log: (l) => log.push(l) }); + assert.deepStrictEqual({ bundle: r.bundle, bundleChanged: r.bundleChanged, overridesChanged: r.overridesChanged }, { bundle: null, bundleChanged: false, overridesChanged: true }); + assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist(), 'the bundle is not touched'); + assert.ok(log.some((l) => /not macOS \(linux\).*will not reach this editor/.test(l)), log.join(' | ')); + }); + + // ── a build must be the app that ships ─────────────────────────────────────────────────────── + await test('release check: an app with the shipped identity passes', () => { + assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp()), []); + }); + + await test('release check: a dev identity on the bundle, or in its product.json, is named and refused', () => { + assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp({ plist: DEV })), [ + 'bundle identifier is ai.levelcode.app.dev, not ai.levelcode.app', + 'URL schemes are [levelcode-dev], not [levelcode]' + ]); + assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp({ product: DEV })), [ + 'product.json urlProtocol is "levelcode-dev", not "levelcode"', + 'product.json darwinBundleIdentifier is "ai.levelcode.app.dev", not "ai.levelcode.app"' + ]); + }); + + await test('release check: an overrides file inside the app, a missing product.json, a missing Info.plist — each fails', () => { + assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp({ overrides: true })), ['product.overrides.json was packaged — it is for runs from source only']); + assert.strictEqual(identity.releaseIdentityProblems(builtApp({ product: null })).length, 1); + assert.match(identity.releaseIdentityProblems(path.join(tmp(), 'Nothing.app'))[0], /no Info\.plist/); + }); + + // ── the command line, as run-dev.sh and build-macos.sh call it ─────────────────────────────── + await test('command line: check-release exits 0 for the shipped identity and 1, naming the problem, for any other', () => { + const ok = spawnSync(process.execPath, [SCRIPT, 'check-release', builtApp()], { encoding: 'utf8' }); + assert.strictEqual(ok.status, 0, ok.stderr); + const bad = spawnSync(process.execPath, [SCRIPT, 'check-release', builtApp({ plist: DEV })], { encoding: 'utf8' }); + assert.strictEqual(bad.status, 1); + assert.match(bad.stderr, /does NOT carry the shipped identity[\s\S]*bundle identifier is ai\.levelcode\.app\.dev/); + }); + + await test('command line: dev applies the identity and says which server setting sign-in needs; no arguments is a usage error', () => { + const c = checkout(); + // --no-register: a fixture in a temp directory is not something to tell LaunchServices about. + const run = spawnSync(process.execPath, [SCRIPT, 'dev', c.dir, '--no-register'], { encoding: 'utf8' }); + if (process.platform === 'darwin') { + assert.strictEqual(run.status, 0, run.stderr); + assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist(DEV)); + } + assert.deepStrictEqual(JSON.parse(fs.readFileSync(c.overrides, 'utf8')), DEV); + assert.match(run.stdout, /LEVELCODE_EXTRA_EDITOR_SCHEMES=levelcode-dev/); + assert.strictEqual(spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8' }).status, 2); + const missing = spawnSync(process.execPath, [SCRIPT, 'dev', tmp(), '--no-register'], { encoding: 'utf8' }); + assert.strictEqual(missing.status, 1); + assert.match(missing.stderr, /no product\.json/); + }); + + // ── why no auth code had to change ─────────────────────────────────────────────────────────── + await test('sign-in asks to be called back on the editor\'s OWN scheme — whatever the product says it is', async () => { + for (const scheme of ['levelcode', 'levelcode-dev']) { + const url = await signInUrl(scheme); + assert.strictEqual(url.origin + url.pathname, 'https://cloud.test/ai/login'); + assert.strictEqual(url.searchParams.get('redirect_uri'), scheme + '://levelcode.levelcode-ai/auth/callback?windowId=1', scheme); + assert.ok(url.searchParams.get('code_challenge'), 'bound to a PKCE challenge'); + } + }); + + // ── what can only be read ──────────────────────────────────────────────────────────────────── + await test('run-dev.sh sets the identity after the bundle exists and before the editor starts', () => { + const sh = read('scripts', 'run-dev.sh'); + const at = (needle) => { const i = sh.indexOf(needle); assert.ok(i >= 0, 'run-dev.sh no longer has: ' + needle); return i; }; + const order = [at('node build/lib/preLaunch.ts'), at('editor-identity.mjs" dev "$VSCODE_DIR"'), at('VSCODE_SKIP_PRELAUNCH=1 ./scripts/code.sh')]; + assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), 'preLaunch, then the identity, then the launch'); + }); + + await test('build-macos.sh checks the built app\'s identity before it does anything else to it', () => { + const sh = read('scripts', 'build-macos.sh'); + const check = sh.indexOf('editor-identity.mjs" check-release "$BUILT_APP/LevelCode.app"'); + assert.ok(check >= 0, 'build-macos.sh no longer runs the release identity check'); + assert.ok(check > sh.indexOf('npm run gulp -- "$GULP_TARGET"'), 'after the build'); + assert.ok(check < sh.indexOf('strip-proprietary.mjs'), 'before the strip steps'); + assert.match(sh, /^set -euo pipefail$/m, 'and a failing check stops the script'); + }); + + console.log('\neditorIdentity: ' + n + ' tests passed.'); +})().catch((e) => { console.error(e); process.exitCode = 1; }).finally(() => { + for (const d of made) { fs.rmSync(d, { recursive: true, force: true }); } +}); diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index a878ff3..8e8fa90 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -74,6 +74,12 @@ npm run gulp -- "$GULP_TARGET" APP_PARENT="$(cd "$VSCODE_DIR/.." && pwd)" BUILT_APP="$APP_PARENT/$OUT_DIR" +# A build must be the app that ships: its bundle identifier, its levelcode:// scheme, and no +# overrides file. A run from source has an identity of its own (run-dev.sh), and this is what stops +# that one from ever being packaged. Checked first — nothing below is worth doing to the wrong app. +echo "[build] Checking the app carries the shipped identity …" +node "$SCRIPT_DIR/editor-identity.mjs" check-release "$BUILT_APP/LevelCode.app" + # De-Microsoft: strip the proprietary Copilot/MS packages from the BUILT APP — NOT the source checkout, # so dev-mode typecheck (run-dev.sh → tsgo) still sees the real type declarations. Removes ~120 MB of # non-redistributable code from the shipped bundle. Idempotent + loud. diff --git a/scripts/editor-identity.mjs b/scripts/editor-identity.mjs new file mode 100755 index 0000000..93a6284 --- /dev/null +++ b/scripts/editor-identity.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * LevelCode — which app the editor is, to the operating system. + * + * Usage: node scripts/editor-identity.mjs dev [--no-register] + * node scripts/editor-identity.mjs check-release + * + * WHY THIS EXISTS + * + * A LevelCode run from source (scripts/run-dev.sh) and the LevelCode in /Applications were the same + * app as far as macOS could tell: one bundle identifier, one URL scheme. Sign-in ends with the + * browser opening levelcode://…/auth/callback, and macOS decides which app that belongs to. It + * picked the installed one. The dev editor that had asked never heard back, and the installed + * editor was handed a callback for a sign-in it had not started. + * + * The sign-in code needs no change for this: it builds its callback from the editor's own scheme + * (vscode.env.uriScheme). What was missing is a scheme — and a bundle identifier — of the dev + * run's own. A scheme alone is not enough: with one shared identifier macOS can still hand a + * launch, or a link, to whichever copy is running. + * + * `dev` gives the checkout that identity, in the two places it lives: + * + * 1. vscode/product.overrides.json — what the editor believes at RUNTIME. Code-OSS reads this + * file only when running from source, and never packages it, so product.json stays the + * product that ships. Keys a developer has put there themselves are kept. + * 2. The dev Electron bundle's Info.plist — what MACOS believes. The bundle is generated from + * product.json, and regenerated only when the Electron version changes, so its identifier + * and URL scheme are set here, and set again after a regeneration. Then the bundle is + * registered with LaunchServices, which is what actually routes the link. + * + * Both, or neither works: with only (1) the browser is sent to a scheme nothing claims; with only + * (2) the editor still asks to be called back on the installed app's. + * + * `check-release` is the other direction: a BUILT app must carry the shipped identity, and no + * overrides file. scripts/build-macos.sh runs it, so a dev identity cannot be packaged by accident. + * + * A server has to be told to accept the dev scheme (LEVELCODE_EXTRA_EDITOR_SCHEMES, thin.ly). When a + * dev sign-in ends on the account page in the browser and the editor hears nothing, that is why. + * + * The functions are exported so test/editorIdentity.test.js can run them — on a fixture, on any OS. + *--------------------------------------------------------------------------------------------*/ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const DEV_IDENTITY_FILE = join(REPO, 'branding', 'product.dev.json'); +export const SHIPPED_IDENTITY_FILE = join(REPO, 'branding', 'product.overlay.json'); +const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; + +/** The only shape a dev scheme may have: it is also the only shape a server can be told to accept. */ +export const DEV_SCHEME = /^levelcode-[a-z0-9]+(?:[.-][a-z0-9]+)*$/; + +/** @param {string} file @returns {{urlProtocol: string, darwinBundleIdentifier: string}} */ +function identityIn(file) { + const json = JSON.parse(readFileSync(file, 'utf8')); + const { urlProtocol, darwinBundleIdentifier } = json; + if (typeof urlProtocol !== 'string' || typeof darwinBundleIdentifier !== 'string' || !urlProtocol || !darwinBundleIdentifier) { + throw new Error(`${file} must name both urlProtocol and darwinBundleIdentifier`); + } + return { urlProtocol, darwinBundleIdentifier }; +} + +/** The identity of the product that ships. */ +export function shippedIdentity(file = SHIPPED_IDENTITY_FILE) { return identityIn(file); } + +/** + * The identity of a run from source. It has to differ from the shipped one in BOTH parts — sharing + * either is the bug this file exists for — and its scheme has to be one a server can accept. + */ +export function devIdentity(file = DEV_IDENTITY_FILE, shipped = shippedIdentity()) { + const dev = identityIn(file); + if (!DEV_SCHEME.test(dev.urlProtocol)) { + throw new Error(`the dev urlProtocol must look like levelcode-dev, not ${JSON.stringify(dev.urlProtocol)}`); + } + if (dev.urlProtocol === shipped.urlProtocol || dev.darwinBundleIdentifier === shipped.darwinBundleIdentifier) { + throw new Error('the dev identity must differ from the shipped one in both urlProtocol and darwinBundleIdentifier'); + } + return dev; +} + +/** + * product.overrides.json with the dev identity in it. Whatever else the developer keeps there stays. + * A file that is not JSON is theirs to fix, not ours to overwrite. + * @param {string|null} existing the file's text, or null when there is none + * @returns {{text: string, changed: boolean}} + */ +export function mergeOverrides(existing, identity) { + let current = {}; + if (existing !== null && existing.trim()) { + try { current = JSON.parse(existing); } + catch (e) { throw new Error('product.overrides.json is not valid JSON — fix or delete it: ' + e.message); } + if (!current || typeof current !== 'object' || Array.isArray(current)) { + throw new Error('product.overrides.json must hold a JSON object'); + } + } + const changed = current.urlProtocol !== identity.urlProtocol || current.darwinBundleIdentifier !== identity.darwinBundleIdentifier; + const merged = { ...current, urlProtocol: identity.urlProtocol, darwinBundleIdentifier: identity.darwinBundleIdentifier }; + return { text: changed || existing === null ? JSON.stringify(merged, null, '\t') + '\n' : existing, changed: changed || existing === null }; +} + +const IDENTIFIER = /(CFBundleIdentifier<\/key>\s*)([^<]*)(<\/string>)/g; +const URL_SCHEMES = /(CFBundleURLSchemes<\/key>\s*)([\s\S]*?)(<\/array>)/g; + +/** + * What an Info.plist says the bundle is. Strict on purpose: the file is generated, its shape is + * known, and anything else — a binary plist, two URL types — is a reason to stop, not to guess. + * @param {string} xml + * @returns {{darwinBundleIdentifier: string, urlSchemes: string[]}} + */ +export function plistIdentity(xml) { + const ids = [...String(xml).matchAll(IDENTIFIER)]; + const schemes = [...String(xml).matchAll(URL_SCHEMES)]; + if (ids.length !== 1) { throw new Error(`Info.plist: expected one CFBundleIdentifier, found ${ids.length}`); } + if (schemes.length !== 1) { throw new Error(`Info.plist: expected one CFBundleURLSchemes list, found ${schemes.length}`); } + return { + darwinBundleIdentifier: ids[0][2].trim(), + urlSchemes: [...schemes[0][2].matchAll(/([^<]*)<\/string>/g)].map((m) => m[1].trim()) + }; +} + +/** + * The same Info.plist, claiming `identity`: that bundle identifier, and that URL scheme ALONE. + * @param {string} xml + * @returns {{xml: string, changed: boolean, was: {darwinBundleIdentifier: string, urlSchemes: string[]}}} + */ +export function withIdentity(xml, identity) { + const was = plistIdentity(xml); + const changed = was.darwinBundleIdentifier !== identity.darwinBundleIdentifier + || was.urlSchemes.length !== 1 || was.urlSchemes[0] !== identity.urlProtocol; + if (!changed) { return { xml, changed, was }; } + const next = String(xml) + .replace(IDENTIFIER, (_all, open, _id, close) => open + identity.darwinBundleIdentifier + close) + .replace(URL_SCHEMES, (_all, open, inner, close) => { + const indent = (/\n([ \t]*)/.exec(inner) || [, ''])[1]; + const tail = (/\n[ \t]*$/.exec(inner) || [''])[0]; + return open + (indent || tail ? '\n' + indent : '') + '' + identity.urlProtocol + '' + tail + close; + }); + return { xml: next, changed, was }; +} + +/** + * Give the Code-OSS checkout at `vscodeDir` the dev identity. Idempotent — and all or nothing: + * everything that can refuse is asked BEFORE anything is written, because one half without the + * other is worse than neither (see the header). + * @param {{vscodeDir: string, identity?: any, register?: boolean, platform?: string, log?: (line: string) => void}} o + */ +export function applyDevIdentity(o) { + const log = o.log || (() => { }); + const identity = o.identity || devIdentity(); + const platform = o.platform || process.platform; + const productPath = join(o.vscodeDir, 'product.json'); + if (!existsSync(productPath)) { throw new Error(`no product.json in ${o.vscodeDir} — is that the Code-OSS checkout?`); } + + // 1. What the editor believes at runtime. + const overridesPath = join(o.vscodeDir, 'product.overrides.json'); + const overrides = mergeOverrides(existsSync(overridesPath) ? readFileSync(overridesPath, 'utf8') : null, identity); + + // 2. What macOS believes. The scheme of a dev run on other systems is registered differently + // (a .desktop file, the registry) and is not handled here. + let bundle = null, plistPath = null, plist = null, name = ''; + if (platform === 'darwin') { + name = JSON.parse(readFileSync(productPath, 'utf8')).nameLong; + bundle = join(o.vscodeDir, '.build', 'electron', name + '.app'); + plistPath = join(bundle, 'Contents', 'Info.plist'); + if (!existsSync(plistPath)) { throw new Error(`no dev Electron bundle at ${bundle} — it is created on first launch (node build/lib/preLaunch.ts)`); } + plist = withIdentity(readFileSync(plistPath, 'utf8'), identity); + } + + if (overrides.changed) { writeFileSync(overridesPath, overrides.text, 'utf8'); } + log(`product.overrides.json: ${identity.urlProtocol}:// (${overrides.changed ? 'written' : 'already set'})`); + if (!plist) { + log(`not macOS (${platform}): the URL scheme is not registered with the system — the callback will not reach this editor`); + return { identity, overridesChanged: overrides.changed, bundle: null, bundleChanged: false, registered: false }; + } + if (plist.changed) { writeFileSync(plistPath, plist.xml, 'utf8'); } + log(`${name}.app: ${identity.darwinBundleIdentifier} (${plist.changed ? 'was ' + plist.was.darwinBundleIdentifier + ', ' + (plist.was.urlSchemes.join(', ') || 'no scheme') + '://' : 'already set'})`); + + // Registered every time, not only after a change: it is what routes the link, it is cheap, and a + // rebuilt LaunchServices database forgets a bundle that was only ever launched from a shell. + let registered = false; + if (o.register !== false) { + const r = spawnSync(LSREGISTER, ['-f', bundle], { encoding: 'utf8' }); + registered = r.status === 0; + log(registered ? `registered ${identity.urlProtocol}:// with LaunchServices` : `could not register with LaunchServices (${(r.stderr || r.error || 'lsregister failed').toString().trim()})`); + } + return { identity, overridesChanged: overrides.changed, bundle, bundleChanged: plist.changed, registered }; +} + +/** + * Everything wrong with the identity of a BUILT app — an empty list is a pass. + * @param {string} app path to LevelCode.app + * @returns {string[]} + */ +export function releaseIdentityProblems(app, shipped = shippedIdentity()) { + const problems = []; + const plistPath = join(app, 'Contents', 'Info.plist'); + const resources = join(app, 'Contents', 'Resources', 'app'); + if (!existsSync(plistPath)) { return [`no Info.plist at ${plistPath}`]; } + try { + const is = plistIdentity(readFileSync(plistPath, 'utf8')); + if (is.darwinBundleIdentifier !== shipped.darwinBundleIdentifier) { + problems.push(`bundle identifier is ${is.darwinBundleIdentifier}, not ${shipped.darwinBundleIdentifier}`); + } + if (is.urlSchemes.length !== 1 || is.urlSchemes[0] !== shipped.urlProtocol) { + problems.push(`URL schemes are [${is.urlSchemes.join(', ')}], not [${shipped.urlProtocol}]`); + } + } catch (e) { problems.push(String(e.message || e)); } + const productPath = join(resources, 'product.json'); + if (!existsSync(productPath)) { problems.push(`no product.json at ${productPath}`); } + else { + const product = JSON.parse(readFileSync(productPath, 'utf8')); + for (const key of ['urlProtocol', 'darwinBundleIdentifier']) { + if (product[key] !== shipped[key]) { problems.push(`product.json ${key} is ${JSON.stringify(product[key])}, not ${JSON.stringify(shipped[key])}`); } + } + } + if (existsSync(join(resources, 'product.overrides.json'))) { problems.push('product.overrides.json was packaged — it is for runs from source only'); } + return problems; +} + +// ---- command line ------------------------------------------------------------------------------ + +function main(argv) { + const [command, target, ...flags] = argv; + const say = (line) => console.log('[editor-identity] ' + line); + if (command === 'dev' && target) { + const result = applyDevIdentity({ vscodeDir: resolve(target), register: !flags.includes('--no-register'), log: say }); + say(`sign-in needs a server that accepts ${result.identity.urlProtocol}:// — LEVELCODE_EXTRA_EDITOR_SCHEMES=${result.identity.urlProtocol} on the backend`); + return 0; + } + if (command === 'check-release' && target) { + const problems = releaseIdentityProblems(resolve(target)); + if (!problems.length) { say(`${target} carries the shipped identity`); return 0; } + console.error('\x1b[31m[editor-identity] ' + target + ' does NOT carry the shipped identity:\x1b[0m'); + for (const p of problems) { console.error(' - ' + p); } + return 1; + } + console.error('usage: node scripts/editor-identity.mjs dev [--no-register]\n node scripts/editor-identity.mjs check-release '); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { process.exit(main(process.argv.slice(2))); } + catch (e) { console.error('\x1b[31m[editor-identity] ' + String((e && e.message) || e) + '\x1b[0m'); process.exit(1); } +} diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh index 627b97c..c85d7c2 100755 --- a/scripts/run-dev.sh +++ b/scripts/run-dev.sh @@ -20,16 +20,22 @@ echo "[run-dev] Building core (first run takes a while)…" # below) and strips it from the packaged app (build-macos.sh), so compiling it is pointless here — and # upstream's extensions/copilot/.esbuild.mts fails under Node 24 (glob CJS/ESM named-export error). npm run compile-client -echo "[run-dev] Ensuring a clean LevelCode instance…" -# LevelCode dev + packaged builds share the same macOS bundle identifier. If another -# instance is already running, LaunchServices can hand this launch off to that -# process, making it look like new LevelCode features/commands disappeared. -pkill -u "$USER" -f '/Atom\+\+\.app/Contents/MacOS/Atom\+\+' >/dev/null 2>&1 && \ - echo "[run-dev] Killed existing LevelCode instance(s) — save your work first." || true +# A LevelCode run from source is its OWN app to macOS: its own bundle identifier and its own URL +# scheme (branding/product.dev.json). Sharing the installed app's meant a sign-in started here was +# handed back to the LevelCode in /Applications — the browser's levelcode:// callback goes to +# whichever app macOS picks, and it picked that one. See scripts/editor-identity.mjs. +# +# The identity is set on the dev Electron bundle, so the bundle has to exist first: preLaunch is +# what code.sh would run anyway (it fetches Electron on first launch), run here so the identity can +# go on before the editor starts — and skipped below so it does not run twice. +echo "[run-dev] Giving the dev editor its own identity…" +node build/lib/preLaunch.ts +node "$SCRIPT_DIR/editor-identity.mjs" dev "$VSCODE_DIR" +# A dev editor that is already open is joined, not replaced: quit it first to load rebuilt code. echo "[run-dev] Launching LevelCode (dev)… (Copilot disabled to match the packaged app)" # In dev, built-in extensions load from source — the proprietary Copilot extension # would otherwise appear. Disable it so dev matches the shipped (Copilot-free) app. # NB: workspace trust is left ENABLED — it is a security boundary, not a UX nag. If the # trust dialog is disruptive during development, use a throwaway --user-data-dir profile # or pre-trust the workspace path instead of disabling trust globally. -./scripts/code.sh --new-window --disable-extension GitHub.copilot-chat "$@" +VSCODE_SKIP_PRELAUNCH=1 ./scripts/code.sh --new-window --disable-extension GitHub.copilot-chat "$@" From 83209c35c06eaee8dbce56bdf063cb8d207287ed Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 12:22:30 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(dev):=20#99=20review=20=E2=80=94=20the?= =?UTF-8?q?=20identity=20changes=20as=20one,=20and=20the=20launch=20waits?= =?UTF-8?q?=20for=20macOS=20to=20agree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 4 + .../levelcode-ai/test/editorIdentity.test.js | 170 +++++++++++++++++- scripts/editor-identity.mjs | 147 +++++++++++++-- 3 files changed, 307 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ae04396..a4849db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,10 @@ To macOS a run from source and the installed LevelCode used to be ONE app — sa - **Two halves, both required.** Runtime: `vscode/product.overrides.json` (Code-OSS reads it only when running from source, never packages it). macOS: the dev Electron bundle's `Info.plist`, then `lsregister`. The bundle is regenerated when Electron changes, so the step runs on every launch. +- **Both or neither, and confirmed.** The two files are replaced as one change (staged, renamed, undone + if the second rename fails). Then the step asks macOS which app opens `levelcode-dev://` and FAILS — + `run-dev.sh` stops before launching — unless the answer is this bundle. `lsregister` exiting 0 is + not that answer: it registers a bundle it will never route to. - **`branding/product.overlay.json` is the product that ships — never put a dev value in it.** `build-macos.sh` runs `editor-identity.mjs check-release` and fails a build that is not `levelcode://` + `ai.levelcode.app`, or that carries an overrides file. diff --git a/extensions/levelcode-ai/test/editorIdentity.test.js b/extensions/levelcode-ai/test/editorIdentity.test.js index a9ce8ab..6e4bc67 100644 --- a/extensions/levelcode-ai/test/editorIdentity.test.js +++ b/extensions/levelcode-ai/test/editorIdentity.test.js @@ -10,12 +10,17 @@ * - product.overrides.json gains the identity and keeps whatever else the developer put there * - the dev bundle's Info.plist changes in its identifier and its URL scheme — and nowhere else * - doing it twice changes nothing + * - the two files change as ONE change: a write that fails leaves both as they were, and a + * rename that fails half-way is undone + * - the step fails unless macOS will route the dev scheme to this bundle — told is not routed * - a built app with a dev identity, or with an overrides file in it, fails the release check * - the sign-in callback is built from the editor's OWN scheme: the reason no auth code changed * * Everything runs on fixtures in a temp directory, on any OS — nothing here touches a real - * checkout, a real bundle, or LaunchServices. That last step (the system routing a - * levelcode-dev:// link to the dev bundle) is macOS's, and is not exercised by this file. + * checkout, a real bundle, or LaunchServices. macOS itself is a stand-in (`system`): what the + * script DOES with its answers is pinned here, the answers are not. And where a failure cannot be + * provoked for real — a rename that fails after an earlier one went through — the filesystem is + * handed in with that one step failing. *--------------------------------------------------------------------------------------------*/ // @ts-check 'use strict'; @@ -109,6 +114,20 @@ function builtApp({ plist = SHIPPED, product = SHIPPED, overrides = false } = {} return app; } +/** The real filesystem, with one step replaced. */ +const failing = (step, fn) => ({ ...fs, [step]: fn }); +const temps = (dir) => fs.readdirSync(dir, { recursive: true }).map(String).filter((f) => /\.tmp$/.test(f)); + +/** macOS as the script sees it: `handler` is what it says opens the dev scheme. */ +function system({ register = () => { }, handler }) { + const calls = { register: [], handlerOf: [] }; + return { + calls, + register: (bundle) => { calls.register.push(bundle); return register(bundle); }, + handlerOf: (scheme) => { calls.handlerOf.push(scheme); return typeof handler === 'function' ? handler() : handler; } + }; +} + const changedLines = (before, after) => { const a = before.split('\n'), b = after.split('\n'); assert.strictEqual(a.length, b.length, 'the file has the same number of lines'); @@ -185,6 +204,11 @@ async function signInUrl(uriScheme) { (async () => { const identity = await import(pathToFileURL(SCRIPT).href); + // Nothing in this file may reach the real LaunchServices: a fixture registered there outlives the + // test that made it. Every example hands in its own stand-in, or asks not to register; one that + // forgets fails here instead of leaving a temp-folder bundle in the system's database. + identity.macOS.register = () => { throw new Error('this suite must not register anything with macOS'); }; + identity.macOS.handlerOf = () => { throw new Error('this suite must not ask macOS anything'); }; // ── the two identities ─────────────────────────────────────────────────────────────────────── await test('the product that ships is levelcode:// and ai.levelcode.app — the dev identity changes neither', () => { @@ -215,6 +239,10 @@ async function signInUrl(uriScheme) { assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode', darwinBundleIdentifier: 'ai.levelcode.app.dev' }), SHIPPED), /levelcode-dev/); assert.throws(() => identity.devIdentity(file({ urlProtocol: 'https', darwinBundleIdentifier: 'ai.levelcode.app.dev' }), SHIPPED), /levelcode-dev/); assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode-dev' }), SHIPPED), /must name both/); + // Both values are written into XML as they are. + for (const id of ['ai.levelcode.appx', 'ai levelcode dev', 'dev', 'ai.levelcode.app.dev.']) { + assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode-dev', darwinBundleIdentifier: id }), SHIPPED), /must look like ai\.levelcode\.app\.dev/, id); + } assert.deepStrictEqual(identity.devIdentity(file(DEV), SHIPPED), DEV); }); @@ -341,6 +369,141 @@ async function signInUrl(uriScheme) { assert.ok(log.some((l) => /not macOS \(linux\).*will not reach this editor/.test(l)), log.join(' | ')); }); + // ── the two files change as one change ─────────────────────────────────────────────────────── + await test('together: every file is replaced, keeping its mode; one that was not there is created; nothing is left behind', () => { + const dir = tmp(); + const a = write(path.join(dir, 'a.json'), 'old a'), b = write(path.join(dir, 'sub', 'b.plist'), 'old b'); + fs.chmodSync(b, 0o640); + const c = path.join(dir, 'c.json'); + identity.replaceTogether([{ path: a, text: 'new a' }, { path: b, text: 'new b' }, { path: c, text: 'new c' }]); + assert.deepStrictEqual([a, b, c].map((f) => fs.readFileSync(f, 'utf8')), ['new a', 'new b', 'new c']); + assert.strictEqual(fs.statSync(b).mode & 0o777, 0o640); + assert.deepStrictEqual(temps(dir), []); + }); + + await test('together: a symlinked file is replaced where it really is — the link stays a link', () => { + const dir = tmp(); + const real = write(path.join(dir, 'shared', 'overrides.json'), 'old'); + const link = path.join(dir, 'product.overrides.json'); + fs.symlinkSync(real, link); + identity.replaceTogether([{ path: link, text: 'new' }]); + assert.strictEqual(fs.lstatSync(link).isSymbolicLink(), true); + assert.strictEqual(fs.readFileSync(real, 'utf8'), 'new'); + }); + + await test('together: a file that cannot be written stops it before ANY file has changed', () => { + const dir = tmp(); + const a = write(path.join(dir, 'a.json'), 'old a'); + const nowhere = path.join(dir, 'no-such-folder', 'b.plist'); // a real failure, no stand-in + assert.throws(() => identity.replaceTogether([{ path: a, text: 'new a' }, { path: nowhere, text: 'new b' }]), /ENOENT/); + assert.strictEqual(fs.readFileSync(a, 'utf8'), 'old a'); + assert.deepStrictEqual(temps(dir), []); + }); + + await test('together: a rename that fails after an earlier one went through is undone — old contents back, a new file gone', () => { + for (const existed of [true, false]) { + const dir = tmp(); + const a = path.join(dir, 'a.json'), b = write(path.join(dir, 'b.plist'), 'old b'); + if (existed) { write(a, 'old a'); } + let renames = 0; + const io = failing('renameSync', (from, to) => { if (++renames === 2) { throw new Error('EXDEV: second rename refused'); } return fs.renameSync(from, to); }); + assert.throws(() => identity.replaceTogether([{ path: a, text: 'new a' }, { path: b, text: 'new b' }], io), /second rename refused — nothing was changed/); + assert.strictEqual(fs.existsSync(a) ? fs.readFileSync(a, 'utf8') : null, existed ? 'old a' : null, existed ? 'restored' : 'removed again'); + assert.strictEqual(fs.readFileSync(b, 'utf8'), 'old b'); + assert.deepStrictEqual(temps(dir), []); + } + }); + + await test('together: when even the undo fails, the error says which file was left changed', () => { + const dir = tmp(); + const a = write(path.join(dir, 'a.json'), 'old a'), b = write(path.join(dir, 'b.plist'), 'old b'); + let renames = 0; + const io = { + ...failing('renameSync', (from, to) => { if (++renames === 2) { throw new Error('second rename refused'); } return fs.renameSync(from, to); }), + // Staging writes go to .tmp files; the write that fails here is the one putting a.json back. + writeFileSync: (file, ...rest) => { if (file === fs.realpathSync(a)) { throw new Error('EROFS: read-only now'); } return fs.writeFileSync(file, ...rest); } + }; + assert.throws(() => identity.replaceTogether([{ path: a, text: 'new a' }, { path: b, text: 'new b' }], io), + (e) => /second rename refused/.test(e.message) && /could NOT be undone/.test(e.message) && e.message.includes(fs.realpathSync(a)) && /EROFS/.test(e.message)); + }); + + await test('dev: a bundle that cannot be written leaves the overrides file as it was — no half identity', () => { + // The reviewed order wrote product.overrides.json first: a failure on Info.plist then left the + // editor advertising a scheme the bundle did not own. + for (const theirs of [null, JSON.stringify({ extensionsGallery: {} })]) { + const c = checkout({ overrides: theirs }); + const io = failing('writeFileSync', (file, ...rest) => { if (/Info\.plist\.identity-\d+\.tmp$/.test(file)) { throw new Error('EACCES: permission denied'); } return fs.writeFileSync(file, ...rest); }); + assert.throws(() => identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false, io }), /EACCES/); + assert.strictEqual(fs.existsSync(c.overrides) ? fs.readFileSync(c.overrides, 'utf8') : null, theirs); + assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist()); + assert.deepStrictEqual(temps(c.dir), []); + } + }); + + // ── told is not routed ─────────────────────────────────────────────────────────────────────── + await test('dev: macOS is asked what opens the dev scheme, and the step passes when it names this bundle', () => { + const c = checkout(); + const bundle = path.join(c.dir, '.build', 'electron', 'LevelCode.app'); + const mac = system({ handler: fs.realpathSync(bundle) }); // as macOS gives it: /private/var/…, not /var/… + const log = []; + const r = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: mac, log: (l) => log.push(l) }); + assert.strictEqual(r.registered, true); + assert.deepStrictEqual(mac.calls, { register: [bundle], handlerOf: ['levelcode-dev'] }); + assert.ok(log.some((l) => /macOS opens levelcode-dev:\/\/ with this bundle/.test(l)), log.join(' | ')); + }); + + await test('dev: the bundle\'s own path in another spelling is still this bundle — macOS answers as on disk, the checkout as typed', () => { + const c = checkout(); + const bundle = path.join(c.dir, '.build', 'electron', 'LevelCode.app'); + const shouted = path.join(path.dirname(bundle), 'LEVELCODE.APP'); + const run = () => identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: system({ handler: shouted }) }); + if (fs.existsSync(shouted)) { // the volume folds case, as a Mac's does by default + assert.strictEqual(run().registered, true); + } else { // it does not: those really are two places + assert.throws(run, /macOS opens levelcode-dev:\/\/ with .*LEVELCODE\.APP, not with/); + } + }); + + await test('dev: a registration that fails is fatal — the launcher must not start an editor that cannot hear its callback', () => { + const c = checkout(); + const mac = system({ register: () => { throw new Error('lsregister failed: failed to scan … -10811'); }, handler: '' }); + assert.throws(() => identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: mac }), + /could not be registered for levelcode-dev:\/\/ — lsregister failed: failed to scan[\s\S]*the editor was not started/); + assert.deepStrictEqual(mac.calls.handlerOf, [], 'nothing further is asked'); + // The two files are left in place: they agree with each other, and the next run registers again. + assert.deepStrictEqual(JSON.parse(fs.readFileSync(c.overrides, 'utf8')), DEV); + assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist(DEV)); + const bundle = path.join(c.dir, '.build', 'electron', 'LevelCode.app'); + const retry = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: system({ handler: bundle }) }); + assert.deepStrictEqual({ registered: retry.registered, overridesChanged: retry.overridesChanged, bundleChanged: retry.bundleChanged }, { registered: true, overridesChanged: false, bundleChanged: false }); + }); + + await test('dev: registered but not ROUTED is fatal too — no app for the scheme, or another copy holding it', () => { + const none = checkout(); + assert.throws(() => identity.applyDevIdentity({ vscodeDir: none.dir, platform: 'darwin', system: system({ handler: '' }) }), + /macOS has no app for levelcode-dev:\/\/ even after registering[\s\S]*temporary folder/); + + const other = checkout(); + assert.throws(() => identity.applyDevIdentity({ vscodeDir: other.dir, platform: 'darwin', system: system({ handler: '/Users/dev/other-checkout/vscode/.build/electron/LevelCode.app' }) }), + /macOS opens levelcode-dev:\/\/ with \/Users\/dev\/other-checkout\/[\s\S]*lsregister -u "\/Users\/dev\/other-checkout\//); + }); + + await test('dev: when macOS cannot be asked, a registration that succeeded stands — and the log says it is unconfirmed', () => { + const c = checkout(); + const log = []; + const r = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: system({ handler: null }), log: (l) => log.push(l) }); + assert.strictEqual(r.registered, true); + assert.ok(log.some((l) => /could not ask macOS .* unconfirmed/.test(l)), log.join(' | ')); + }); + + await test('dev: asked not to register, macOS is not consulted at all', () => { + const c = checkout(); + const mac = system({ register: () => { throw new Error('must not be called'); }, handler: () => { throw new Error('must not be called'); } }); + const r = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false, system: mac }); + assert.strictEqual(r.registered, false); + assert.deepStrictEqual(mac.calls, { register: [], handlerOf: [] }); + }); + // ── a build must be the app that ships ─────────────────────────────────────────────────────── await test('release check: an app with the shipped identity passes', () => { assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp()), []); @@ -360,6 +523,9 @@ async function signInUrl(uriScheme) { await test('release check: an overrides file inside the app, a missing product.json, a missing Info.plist — each fails', () => { assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp({ overrides: true })), ['product.overrides.json was packaged — it is for runs from source only']); assert.strictEqual(identity.releaseIdentityProblems(builtApp({ product: null })).length, 1); + const unreadable = builtApp(); + write(path.join(unreadable, 'Contents', 'Resources', 'app', 'product.json'), '{ "urlProtocol": '); + assert.match(identity.releaseIdentityProblems(unreadable).join(' | '), /^product\.json cannot be read/); assert.match(identity.releaseIdentityProblems(path.join(tmp(), 'Nothing.app'))[0], /no Info\.plist/); }); diff --git a/scripts/editor-identity.mjs b/scripts/editor-identity.mjs index 93a6284..aa27398 100755 --- a/scripts/editor-identity.mjs +++ b/scripts/editor-identity.mjs @@ -29,7 +29,10 @@ * registered with LaunchServices, which is what actually routes the link. * * Both, or neither works: with only (1) the browser is sent to a scheme nothing claims; with only - * (2) the editor still asks to be called back on the installed app's. + * (2) the editor still asks to be called back on the installed app's. So the two files are replaced + * as one change (replaceTogether), and the step FAILS — run-dev.sh stops before launching — unless + * macOS then says the dev scheme opens this bundle. Being told about the bundle is not the same as + * agreeing to use it: one under a temporary folder is registered and never chosen. * * `check-release` is the other direction: a BUILT app must carry the shipped identity, and no * overrides file. scripts/build-macos.sh runs it, so a dev identity cannot be packaged by accident. @@ -39,7 +42,8 @@ * * The functions are exported so test/editorIdentity.test.js can run them — on a fixture, on any OS. *--------------------------------------------------------------------------------------------*/ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import * as fs from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -51,6 +55,8 @@ const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework/Frameworks /** The only shape a dev scheme may have: it is also the only shape a server can be told to accept. */ export const DEV_SCHEME = /^levelcode-[a-z0-9]+(?:[.-][a-z0-9]+)*$/; +/** A bundle identifier: reverse-DNS, letters, digits, dots and hyphens. */ +const BUNDLE_IDENTIFIER = /^[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)+$/; /** @param {string} file @returns {{urlProtocol: string, darwinBundleIdentifier: string}} */ function identityIn(file) { @@ -74,6 +80,11 @@ export function devIdentity(file = DEV_IDENTITY_FILE, shipped = shippedIdentity( if (!DEV_SCHEME.test(dev.urlProtocol)) { throw new Error(`the dev urlProtocol must look like levelcode-dev, not ${JSON.stringify(dev.urlProtocol)}`); } + // Both values are written into an Info.plist as they are. Neither pattern admits a character + // XML would read as markup. + if (!BUNDLE_IDENTIFIER.test(dev.darwinBundleIdentifier)) { + throw new Error(`the dev darwinBundleIdentifier must look like ai.levelcode.app.dev, not ${JSON.stringify(dev.darwinBundleIdentifier)}`); + } if (dev.urlProtocol === shipped.urlProtocol || dev.darwinBundleIdentifier === shipped.darwinBundleIdentifier) { throw new Error('the dev identity must differ from the shipped one in both urlProtocol and darwinBundleIdentifier'); } @@ -140,11 +151,102 @@ export function withIdentity(xml, identity) { return { xml: next, changed, was }; } +/** + * Replace several files as ONE change: all of them, or none. + * + * Staged first. Each new text goes to a temporary file beside its target, so everything that can + * stop a write — a directory that cannot be written, a full disk — is met while every target is + * still as it was. Only then is each temporary file renamed over its target, which either happens + * or does not: a target is never left half written. + * + * That leaves one way to end up half done — a rename failing after an earlier one succeeded — and + * it is undone: the earlier targets get their old contents back, or are removed if they were not + * there. If even that fails, the error says which file was left changed. + * + * (A process KILLED between the renames can still leave one file ahead of the other. The next run + * finishes the job, and run-dev.sh never launches without a run that finished.) + * + * @param {{path: string, text: string}[]} files + * @param {typeof fs} [io] the filesystem — replaced in tests, to fail a step that cannot be made to fail for real + */ +export function replaceTogether(files, io = fs) { + /** @type {{target: string, temp: string, before: string|null}[]} */ + const staged = []; + const discard = (temp) => { try { io.rmSync(temp, { force: true }); } catch { /* a stray temp file is not worth a second error */ } }; + try { + for (const file of files) { + // A symlinked target is replaced where it really is, so the link survives. + const target = io.existsSync(file.path) ? io.realpathSync(file.path) : file.path; + const before = io.existsSync(target) ? io.readFileSync(target, 'utf8') : null; + const temp = `${target}.identity-${process.pid}.tmp`; + staged.push({ target, temp, before }); + io.writeFileSync(temp, file.text, 'utf8'); + if (before !== null) { io.chmodSync(temp, io.statSync(target).mode); } + } + } catch (e) { + staged.forEach((s) => discard(s.temp)); + throw e; + } + /** @type {typeof staged} */ + const replaced = []; + try { + for (const s of staged) { io.renameSync(s.temp, s.target); replaced.push(s); } + } catch (e) { + const stuck = []; + for (const s of replaced.reverse()) { + try { + if (s.before === null) { io.rmSync(s.target, { force: true }); } + else { io.writeFileSync(s.target, s.before, 'utf8'); } + } catch (again) { stuck.push(`${s.target} (${String((again && again.message) || again)})`); } + } + staged.forEach((s) => discard(s.temp)); + const why = String((e && e.message) || e); + throw new Error(stuck.length + ? `${why} — and the change could NOT be undone: ${stuck.join('; ')} is left carrying the dev identity. Run this again once the cause is fixed.` + : `${why} — nothing was changed`, { cause: e }); + } +} + +const HANDLER_OF = 'ObjC.import("AppKit"); function run(argv) { const app = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString(argv[0] + "://probe")); return app.isNil() ? "" : ObjC.unwrap(app.path); }'; + +/** macOS, as far as this script deals with it. Replaced in tests: a fixture is nothing to tell LaunchServices about. */ +export const macOS = { + /** Tell LaunchServices about `bundle`. Throws when it could not be told. */ + register(bundle) { + const r = spawnSync(LSREGISTER, ['-f', bundle], { encoding: 'utf8', timeout: 30_000 }); + if (r.status !== 0) { + throw new Error(`lsregister ${r.error ? 'could not be run (' + r.error.message + ')' : 'failed' + (r.signal ? ' (' + r.signal + ')' : '')}: ${((r.stdout || '') + (r.stderr || '')).trim() || 'no output'}`); + } + }, + /** The app macOS opens a `scheme://` link with: its path, '' when there is none, null when macOS could not be asked. */ + handlerOf(scheme) { + const r = spawnSync('/usr/bin/osascript', ['-l', 'JavaScript', '-e', HANDLER_OF, scheme], { encoding: 'utf8', timeout: 30_000 }); + return r.status === 0 ? r.stdout.trim() : null; + } +}; + +/** + * Do two paths name the same place? `/tmp` and `/private/tmp` do — and so, on the volume a Mac + * ships with, do `~/Code` and `~/code`: macOS answers with a path as it is on disk, while the + * checkout's is as someone typed it into `cd`. The native realpath settles both; the JS one keeps + * the case it was given, and would call the bundle's own path "another copy". + */ +function samePlace(a, b) { + const real = (p) => { try { return fs.realpathSync.native(p); } catch { return resolve(p); } }; + return real(a) === real(b); +} + /** * Give the Code-OSS checkout at `vscodeDir` the dev identity. Idempotent — and all or nothing: - * everything that can refuse is asked BEFORE anything is written, because one half without the - * other is worse than neither (see the header). - * @param {{vscodeDir: string, identity?: any, register?: boolean, platform?: string, log?: (line: string) => void}} o + * everything that can refuse is asked BEFORE anything is written, and the two files are then + * replaced as one change, because one half without the other is worse than neither (see the header). + * + * It THROWS unless macOS ends up routing the dev scheme to this bundle. The caller is about to + * launch an editor that will ask to be called back on that scheme; launching one that will not + * hear the answer is the failure this script exists to remove. + * + * @param {{vscodeDir: string, identity?: any, register?: boolean, platform?: string, + * log?: (line: string) => void, io?: typeof fs, system?: typeof macOS}} o */ export function applyDevIdentity(o) { const log = o.log || (() => { }); @@ -168,22 +270,41 @@ export function applyDevIdentity(o) { plist = withIdentity(readFileSync(plistPath, 'utf8'), identity); } - if (overrides.changed) { writeFileSync(overridesPath, overrides.text, 'utf8'); } + const changes = []; + if (overrides.changed) { changes.push({ path: overridesPath, text: overrides.text }); } + if (plist && plist.changed) { changes.push({ path: plistPath, text: plist.xml }); } + replaceTogether(changes, o.io); + log(`product.overrides.json: ${identity.urlProtocol}:// (${overrides.changed ? 'written' : 'already set'})`); if (!plist) { log(`not macOS (${platform}): the URL scheme is not registered with the system — the callback will not reach this editor`); return { identity, overridesChanged: overrides.changed, bundle: null, bundleChanged: false, registered: false }; } - if (plist.changed) { writeFileSync(plistPath, plist.xml, 'utf8'); } log(`${name}.app: ${identity.darwinBundleIdentifier} (${plist.changed ? 'was ' + plist.was.darwinBundleIdentifier + ', ' + (plist.was.urlSchemes.join(', ') || 'no scheme') + '://' : 'already set'})`); // Registered every time, not only after a change: it is what routes the link, it is cheap, and a // rebuilt LaunchServices database forgets a bundle that was only ever launched from a shell. + // + // The two files are left as they are when this fails. They agree with each other, the next run + // registers again, and undoing them would only hand the next launch the installed app's scheme. let registered = false; if (o.register !== false) { - const r = spawnSync(LSREGISTER, ['-f', bundle], { encoding: 'utf8' }); - registered = r.status === 0; - log(registered ? `registered ${identity.urlProtocol}:// with LaunchServices` : `could not register with LaunchServices (${(r.stderr || r.error || 'lsregister failed').toString().trim()})`); + const system = o.system || macOS; + const scheme = identity.urlProtocol; + try { system.register(bundle); } + catch (e) { throw new Error(`${bundle} could not be registered for ${scheme}:// — ${String((e && e.message) || e)}. Run this again; the editor was not started.`, { cause: e }); } + // Registered is not routed. Ask macOS what it will actually do with the link. + const handler = system.handlerOf(scheme); + if (handler === null) { + log(`registered with LaunchServices — could not ask macOS which app opens ${scheme}://, so that is unconfirmed`); + } else if (handler === '') { + throw new Error(`macOS has no app for ${scheme}:// even after registering ${bundle}. A bundle under a temporary folder is registered and never chosen — is the checkout in one?`); + } else if (!samePlace(handler, bundle)) { + throw new Error(`macOS opens ${scheme}:// with ${handler}, not with ${bundle}. Another copy claims the scheme; quit it and unregister it:\n ${LSREGISTER} -u "${handler}"`); + } else { + log(`macOS opens ${scheme}:// with this bundle`); + } + registered = true; } return { identity, overridesChanged: overrides.changed, bundle, bundleChanged: plist.changed, registered }; } @@ -210,8 +331,10 @@ export function releaseIdentityProblems(app, shipped = shippedIdentity()) { const productPath = join(resources, 'product.json'); if (!existsSync(productPath)) { problems.push(`no product.json at ${productPath}`); } else { - const product = JSON.parse(readFileSync(productPath, 'utf8')); - for (const key of ['urlProtocol', 'darwinBundleIdentifier']) { + let product = null; + try { product = JSON.parse(readFileSync(productPath, 'utf8')); } + catch (e) { problems.push(`product.json cannot be read: ${String((e && e.message) || e)}`); } + for (const key of product ? ['urlProtocol', 'darwinBundleIdentifier'] : []) { if (product[key] !== shipped[key]) { problems.push(`product.json ${key} is ${JSON.stringify(product[key])}, not ${JSON.stringify(shipped[key])}`); } } }