diff --git a/CLAUDE.md b/CLAUDE.md index 4cdc4cf..a4849db 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,33 @@ 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. +- **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. +- **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..6e4bc67 --- /dev/null +++ b/extensions/levelcode-ai/test/editorIdentity.test.js @@ -0,0 +1,587 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + * - 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. 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'; + +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; +} + +/** 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'); + 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); + // 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', () => { + 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/); + // 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); + }); + + // ── 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(' | ')); + }); + + // ── 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()), []); + }); + + 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); + 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/); + }); + + // ── 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..aa27398 --- /dev/null +++ b/scripts/editor-identity.mjs @@ -0,0 +1,369 @@ +#!/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. 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. + * + * 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 * 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'; + +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]+)*$/; +/** 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) { + 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)}`); + } + // 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'); + } + 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 }; +} + +/** + * 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, 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 || (() => { }); + 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); + } + + 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 }; + } + 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 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 }; +} + +/** + * 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 { + 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])}`); } + } + } + 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 "$@"