Skip to content

Commit 83209c3

Browse files
committed
fix(dev): #99 review — the identity changes as one, and the launch waits for macOS to agree
Two findings, both on scripts/editor-identity.mjs, both right. 1. The two files could still end up half changed. Everything that could REFUSE was asked before writing — but the writes themselves can fail, and product.overrides.json was written before Info.plist. A bundle that could not be written left the editor asking to be called back on a scheme the bundle did not own: the half-state the function said it prevented. Reproduced on the reviewed script with a read-only bundle. The files are now replaced as one change (replaceTogether). Each new text is staged in a temporary file beside its target, so a directory that cannot be written or a full disk is met while both targets are untouched; then each is renamed into place, which either happens or does not. The one way left to be half done — a rename failing after an earlier one went through — is undone: old contents back, a newly created file removed. If the undo fails too, the error names the file left changed. A symlinked overrides file is replaced where it really is, and a file keeps its mode. A process killed between the two renames can still leave one file ahead. The next run finishes it, and run-dev.sh does not launch without a finished run. 2. A registration that failed was logged and passed over, so run-dev.sh went on to launch an editor macOS might not route levelcode-dev:// to. It is fatal now: the script throws, exits 1, and `set -e` stops the launcher. Checking lsregister's exit status turned out not to be enough. Run on a bundle under a temporary folder, the reviewed script printed "registered" and exited 0 — lsregister had exited 0 too — while macOS had no app for the scheme at all: it registers such a bundle and never chooses it. So after registering, the script asks macOS what it will do with the link (NSWorkspace, through osascript: built in, ~120 ms) and fails unless the answer is this bundle. No app, or another copy holding the scheme, are both fatal, the second naming the copy and how to unregister it. If macOS cannot be asked at all, a registration that succeeded stands and the log says it is unconfirmed. The two files stay as they are when registration fails: they agree with each other, the next run registers again, and undoing them would hand the next launch the installed app's scheme. Found while doing it: the comparison of macOS's answer with the bundle's path has to use the native realpath. macOS answers with the path as it is on disk; the checkout's is as someone typed it into `cd`, and on a Mac's default volume ~/Code and ~/code are one place. The JS realpath keeps the case it is given and would have called the bundle's own path "another copy". Also: the dev bundle identifier is checked for shape before it is written into XML; a product.json that is not JSON is a problem the release check reports rather than a crash; and the suite now replaces the script's macOS object with one that throws, so a test that forgets its stand-in fails instead of leaving a temp-folder bundle in LaunchServices. test/editorIdentity.test.js goes from 25 to 37 cases. The filesystem is handed in with one step failing only where the failure cannot be provoked for real (a second rename); the rest use real files. Seventeen mutations of the new code each fail a case. Checked against macOS itself, on a clone of the dev bundle under the home folder: success says "macOS opens levelcode-dev:// with this bundle" and exits 0; the same clone under a temp folder exits 1. 49 suites pass on macOS and in a Linux container.
1 parent 791735e commit 83209c3

3 files changed

Lines changed: 307 additions & 14 deletions

File tree

‎CLAUDE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ To macOS a run from source and the installed LevelCode used to be ONE app — sa
6666
- **Two halves, both required.** Runtime: `vscode/product.overrides.json` (Code-OSS reads it only when
6767
running from source, never packages it). macOS: the dev Electron bundle's `Info.plist`, then
6868
`lsregister`. The bundle is regenerated when Electron changes, so the step runs on every launch.
69+
- **Both or neither, and confirmed.** The two files are replaced as one change (staged, renamed, undone
70+
if the second rename fails). Then the step asks macOS which app opens `levelcode-dev://` and FAILS —
71+
`run-dev.sh` stops before launching — unless the answer is this bundle. `lsregister` exiting 0 is
72+
not that answer: it registers a bundle it will never route to.
6973
- **`branding/product.overlay.json` is the product that ships — never put a dev value in it.**
7074
`build-macos.sh` runs `editor-identity.mjs check-release` and fails a build that is not
7175
`levelcode://` + `ai.levelcode.app`, or that carries an overrides file.

