@deeeed/metamask-harness 0.51.5 → 0.51.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +25 -0
- package/adapters/extension/ensure-browser.sh +31 -7
- package/adapters/extension/launch-browser.cjs +154 -59
- package/adapters/extension/lib/macos-focus.cjs +207 -12
- package/adapters/extension/lib/validation-process-ownership.cjs +57 -2
- package/adapters/extension/live.sh +0 -2
- package/adapters/extension/stop-viewers.sh +1 -2
- package/dist/adapters/extension/validation-process-ownership.js +3 -2
- package/dist/adapters/slot-ports.js +65 -13
- package/dist/cli-commands.js +4 -1
- package/dist/cli.js +6 -0
- package/dist/command-contract.js +26 -1
- package/dist/commands/config.js +100 -0
- package/dist/commands/domain.js +56 -0
- package/dist/commands/help.js +2 -0
- package/dist/commands/review.js +135 -0
- package/dist/mm-harness-cli.js +71 -3
- package/dist/review/knowledge.js +513 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.51.6 - 2026-09-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Review knowledge composition: a team library's `review/antipatterns.md`, `review/parity.md`, and `owned-paths.json` compose on top of the harness's base review; the harness never copies them.
|
|
10
|
+
- `review checklist [--domain <name>] [--since <sha>] [--base <ref>] [--out <file>]`: phase-structured checklist (Setup, Base review, Domain patterns, Parity, Verdict) that `checklist mark` tracks; one line per `##` section of the library's anti-patterns; the other platform's checkout named as the parity reference or "not checked" with the reason; `--since` for an incremental re-review.
|
|
11
|
+
A library's `review/antipatterns.<adapter>.md` adds families for that adapter only.
|
|
12
|
+
- `help review [--domain <name>]`: the same composition as prose; never fetches.
|
|
13
|
+
- `domain`: a declared `--domain` / `MM_HARNESS_DOMAIN` wins, else the changed files are matched against every reachable library's `owned-paths.json`.
|
|
14
|
+
- `config`: per-engineer locations outside any repo (`~/.mm-harness/config.json`): `libraries.<name>`, `references.<mobile|extension|core>`.
|
|
15
|
+
- Resolution order: env (`RECIPE_LIBRARY_PATH`, `MM_HARNESS_REF_<ADAPTER>`) → config → sibling directory beside the checkout → shallow fetch into `<checkout>/.skills-cache/<name>` for known libraries (excluded through the checkout's git exclude file, worktrees included).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
Extension fresh-launch and macOS focus fixes, first published off-main as 0.50.5 and 0.50.6 and now on main:
|
|
20
|
+
|
|
21
|
+
- Avoid disabling and re-enabling the Extension after a fresh browser launch, which could cause `No SW` storage errors in reused wallet profiles. Keep build handoff for updates in an already-running browser.
|
|
22
|
+
- Keep the previously focused macOS app in front while Chrome starts, instead of restoring only after CDP is up. Do not let caffeinate declare user activity during that launch.
|
|
23
|
+
- Keep the Extension Chrome window visible, but restore the previously focused macOS app through Launch Services while Chrome starts so the window does not come to the front or take the keyboard. System Events hangs during that activation, so osascript restore cannot hold focus.
|
|
24
|
+
- Reap every Chrome process that still owns the Extension profile before relaunch, and kill a macOS `open -n` browser that never exposes CDP, so repeated launches cannot stack headed Chrome instances on the same user-data-dir.
|
|
25
|
+
- Close extra MetaMask home tabs after an Extension browser launch. `open -n` with a start URL against a still-live profile was stacking unlock tabs.
|
|
26
|
+
- The macOS focus hold ends as soon as the operator brings any other app to the front, and the reopen launcher reaps a Chrome that never exposed CDP instead of leaving it behind.
|
|
27
|
+
- Stop every Extension Chrome whose `--user-data-dir` is under this checkout's `temp/recipe/runtime/`, including uniquely named recipe profiles and checkout paths that contain spaces. The previous allow-list of `chrome-profile`, `chrome-profile-recipe`, and `chrome-profile-pw` left leftovers that `mm-harness stop` reported as already stopped. Nested `console-tail.mjs` log paths under that runtime dir are reaped the same way. Personal Chrome and other checkouts stay untouched.
|
|
28
|
+
|
|
5
29
|
## 0.51.5 - 2026-09-14
|
|
6
30
|
|
|
7
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -193,6 +193,31 @@ Teams maintain their library checkouts. Update them before starting a task;
|
|
|
193
193
|
the harness records local revisions and recipe digests but does not fetch or
|
|
194
194
|
check for upstream updates. Keep the selected library unchanged during a run.
|
|
195
195
|
|
|
196
|
+
## Review a change
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# Which team library owns the change (declared --domain wins, else owned-paths.json).
|
|
200
|
+
mm-harness domain
|
|
201
|
+
|
|
202
|
+
# The review guide: base review + the library's anti-pattern families and parity rule.
|
|
203
|
+
mm-harness help review --domain perps
|
|
204
|
+
|
|
205
|
+
# A checklist the mark shim can track: Setup, Base review, Domain patterns, Parity, Verdict.
|
|
206
|
+
mm-harness review checklist --domain perps --out temp/tasks/review/TAT-1/CHECKLIST.md
|
|
207
|
+
mm-harness review checklist --domain perps --since <last-reviewed-sha> # incremental re-review
|
|
208
|
+
|
|
209
|
+
# Where this machine keeps libraries and the other platform's checkout (outside any repo).
|
|
210
|
+
mm-harness config set references.extension ~/dev/metamask-extension
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A team library supplies `review/antipatterns.md` (one `##` section per family),
|
|
214
|
+
optional `review/antipatterns.<adapter>.md` families appended for that adapter only,
|
|
215
|
+
`review/parity.md`, and `owned-paths.json`; the harness composes them and never
|
|
216
|
+
copies them. Locations resolve env (`RECIPE_LIBRARY_PATH`, `MM_HARNESS_REF_<ADAPTER>`)
|
|
217
|
+
→ `mm-harness config` → sibling directory beside the checkout → shallow fetch into
|
|
218
|
+
`<checkout>/.skills-cache/<name>`. A missing reference checkout degrades the parity
|
|
219
|
+
step to "not checked" with the reason.
|
|
220
|
+
|
|
196
221
|
## Recover
|
|
197
222
|
|
|
198
223
|
```bash
|
|
@@ -301,7 +301,8 @@ const {
|
|
|
301
301
|
runtimeIdentityArgs,
|
|
302
302
|
writeRuntimeIdentity,
|
|
303
303
|
} = require(path.join('${SCRIPT_DIR}', 'lib/chrome-args.cjs'));
|
|
304
|
-
const {
|
|
304
|
+
const { captureMacFrontmost, macBackgroundOpenArgs, preserveMacFrontmost, startMacFocusHold, stopMacFocusHold } = require(path.join('${SCRIPT_DIR}', 'lib/macos-focus.cjs'));
|
|
305
|
+
const { stopProfileProcessesSync } = require(path.join('${SCRIPT_DIR}', 'lib/validation-process-ownership.cjs'));
|
|
305
306
|
|
|
306
307
|
const SLOT_ID = '${SLOT_ID}';
|
|
307
308
|
const AGENT_DIR = '${AGENT_DIR}';
|
|
@@ -322,6 +323,14 @@ const resumeWebpack = () => {
|
|
|
322
323
|
if (WEBPACK_PID) { try { process.kill(-Number(WEBPACK_PID), 'SIGCONT'); } catch { try { process.kill(Number(WEBPACK_PID), 'SIGCONT'); } catch {} } }
|
|
323
324
|
};
|
|
324
325
|
|
|
326
|
+
const previousFrontmost = captureMacFrontmost();
|
|
327
|
+
const chromiumApp = path.dirname(path.dirname(path.dirname(chromium.executablePath())));
|
|
328
|
+
let focusHold = null;
|
|
329
|
+
const releaseFocusHold = () => {
|
|
330
|
+
stopMacFocusHold(focusHold);
|
|
331
|
+
preserveMacFrontmost(previousFrontmost, chromiumApp);
|
|
332
|
+
};
|
|
333
|
+
|
|
325
334
|
(async () => {
|
|
326
335
|
const runtimeNonce = createRuntimeIdentityNonce();
|
|
327
336
|
const startedAt = Date.now();
|
|
@@ -343,9 +352,8 @@ const resumeWebpack = () => {
|
|
|
343
352
|
args.push(...remoteDebuggingArgs(CDP_PORT));
|
|
344
353
|
}
|
|
345
354
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
execFileSync('open', ['-g', '-n', '-a', chromiumApp, '--args', ...args], { stdio: 'ignore' });
|
|
355
|
+
focusHold = startMacFocusHold(previousFrontmost, chromiumApp);
|
|
356
|
+
execFileSync('open', macBackgroundOpenArgs(chromiumApp, args), { stdio: 'ignore' });
|
|
349
357
|
for (let i = 0; i < 60; i++) {
|
|
350
358
|
try {
|
|
351
359
|
const version = await new Promise((resolve, reject) => {
|
|
@@ -360,7 +368,18 @@ const resumeWebpack = () => {
|
|
|
360
368
|
} catch {}
|
|
361
369
|
await new Promise(r => setTimeout(r, 1000));
|
|
362
370
|
}
|
|
363
|
-
|
|
371
|
+
let browser;
|
|
372
|
+
try {
|
|
373
|
+
browser = await chromium.connectOverCDP('http://127.0.0.1:' + CDP_PORT);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
// The submitted browser never exposed CDP: reap whatever owns the profile
|
|
376
|
+
// so the next launch cannot stack a second Chrome on the same user-data-dir.
|
|
377
|
+
stopProfileProcessesSync(PROFILE, { waitForAppearanceMs: 400 });
|
|
378
|
+
throw new Error(
|
|
379
|
+
'Reopened Chrome did not expose CDP on port ' + CDP_PORT + ': ' + (error && error.message ? error.message : String(error)) +
|
|
380
|
+
'. Next: inspect the slot browser process, then rerun: mm-harness launch --adapter extension --build'
|
|
381
|
+
);
|
|
382
|
+
}
|
|
364
383
|
const ctx = browser.contexts()[0];
|
|
365
384
|
let listenerPids;
|
|
366
385
|
try {
|
|
@@ -396,6 +415,7 @@ const resumeWebpack = () => {
|
|
|
396
415
|
} catch {}
|
|
397
416
|
}
|
|
398
417
|
if (!browserPid) {
|
|
418
|
+
stopProfileProcessesSync(PROFILE, { waitForAppearanceMs: 400 });
|
|
399
419
|
throw new Error(
|
|
400
420
|
'Reopened Chrome did not expose an owned CDP listener on port ' + CDP_PORT +
|
|
401
421
|
'. Next: inspect the slot browser process, then rerun: mm-harness launch --adapter extension --build'
|
|
@@ -428,11 +448,12 @@ const resumeWebpack = () => {
|
|
|
428
448
|
try {
|
|
429
449
|
extId = execFileSync(RUNNER_BIN, ['resolve-extension', '--adapter', 'extension', '--target', REPO], { encoding: 'utf8' }).trim();
|
|
430
450
|
} catch (err) {
|
|
451
|
+
releaseFocusHold();
|
|
431
452
|
resumeWebpack();
|
|
432
453
|
console.error('[FAIL] runner resolve-extension failed: ' + (err && err.message ? err.message : String(err)));
|
|
433
454
|
process.exit(1);
|
|
434
455
|
}
|
|
435
|
-
if (!/^[a-p]{32}$/.test(extId)) { resumeWebpack(); console.error('[FAIL] runner returned invalid extension id: ' + JSON.stringify(extId)); process.exit(1); }
|
|
456
|
+
if (!/^[a-p]{32}$/.test(extId)) { releaseFocusHold(); resumeWebpack(); console.error('[FAIL] runner returned invalid extension id: ' + JSON.stringify(extId)); process.exit(1); }
|
|
436
457
|
console.log('[reopen] Extension: ' + extId + ' (via runner resolve-extension)');
|
|
437
458
|
|
|
438
459
|
// Navigate to MetaMask home
|
|
@@ -498,8 +519,10 @@ const resumeWebpack = () => {
|
|
|
498
519
|
if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
|
|
499
520
|
await page.bringToFront().catch(() => {});
|
|
500
521
|
} else {
|
|
501
|
-
|
|
522
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
523
|
+
preserveMacFrontmost(previousFrontmost, chromiumApp);
|
|
502
524
|
}
|
|
525
|
+
releaseFocusHold();
|
|
503
526
|
|
|
504
527
|
fs.writeFileSync(path.join(AGENT_DIR, 'extension.id'), extId);
|
|
505
528
|
console.log('[reopen] Ready \u2014 ' + SLOT_ID + (CDP_PORT ? ' CDP:' + CDP_PORT : ''));
|
|
@@ -509,6 +532,7 @@ const resumeWebpack = () => {
|
|
|
509
532
|
if (WEBPACK_PID) console.log('[webpack] Resumed');
|
|
510
533
|
process.exit(0);
|
|
511
534
|
})().catch(e => {
|
|
535
|
+
releaseFocusHold();
|
|
512
536
|
resumeWebpack();
|
|
513
537
|
try { removeRuntimeIdentity(AGENT_DIR); } catch {}
|
|
514
538
|
try { fs.unlinkSync(path.join(AGENT_DIR, 'browser.pid')); } catch {}
|
|
@@ -24,7 +24,7 @@ const os = require('node:os');
|
|
|
24
24
|
const path = require('node:path');
|
|
25
25
|
const { execFileSync, spawn, spawnSync } = require('node:child_process');
|
|
26
26
|
const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
|
|
27
|
-
const { profileProcessPids } = require('./lib/validation-process-ownership.cjs');
|
|
27
|
+
const { profileProcessPids, stopProfileProcessesSync } = require('./lib/validation-process-ownership.cjs');
|
|
28
28
|
const { acquireBuildHandoff } = require('./build-handoff.cjs');
|
|
29
29
|
const {
|
|
30
30
|
automationRuntimeArgs,
|
|
@@ -42,7 +42,13 @@ const {
|
|
|
42
42
|
validationLaunchQuarantineError,
|
|
43
43
|
writeRuntimeIdentity,
|
|
44
44
|
} = require('./lib/chrome-args.cjs');
|
|
45
|
-
const {
|
|
45
|
+
const {
|
|
46
|
+
captureMacFrontmost,
|
|
47
|
+
macBackgroundOpenArgs,
|
|
48
|
+
preserveMacFrontmost,
|
|
49
|
+
startMacFocusHold,
|
|
50
|
+
stopMacFocusHold,
|
|
51
|
+
} = require('./lib/macos-focus.cjs');
|
|
46
52
|
|
|
47
53
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
48
54
|
console.log(
|
|
@@ -107,7 +113,7 @@ try {
|
|
|
107
113
|
'Next: remove the invalid runtime identity and fix owner permissions, then rerun: mm-harness launch --adapter extension --build',
|
|
108
114
|
);
|
|
109
115
|
}
|
|
110
|
-
|
|
116
|
+
stopProfileProcessesSync(args.profile, { extraPids: [...ownedPids] });
|
|
111
117
|
if (args['reset-profile'] !== undefined) {
|
|
112
118
|
fs.rmSync(args.profile, { recursive: true, force: true });
|
|
113
119
|
fs.mkdirSync(args.profile, { recursive: true });
|
|
@@ -164,70 +170,85 @@ const chromeArgs = [
|
|
|
164
170
|
initialUrl,
|
|
165
171
|
];
|
|
166
172
|
const logFd = fs.openSync(args['chrome-log'], 'a');
|
|
167
|
-
const
|
|
173
|
+
const previousFrontmost = captureMacFrontmost();
|
|
174
|
+
const application = process.platform === 'darwin' ? macApplicationForExecutable(args['chrome-bin']) : null;
|
|
175
|
+
const focusHold = startMacFocusHold(previousFrontmost, application);
|
|
168
176
|
let browserPid;
|
|
169
177
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
try {
|
|
179
|
+
if (application) {
|
|
180
|
+
beginDetachedLaunch(cdpPort, args.profile, activeValidationLease);
|
|
181
|
+
execFileSync('open', macBackgroundOpenArgs(application, chromeArgs), {
|
|
182
|
+
env: sanitizedChildEnv(),
|
|
183
|
+
stdio: ['ignore', logFd, logFd],
|
|
184
|
+
});
|
|
185
|
+
browserPid = waitForOwnedCdpPid(cdpPort, args.profile, args['extension-dir']);
|
|
186
|
+
if (browserPid === null) {
|
|
187
|
+
stopProfileProcessesSync(args.profile, { waitForAppearanceMs: 400 });
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}. ` +
|
|
190
|
+
`Next: inspect ${args['chrome-log']}, then rerun: mm-harness launch --adapter extension --build`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
beginDetachedLaunch(cdpPort, args.profile, activeValidationLease);
|
|
195
|
+
const child = spawn(args['chrome-bin'], chromeArgs, {
|
|
196
|
+
detached: true,
|
|
197
|
+
env: sanitizedChildEnv(),
|
|
198
|
+
stdio: ['ignore', logFd, logFd],
|
|
199
|
+
});
|
|
200
|
+
child.unref();
|
|
201
|
+
browserPid = waitForOwnedCdpPid(cdpPort, args.profile, args['extension-dir']);
|
|
202
|
+
if (browserPid === null) {
|
|
203
|
+
stopProfileProcessesSync(args.profile, { extraPids: child.pid ? [child.pid] : [] });
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}. ` +
|
|
206
|
+
`Next: inspect ${args['chrome-log']}, then rerun: mm-harness launch --adapter extension --build`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const problem of pruneExtraHomeTabs(cdpPort, args['extension-dir'])) {
|
|
211
|
+
process.stderr.write(`[launch] home-tab pruning: ${problem}\n`);
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
fs.closeSync(logFd);
|
|
215
|
+
}
|
|
216
|
+
fs.writeFileSync(args['chrome-pid'], `${browserPid}\n`);
|
|
217
|
+
try {
|
|
218
|
+
writeRuntimeIdentity(runtimeDir, {
|
|
219
|
+
port: cdpPort,
|
|
220
|
+
pid: browserPid,
|
|
221
|
+
startedAt,
|
|
222
|
+
nonce: runtimeNonce,
|
|
176
223
|
});
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
224
|
+
clearDetachedLaunchUnproven(args.profile);
|
|
225
|
+
if (!activeValidationLease) {
|
|
226
|
+
clearValidationPortQuarantine(cdpPort, args.profile);
|
|
180
227
|
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
228
|
+
} catch (error) {
|
|
229
|
+
terminatePids([browserPid]);
|
|
230
|
+
fs.rmSync(args['chrome-pid'], { force: true });
|
|
231
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Failed to persist Extension runtime identity under ${runtimeDir}: ${detail}. ` +
|
|
234
|
+
'Next: fix owner write permissions for the runtime directory, then rerun: mm-harness launch --adapter extension --build',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (process.platform === 'darwin') {
|
|
238
|
+
const wake = spawn('caffeinate', ['-dims', '-w', String(browserPid)], {
|
|
184
239
|
detached: true,
|
|
185
|
-
|
|
186
|
-
stdio: ['ignore', logFd, logFd],
|
|
240
|
+
stdio: 'ignore',
|
|
187
241
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
`Next: inspect ${args['chrome-log']}, then rerun: mm-harness launch --adapter extension --build`,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
242
|
+
wake.unref();
|
|
243
|
+
// Chrome often activates when the first window paints, which can be after CDP
|
|
244
|
+
// is already listening. Keep the previous app frontmost through that paint.
|
|
245
|
+
const settleMs = Number(process.env.MM_HARNESS_FOCUS_SETTLE_MS);
|
|
246
|
+
const waitSec = Number.isFinite(settleMs) && settleMs >= 0 ? settleMs / 1000 : 1;
|
|
247
|
+
if (waitSec > 0) spawnSync('sleep', [String(waitSec)]);
|
|
197
248
|
}
|
|
198
249
|
} finally {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
fs.writeFileSync(args['chrome-pid'], `${browserPid}\n`);
|
|
202
|
-
try {
|
|
203
|
-
writeRuntimeIdentity(runtimeDir, {
|
|
204
|
-
port: cdpPort,
|
|
205
|
-
pid: browserPid,
|
|
206
|
-
startedAt,
|
|
207
|
-
nonce: runtimeNonce,
|
|
208
|
-
});
|
|
209
|
-
clearDetachedLaunchUnproven(args.profile);
|
|
210
|
-
if (!activeValidationLease) {
|
|
211
|
-
clearValidationPortQuarantine(cdpPort, args.profile);
|
|
212
|
-
}
|
|
213
|
-
} catch (error) {
|
|
214
|
-
terminatePids([browserPid]);
|
|
215
|
-
fs.rmSync(args['chrome-pid'], { force: true });
|
|
216
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
217
|
-
throw new Error(
|
|
218
|
-
`Failed to persist Extension runtime identity under ${runtimeDir}: ${detail}. ` +
|
|
219
|
-
'Next: fix owner write permissions for the runtime directory, then rerun: mm-harness launch --adapter extension --build',
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
if (process.platform === 'darwin') {
|
|
223
|
-
const wake = spawn('caffeinate', ['-dimsu', '-w', String(browserPid)], {
|
|
224
|
-
detached: true,
|
|
225
|
-
stdio: 'ignore',
|
|
226
|
-
});
|
|
227
|
-
wake.unref();
|
|
228
|
-
}
|
|
229
|
-
if (process.env.MM_HARNESS_FOCUS_BROWSER !== '1') {
|
|
230
|
-
restoreMacFrontmostProcess(previousFrontmostPid);
|
|
250
|
+
stopMacFocusHold(focusHold);
|
|
251
|
+
preserveMacFrontmost(previousFrontmost, application);
|
|
231
252
|
}
|
|
232
253
|
|
|
233
254
|
function sanitizedChildEnv() {
|
|
@@ -426,3 +447,77 @@ function extensionHomeUrl(extensionDir) {
|
|
|
426
447
|
const id = extensionIdFromExtensionDir(extensionDir);
|
|
427
448
|
return id ? `chrome-extension://${id}/home.html` : '';
|
|
428
449
|
}
|
|
450
|
+
|
|
451
|
+
function cdpHttp(port, pathname) {
|
|
452
|
+
const result = spawnSync(process.execPath, ['-e', `
|
|
453
|
+
const http = require('http');
|
|
454
|
+
http.get('http://127.0.0.1:${Number(port)}${pathname}', (res) => {
|
|
455
|
+
let body = '';
|
|
456
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
457
|
+
res.on('end', () => {
|
|
458
|
+
process.stdout.write(body);
|
|
459
|
+
process.exitCode = res.statusCode === 200 ? 0 : 1;
|
|
460
|
+
});
|
|
461
|
+
}).on('error', () => process.exit(1));
|
|
462
|
+
`], { encoding: 'utf8', timeout: 4000 });
|
|
463
|
+
if (result.status !== 0) return null;
|
|
464
|
+
return result.stdout;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Best effort by design: CDP ownership is already proven and ensure-ready
|
|
468
|
+
// converges home tabs again during verify, so a pruning failure must not fail
|
|
469
|
+
// the launch. Every failure is returned so the launch log says why a duplicate
|
|
470
|
+
// tab survived instead of hiding it.
|
|
471
|
+
function pruneExtraHomeTabs(port, extensionDir) {
|
|
472
|
+
const id = extensionIdFromExtensionDir(extensionDir);
|
|
473
|
+
if (!id) return ['extension id unavailable; skipped'];
|
|
474
|
+
const raw = cdpHttp(port, '/json/list');
|
|
475
|
+
if (raw === null) return [`GET http://127.0.0.1:${port}/json/list failed`];
|
|
476
|
+
let targets;
|
|
477
|
+
try {
|
|
478
|
+
targets = JSON.parse(raw);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
return [`/json/list returned malformed JSON: ${error.message}`];
|
|
481
|
+
}
|
|
482
|
+
if (!Array.isArray(targets)) return ['/json/list did not return a target list'];
|
|
483
|
+
const problems = [];
|
|
484
|
+
const close = (target) => {
|
|
485
|
+
if (cdpHttp(port, `/json/close/${target.id}`) === null) problems.push(`could not close ${target.url} (${target.id})`);
|
|
486
|
+
};
|
|
487
|
+
const homes = targets.filter((target) => (
|
|
488
|
+
target &&
|
|
489
|
+
target.type === 'page' &&
|
|
490
|
+
typeof target.id === 'string' &&
|
|
491
|
+
typeof target.url === 'string' &&
|
|
492
|
+
target.url.includes(`chrome-extension://${id}/`) &&
|
|
493
|
+
target.url.includes('/home.html')
|
|
494
|
+
));
|
|
495
|
+
const strays = targets.filter((target) => (
|
|
496
|
+
target &&
|
|
497
|
+
target.type === 'page' &&
|
|
498
|
+
typeof target.id === 'string' &&
|
|
499
|
+
typeof target.url === 'string' &&
|
|
500
|
+
(
|
|
501
|
+
target.url === 'about:blank' ||
|
|
502
|
+
target.url === 'chrome://newtab/' ||
|
|
503
|
+
target.url.startsWith('chrome://new-tab-page')
|
|
504
|
+
)
|
|
505
|
+
));
|
|
506
|
+
if (homes.length === 0) return problems;
|
|
507
|
+
const keeper = homes.find((target) => (
|
|
508
|
+
typeof target.title === 'string' &&
|
|
509
|
+
target.title.trim() !== '' &&
|
|
510
|
+
target.title.trim() !== 'MetaMask'
|
|
511
|
+
)) ?? homes[0];
|
|
512
|
+
if (homes.length > 1) {
|
|
513
|
+
for (const extra of homes) {
|
|
514
|
+
if (keeper && extra.id === keeper.id) continue;
|
|
515
|
+
close(extra);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const stray of strays) {
|
|
519
|
+
if (keeper && stray.id === keeper.id) continue;
|
|
520
|
+
close(stray);
|
|
521
|
+
}
|
|
522
|
+
return problems;
|
|
523
|
+
}
|
|
@@ -1,32 +1,227 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { execFileSync } = require('node:child_process');
|
|
3
|
+
const { execFileSync, spawn } = require('node:child_process');
|
|
4
|
+
const fs = require('node:fs');
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
const HOLD_INTERVAL_MS = 20;
|
|
7
|
+
// Upper bound for one Launch Services query or `open -a`. A short bound made a
|
|
8
|
+
// loaded Mac time out the first query, which silently disabled the whole hold.
|
|
9
|
+
const LS_TIMEOUT_MS = 2000;
|
|
10
|
+
// Hard cap on a hold whose parent is alive but never released it.
|
|
11
|
+
const HOLD_MAX_MS = 120_000;
|
|
12
|
+
const toolEnv = () => ({ ...process.env });
|
|
13
|
+
|
|
14
|
+
function captureMacFrontmost() {
|
|
6
15
|
if (process.platform !== 'darwin') return null;
|
|
7
16
|
try {
|
|
8
|
-
const
|
|
9
|
-
'
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
const asn = execFileSync('lsappinfo', ['front'], {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
env: toolEnv(),
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
21
|
+
timeout: LS_TIMEOUT_MS,
|
|
22
|
+
}).trim();
|
|
23
|
+
if (!asn) return null;
|
|
24
|
+
return parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
env: toolEnv(),
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
|
+
timeout: LS_TIMEOUT_MS,
|
|
29
|
+
}));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function captureMacFrontmostProcess() {
|
|
36
|
+
return captureMacFrontmost()?.pid ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseLsappinfo(info) {
|
|
40
|
+
const pid = Number.parseInt((info.match(/\bpid\s*=\s*(\d+)/u) || info.match(/"pid"=(\d+)/u) || [])[1], 10);
|
|
41
|
+
const bundlePath = (info.match(/bundle path="([^"]+)"/u) || info.match(/"LSBundlePath"="([^"]+)"/u) || [])[1];
|
|
42
|
+
const name = (info.match(/^"([^"]+)"/mu) || info.match(/"LSDisplayName"="([^"]+)"/u) || [])[1];
|
|
43
|
+
if (!Number.isInteger(pid) || pid <= 0 || !bundlePath) return null;
|
|
44
|
+
return { pid, bundlePath, name: name || bundlePath };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function currentFrontmostPid() {
|
|
48
|
+
try {
|
|
49
|
+
const asn = execFileSync('lsappinfo', ['front'], {
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
env: toolEnv(),
|
|
52
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
53
|
+
timeout: LS_TIMEOUT_MS,
|
|
54
|
+
}).trim();
|
|
55
|
+
if (!asn) return null;
|
|
56
|
+
const value = execFileSync('lsappinfo', ['info', '-only', 'pid', asn], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
env: toolEnv(),
|
|
59
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
60
|
+
timeout: LS_TIMEOUT_MS,
|
|
61
|
+
}).trim();
|
|
62
|
+
const pid = Number.parseInt((value.match(/"pid"=(\d+)/u) || [])[1], 10);
|
|
13
63
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
14
64
|
} catch {
|
|
15
65
|
return null;
|
|
16
66
|
}
|
|
17
67
|
}
|
|
18
68
|
|
|
69
|
+
function restoreMacFrontmost(target) {
|
|
70
|
+
if (process.platform !== 'darwin' || !target?.bundlePath) return false;
|
|
71
|
+
try {
|
|
72
|
+
execFileSync('open', ['-a', target.bundlePath], {
|
|
73
|
+
env: toolEnv(),
|
|
74
|
+
stdio: 'ignore',
|
|
75
|
+
timeout: LS_TIMEOUT_MS,
|
|
76
|
+
});
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
19
83
|
function restoreMacFrontmostProcess(pid) {
|
|
20
84
|
if (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 0) return false;
|
|
21
85
|
try {
|
|
22
|
-
execFileSync('
|
|
23
|
-
'
|
|
24
|
-
|
|
25
|
-
|
|
86
|
+
const asn = execFileSync('lsappinfo', ['find', `pid=${pid}`], {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
env: toolEnv(),
|
|
89
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
90
|
+
timeout: LS_TIMEOUT_MS,
|
|
91
|
+
}).trim();
|
|
92
|
+
if (!asn) return false;
|
|
93
|
+
return restoreMacFrontmost(parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
env: toolEnv(),
|
|
96
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
97
|
+
timeout: LS_TIMEOUT_MS,
|
|
98
|
+
})));
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Bring `target` back in front of the launched browser. With `launchedBundlePath`
|
|
105
|
+
// given, any other frontmost app is an intentional operator switch and is kept.
|
|
106
|
+
function preserveMacFrontmost(target, launchedBundlePath) {
|
|
107
|
+
if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return false;
|
|
108
|
+
if (process.platform !== 'darwin' || !target?.bundlePath || !Number.isInteger(target.pid) || target.pid <= 0) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const front = captureMacFrontmost();
|
|
112
|
+
if (front && front.pid === target.pid) return true;
|
|
113
|
+
if (front && launchedBundlePath && !sameBundle(front.bundlePath, launchedBundlePath)) return false;
|
|
114
|
+
return restoreMacFrontmost(target);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function preserveMacFrontmostProcess(pid) {
|
|
118
|
+
if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return false;
|
|
119
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
120
|
+
const current = currentFrontmostPid();
|
|
121
|
+
if (current === pid) return true;
|
|
122
|
+
return restoreMacFrontmostProcess(pid);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function macBackgroundOpenArgs(application, chromeArgs) {
|
|
126
|
+
return ['-g', '-n', '-a', application, '--args', ...chromeArgs];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Keep the previously focused app in front while `launchedBundlePath` starts.
|
|
130
|
+
// The hold only counters the launched browser: the moment any other app is
|
|
131
|
+
// brought to the front, the operator switched on purpose and the hold ends.
|
|
132
|
+
function startMacFocusHold(targetOrPid, launchedBundlePath) {
|
|
133
|
+
if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return null;
|
|
134
|
+
if (process.platform !== 'darwin') return null;
|
|
135
|
+
const target = typeof targetOrPid === 'number'
|
|
136
|
+
? { pid: targetOrPid, bundlePath: bundlePathForPid(targetOrPid) }
|
|
137
|
+
: targetOrPid;
|
|
138
|
+
if (!target?.bundlePath || !Number.isInteger(target.pid) || target.pid <= 0) return null;
|
|
139
|
+
return spawn(process.execPath, [
|
|
140
|
+
__filename,
|
|
141
|
+
'--hold',
|
|
142
|
+
String(target.pid),
|
|
143
|
+
target.bundlePath,
|
|
144
|
+
String(process.pid),
|
|
145
|
+
launchedBundlePath || '',
|
|
146
|
+
], { stdio: 'ignore', env: toolEnv() });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sameBundle(left, right) {
|
|
150
|
+
if (!left || !right) return false;
|
|
151
|
+
const real = (value) => {
|
|
152
|
+
try {
|
|
153
|
+
return fs.realpathSync(value);
|
|
154
|
+
} catch {
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return real(left) === real(right);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function bundlePathForPid(pid) {
|
|
162
|
+
try {
|
|
163
|
+
const asn = execFileSync('lsappinfo', ['find', `pid=${pid}`], {
|
|
164
|
+
encoding: 'utf8',
|
|
165
|
+
env: toolEnv(),
|
|
166
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
167
|
+
timeout: LS_TIMEOUT_MS,
|
|
168
|
+
}).trim();
|
|
169
|
+
return parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
env: toolEnv(),
|
|
172
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
173
|
+
timeout: LS_TIMEOUT_MS,
|
|
174
|
+
}))?.bundlePath ?? null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function stopMacFocusHold(child) {
|
|
181
|
+
if (!child || !Number.isInteger(child.pid) || child.pid <= 0) return;
|
|
182
|
+
try {
|
|
183
|
+
process.kill(child.pid, 'SIGTERM');
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error.code !== 'ESRCH') throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parentAlive(pid) {
|
|
190
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
191
|
+
try {
|
|
192
|
+
process.kill(pid, 0);
|
|
26
193
|
return true;
|
|
27
194
|
} catch {
|
|
28
195
|
return false;
|
|
29
196
|
}
|
|
30
197
|
}
|
|
31
198
|
|
|
32
|
-
|
|
199
|
+
if (require.main === module && process.argv[2] === '--hold') {
|
|
200
|
+
const pid = Number.parseInt(process.argv[3], 10);
|
|
201
|
+
const bundlePath = process.argv[4];
|
|
202
|
+
const parentPid = Number.parseInt(process.argv[5], 10);
|
|
203
|
+
const launchedBundlePath = process.argv[6] || '';
|
|
204
|
+
const target = { pid, bundlePath };
|
|
205
|
+
const hold = () => {
|
|
206
|
+
if (!parentAlive(parentPid)) process.exit(0);
|
|
207
|
+
const front = captureMacFrontmost();
|
|
208
|
+
if (front && front.pid === target.pid) return;
|
|
209
|
+
if (front && launchedBundlePath && !sameBundle(front.bundlePath, launchedBundlePath)) process.exit(0);
|
|
210
|
+
preserveMacFrontmost(target, launchedBundlePath);
|
|
211
|
+
};
|
|
212
|
+
hold();
|
|
213
|
+
setInterval(hold, HOLD_INTERVAL_MS);
|
|
214
|
+
setTimeout(() => process.exit(0), HOLD_MAX_MS);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
captureMacFrontmost,
|
|
219
|
+
captureMacFrontmostProcess,
|
|
220
|
+
macBackgroundOpenArgs,
|
|
221
|
+
preserveMacFrontmost,
|
|
222
|
+
preserveMacFrontmostProcess,
|
|
223
|
+
restoreMacFrontmost,
|
|
224
|
+
restoreMacFrontmostProcess,
|
|
225
|
+
startMacFocusHold,
|
|
226
|
+
stopMacFocusHold,
|
|
227
|
+
};
|