‎extensions/levelcode-ai/test/editorIdentity.test.js‎

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@
1010
* - product.overrides.json gains the identity and keeps whatever else the developer put there
1111
* - the dev bundle's Info.plist changes in its identifier and its URL scheme — and nowhere else
1212
* - doing it twice changes nothing
13+
* - the two files change as ONE change: a write that fails leaves both as they were, and a
14+
* rename that fails half-way is undone
15+
* - the step fails unless macOS will route the dev scheme to this bundle — told is not routed
1316
* - a built app with a dev identity, or with an overrides file in it, fails the release check
1417
* - the sign-in callback is built from the editor's OWN scheme: the reason no auth code changed
1518
*
1619
* Everything runs on fixtures in a temp directory, on any OS — nothing here touches a real
17-
* checkout, a real bundle, or LaunchServices. That last step (the system routing a
18-
* levelcode-dev:// link to the dev bundle) is macOS's, and is not exercised by this file.
20+
* checkout, a real bundle, or LaunchServices. macOS itself is a stand-in (`system`): what the
21+
* script DOES with its answers is pinned here, the answers are not. And where a failure cannot be
22+
* provoked for real — a rename that fails after an earlier one went through — the filesystem is
23+
* handed in with that one step failing.
1924
*--------------------------------------------------------------------------------------------*/
2025
// @ts-check
2126
'use strict';
@@ -109,6 +114,20 @@ function builtApp({ plist = SHIPPED, product = SHIPPED, overrides = false } = {}
109114
return app;
110115
}
111116

117+
/** The real filesystem, with one step replaced. */
118+
const failing = (step, fn) => ({ ...fs, [step]: fn });
119+
const temps = (dir) => fs.readdirSync(dir, { recursive: true }).map(String).filter((f) => /\.tmp$/.test(f));
120+
121+
/** macOS as the script sees it: `handler` is what it says opens the dev scheme. */
122+
function system({ register = () => { }, handler }) {
123+
const calls = { register: [], handlerOf: [] };
124+
return {
125+
calls,
126+
register: (bundle) => { calls.register.push(bundle); return register(bundle); },
127+
handlerOf: (scheme) => { calls.handlerOf.push(scheme); return typeof handler === 'function' ? handler() : handler; }
128+
};
129+
}
130+
112131
const changedLines = (before, after) => {
113132
const a = before.split('\n'), b = after.split('\n');
114133
assert.strictEqual(a.length, b.length, 'the file has the same number of lines');
@@ -185,6 +204,11 @@ async function signInUrl(uriScheme) {
185204

186205
(async () => {
187206
const identity = await import(pathToFileURL(SCRIPT).href);
207+
// Nothing in this file may reach the real LaunchServices: a fixture registered there outlives the
208+
// test that made it. Every example hands in its own stand-in, or asks not to register; one that
209+
// forgets fails here instead of leaving a temp-folder bundle in the system's database.
210+
identity.macOS.register = () => { throw new Error('this suite must not register anything with macOS'); };
211+
identity.macOS.handlerOf = () => { throw new Error('this suite must not ask macOS anything'); };
188212

189213
// ── the two identities ───────────────────────────────────────────────────────────────────────
190214
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) {
215239
assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode', darwinBundleIdentifier: 'ai.levelcode.app.dev' }), SHIPPED), /levelcode-dev/);
216240
assert.throws(() => identity.devIdentity(file({ urlProtocol: 'https', darwinBundleIdentifier: 'ai.levelcode.app.dev' }), SHIPPED), /levelcode-dev/);
217241
assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode-dev' }), SHIPPED), /must name both/);
242+
// Both values are written into XML as they are.
243+
for (const id of ['ai.levelcode.app</string><string>x', 'ai levelcode dev', 'dev', 'ai.levelcode.app.dev.']) {
244+
assert.throws(() => identity.devIdentity(file({ urlProtocol: 'levelcode-dev', darwinBundleIdentifier: id }), SHIPPED), /must look like ai\.levelcode\.app\.dev/, id);
245+
}
218246
assert.deepStrictEqual(identity.devIdentity(file(DEV), SHIPPED), DEV);
219247
});
220248

@@ -341,6 +369,141 @@ async function signInUrl(uriScheme) {
341369
assert.ok(log.some((l) => /not macOS \(linux\).*will not reach this editor/.test(l)), log.join(' | '));
342370
});
343371

372+
// ── the two files change as one change ───────────────────────────────────────────────────────
373+
await test('together: every file is replaced, keeping its mode; one that was not there is created; nothing is left behind', () => {
374+
const dir = tmp();
375+
const a = write(path.join(dir, 'a.json'), 'old a'), b = write(path.join(dir, 'sub', 'b.plist'), 'old b');
376+
fs.chmodSync(b, 0o640);
377+
const c = path.join(dir, 'c.json');
378+
identity.replaceTogether([{ path: a, text: 'new a' }, { path: b, text: 'new b' }, { path: c, text: 'new c' }]);
379+
assert.deepStrictEqual([a, b, c].map((f) => fs.readFileSync(f, 'utf8')), ['new a', 'new b', 'new c']);
380+
assert.strictEqual(fs.statSync(b).mode & 0o777, 0o640);
381+
assert.deepStrictEqual(temps(dir), []);
382+
});
383+
384+
await test('together: a symlinked file is replaced where it really is — the link stays a link', () => {
385+
const dir = tmp();
386+
const real = write(path.join(dir, 'shared', 'overrides.json'), 'old');
387+
const link = path.join(dir, 'product.overrides.json');
388+
fs.symlinkSync(real, link);
389+
identity.replaceTogether([{ path: link, text: 'new' }]);
390+
assert.strictEqual(fs.lstatSync(link).isSymbolicLink(), true);
391+
assert.strictEqual(fs.readFileSync(real, 'utf8'), 'new');
392+
});
393+
394+
await test('together: a file that cannot be written stops it before ANY file has changed', () => {
395+
const dir = tmp();
396+
const a = write(path.join(dir, 'a.json'), 'old a');
397+
const nowhere = path.join(dir, 'no-such-folder', 'b.plist'); // a real failure, no stand-in
398+
assert.throws(() => identity.replaceTogether([{ path: a, text: 'new a' }, { path: nowhere, text: 'new b' }]), /ENOENT/);
399+
assert.strictEqual(fs.readFileSync(a, 'utf8'), 'old a');
400+
assert.deepStrictEqual(temps(dir), []);
401+
});
402+
403+
await test('together: a rename that fails after an earlier one went through is undone — old contents back, a new file gone', () => {
404+
for (const existed of [true, false]) {
405+
const dir = tmp();
406+
const a = path.join(dir, 'a.json'), b = write(path.join(dir, 'b.plist'), 'old b');
407+
if (existed) { write(a, 'old a'); }
408+
let renames = 0;
409+
const io = failing('renameSync', (from, to) => { if (++renames === 2) { throw new Error('EXDEV: second rename refused'); } return fs.renameSync(from, to); });
410+
assert.throws(() => identity.replaceTogether([{ path: a, text: 'new a' }, { path: b, text: 'new b' }], io), /second rename refused — nothing was changed/);
411+
assert.strictEqual(fs.existsSync(a) ? fs.readFileSync(a, 'utf8') : null, existed ? 'old a' : null, existed ? 'restored' : 'removed again');
412+
assert.strictEqual(fs.readFileSync(b, 'utf8'), 'old b');
413+
assert.deepStrictEqual(temps(dir), []);
414+
}
415+
});
416+
417+
await test('together: when even the undo fails, the error says which file was left changed', () => {
418+
const dir = tmp();
419+
const a = write(path.join(dir, 'a.json'), 'old a'), b = write(path.join(dir, 'b.plist'), 'old b');
420+
let renames = 0;
421+
const io = {
422+
...failing('renameSync', (from, to) => { if (++renames === 2) { throw new Error('second rename refused'); } return fs.renameSync(from, to); }),
423+
// Staging writes go to .tmp files; the write that fails here is the one putting a.json back.
424+
writeFileSync: (file, ...rest) => { if (file === fs.realpathSync(a)) { throw new Error('EROFS: read-only now'); } return fs.writeFileSync(file, ...rest); }
425+
};
426+
assert.throws(() => identity.replaceTogether([{ path: a, text: 'new a' }, { path: b, text: 'new b' }], io),
427+
(e) => /second rename refused/.test(e.message) && /could NOT be undone/.test(e.message) && e.message.includes(fs.realpathSync(a)) && /EROFS/.test(e.message));
428+
});
429+
430+
await test('dev: a bundle that cannot be written leaves the overrides file as it was — no half identity', () => {
431+
// The reviewed order wrote product.overrides.json first: a failure on Info.plist then left the
432+
// editor advertising a scheme the bundle did not own.
433+
for (const theirs of [null, JSON.stringify({ extensionsGallery: {} })]) {
434+
const c = checkout({ overrides: theirs });
435+
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); });
436+
assert.throws(() => identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false, io }), /EACCES/);
437+
assert.strictEqual(fs.existsSync(c.overrides) ? fs.readFileSync(c.overrides, 'utf8') : null, theirs);
438+
assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist());
439+
assert.deepStrictEqual(temps(c.dir), []);
440+
}
441+
});
442+
443+
// ── told is not routed ───────────────────────────────────────────────────────────────────────
444+
await test('dev: macOS is asked what opens the dev scheme, and the step passes when it names this bundle', () => {
445+
const c = checkout();
446+
const bundle = path.join(c.dir, '.build', 'electron', 'LevelCode.app');
447+
const mac = system({ handler: fs.realpathSync(bundle) }); // as macOS gives it: /private/var/…, not /var/…
448+
const log = [];
449+
const r = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: mac, log: (l) => log.push(l) });
450+
assert.strictEqual(r.registered, true);
451+
assert.deepStrictEqual(mac.calls, { register: [bundle], handlerOf: ['levelcode-dev'] });
452+
assert.ok(log.some((l) => /macOS opens levelcode-dev:\/\/ with this bundle/.test(l)), log.join(' | '));
453+
});
454+
455+
await test('dev: the bundle\'s own path in another spelling is still this bundle — macOS answers as on disk, the checkout as typed', () => {
456+
const c = checkout();
457+
const bundle = path.join(c.dir, '.build', 'electron', 'LevelCode.app');
458+
const shouted = path.join(path.dirname(bundle), 'LEVELCODE.APP');
459+
const run = () => identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: system({ handler: shouted }) });
460+
if (fs.existsSync(shouted)) { // the volume folds case, as a Mac's does by default
461+
assert.strictEqual(run().registered, true);
462+
} else { // it does not: those really are two places
463+
assert.throws(run, /macOS opens levelcode-dev:\/\/ with .*LEVELCODE\.APP, not with/);
464+
}
465+
});
466+
467+
await test('dev: a registration that fails is fatal — the launcher must not start an editor that cannot hear its callback', () => {
468+
const c = checkout();
469+
const mac = system({ register: () => { throw new Error('lsregister failed: failed to scan … -10811'); }, handler: '' });
470+
assert.throws(() => identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: mac }),
471+
/could not be registered for levelcode-dev:\/\/ — lsregister failed: failed to scan[\s\S]*the editor was not started/);
472+
assert.deepStrictEqual(mac.calls.handlerOf, [], 'nothing further is asked');
473+
// The two files are left in place: they agree with each other, and the next run registers again.
474+
assert.deepStrictEqual(JSON.parse(fs.readFileSync(c.overrides, 'utf8')), DEV);
475+
assert.strictEqual(fs.readFileSync(c.plist, 'utf8'), infoPlist(DEV));
476+
const bundle = path.join(c.dir, '.build', 'electron', 'LevelCode.app');
477+
const retry = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: system({ handler: bundle }) });
478+
assert.deepStrictEqual({ registered: retry.registered, overridesChanged: retry.overridesChanged, bundleChanged: retry.bundleChanged }, { registered: true, overridesChanged: false, bundleChanged: false });
479+
});
480+
481+
await test('dev: registered but not ROUTED is fatal too — no app for the scheme, or another copy holding it', () => {
482+
const none = checkout();
483+
assert.throws(() => identity.applyDevIdentity({ vscodeDir: none.dir, platform: 'darwin', system: system({ handler: '' }) }),
484+
/macOS has no app for levelcode-dev:\/\/ even after registering[\s\S]*temporary folder/);
485+
486+
const other = checkout();
487+
assert.throws(() => identity.applyDevIdentity({ vscodeDir: other.dir, platform: 'darwin', system: system({ handler: '/Users/dev/other-checkout/vscode/.build/electron/LevelCode.app' }) }),
488+
/macOS opens levelcode-dev:\/\/ with \/Users\/dev\/other-checkout\/[\s\S]*lsregister -u "\/Users\/dev\/other-checkout\//);
489+
});
490+
491+
await test('dev: when macOS cannot be asked, a registration that succeeded stands — and the log says it is unconfirmed', () => {
492+
const c = checkout();
493+
const log = [];
494+
const r = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', system: system({ handler: null }), log: (l) => log.push(l) });
495+
assert.strictEqual(r.registered, true);
496+
assert.ok(log.some((l) => /could not ask macOS .* unconfirmed/.test(l)), log.join(' | '));
497+
});
498+
499+
await test('dev: asked not to register, macOS is not consulted at all', () => {
500+
const c = checkout();
501+
const mac = system({ register: () => { throw new Error('must not be called'); }, handler: () => { throw new Error('must not be called'); } });
502+
const r = identity.applyDevIdentity({ vscodeDir: c.dir, platform: 'darwin', register: false, system: mac });
503+
assert.strictEqual(r.registered, false);
504+
assert.deepStrictEqual(mac.calls, { register: [], handlerOf: [] });
505+
});
506+
344507
// ── a build must be the app that ships ───────────────────────────────────────────────────────
345508
await test('release check: an app with the shipped identity passes', () => {
346509
assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp()), []);
@@ -360,6 +523,9 @@ async function signInUrl(uriScheme) {
360523
await test('release check: an overrides file inside the app, a missing product.json, a missing Info.plist — each fails', () => {
361524
assert.deepStrictEqual(identity.releaseIdentityProblems(builtApp({ overrides: true })), ['product.overrides.json was packaged — it is for runs from source only']);
362525
assert.strictEqual(identity.releaseIdentityProblems(builtApp({ product: null })).length, 1);
526+
const unreadable = builtApp();
527+
write(path.join(unreadable, 'Contents', 'Resources', 'app', 'product.json'), '{ "urlProtocol": ');
528+
assert.match(identity.releaseIdentityProblems(unreadable).join(' | '), /^product\.json cannot be read/);
363529
assert.match(identity.releaseIdentityProblems(path.join(tmp(), 'Nothing.app'))[0], /no Info\.plist/);
364530
});
365531

0 commit comments

Comments
 (0)