@extension.dev/mcp 10.10.0 → 10.10.2
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +247 -0
- package/README.md +5 -4
- package/claude/CLAUDE.md +2 -1
- package/dist/module.js +2232 -261
- package/dist/src/lib/bridge-tabs.d.ts +8 -1
- package/dist/src/lib/cdp-devtools.d.ts +27 -0
- package/dist/src/lib/cdp-extension-page.d.ts +18 -0
- package/dist/src/lib/cdp-targets.d.ts +7 -0
- package/dist/src/lib/cdp.d.ts +2 -0
- package/dist/src/lib/common-schema.d.ts +3 -3
- package/dist/src/lib/credentials.d.ts +24 -3
- package/dist/src/lib/device-flow.d.ts +6 -3
- package/dist/src/lib/exec.d.ts +1 -0
- package/dist/src/lib/extension-surfaces.d.ts +7 -0
- package/dist/src/lib/publish.d.ts +3 -1
- package/dist/src/lib/registry.d.ts +1 -0
- package/dist/src/lib/relay-eval.d.ts +12 -0
- package/dist/src/lib/session-browser.d.ts +1 -1
- package/dist/src/lib/session-paths.d.ts +10 -1
- package/dist/src/lib/types.d.ts +3 -0
- package/dist/src/tools/browsers.d.ts +1 -1
- package/dist/src/tools/build.d.ts +1 -1
- package/dist/src/tools/detect-browsers.d.ts +3 -0
- package/dist/src/tools/dev.d.ts +1 -1
- package/dist/src/tools/eval.d.ts +4 -1
- package/dist/src/tools/inspect-gecko.d.ts +3 -0
- package/dist/src/tools/logout.d.ts +1 -1
- package/dist/src/tools/open.d.ts +25 -1
- package/dist/src/tools/preview-web.d.ts +7 -2
- package/dist/src/tools/project-create.d.ts +15 -0
- package/dist/src/tools/publish.d.ts +5 -0
- package/dist/src/tools/release-promote.d.ts +5 -0
- package/dist/src/tools/shares.d.ts +5 -0
- package/dist/src/tools/start.d.ts +6 -1
- package/dist/src/tools/stop.d.ts +5 -0
- package/dist/src/tools/submit.d.ts +5 -0
- package/dist/src/tools/workspace-create.d.ts +42 -0
- package/package.json +18 -13
- package/server.json +3 -3
package/dist/module.js
CHANGED
|
@@ -11,7 +11,7 @@ import { PROD_ORIGINS, resolveOrigins } from "@extension.dev/urls/origins";
|
|
|
11
11
|
import node_crypto, { createHash } from "node:crypto";
|
|
12
12
|
import { consoleProjectPath, wwwNewPath } from "@extension.dev/urls/paths";
|
|
13
13
|
import { UserlandProjectPage, userlandUrl } from "@extension.dev/urls/userland";
|
|
14
|
-
import { CLOSE_BAD_HELLO, CLOSE_BAD_INSTANCE, CLOSE_CONTROL_UNAVAILABLE, CLOSE_SLOW_CONSUMER, CONTROL_ENVELOPE_VERSION, CONTROL_WS_PATH, buildSummaryPath, logsPath, matchesLogQuery, readLogEvents, readReadyContract, readyContractPath as bridge_readyContractPath, sessionArtifactsRootDir } from "extension-develop/bridge";
|
|
14
|
+
import { CLOSE_BAD_HELLO, CLOSE_BAD_INSTANCE, CLOSE_CONTROL_UNAVAILABLE, CLOSE_SLOW_CONSUMER, CONTROL_ENVELOPE_VERSION, CONTROL_WS_PATH, actionsPath, browserArtifactsDir, buildSummaryPath, eventsPath, logsPath, matchesLogQuery, readLogEvents, readReadyContract, readyContractPath as bridge_readyContractPath, sessionArtifactsRootDir, sessionStateDir as bridge_sessionStateDir } from "extension-develop/bridge";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { execFile, execFileSync } from "node:child_process";
|
|
17
17
|
import ws_0 from "ws";
|
|
@@ -93,7 +93,9 @@ __webpack_require__.r(eval_namespaceObject);
|
|
|
93
93
|
__webpack_require__.d(eval_namespaceObject, {
|
|
94
94
|
EXTENSION_PAGE_CONTEXTS: ()=>EXTENSION_PAGE_CONTEXTS,
|
|
95
95
|
chromiumManifestVersion: ()=>chromiumManifestVersion,
|
|
96
|
+
geckoManifestVersion: ()=>geckoManifestVersion,
|
|
96
97
|
handler: ()=>eval_handler,
|
|
98
|
+
isBrowserErrorPage: ()=>isBrowserErrorPage,
|
|
97
99
|
resolveDefaultEvalContext: ()=>resolveDefaultEvalContext,
|
|
98
100
|
schema: ()=>eval_schema,
|
|
99
101
|
wantsExtensionPageOverCdp: ()=>wantsExtensionPageOverCdp
|
|
@@ -192,6 +194,7 @@ __webpack_require__.d(start_namespaceObject, {
|
|
|
192
194
|
var stop_namespaceObject = {};
|
|
193
195
|
__webpack_require__.r(stop_namespaceObject);
|
|
194
196
|
__webpack_require__.d(stop_namespaceObject, {
|
|
197
|
+
contractProcessHints: ()=>contractProcessHints,
|
|
195
198
|
handler: ()=>stop_handler,
|
|
196
199
|
schema: ()=>stop_schema,
|
|
197
200
|
stopOne: ()=>stopOne
|
|
@@ -227,7 +230,13 @@ __webpack_require__.d(wait_namespaceObject, {
|
|
|
227
230
|
handler: ()=>wait_handler,
|
|
228
231
|
schema: ()=>wait_schema
|
|
229
232
|
});
|
|
230
|
-
var
|
|
233
|
+
var workspace_create_namespaceObject = {};
|
|
234
|
+
__webpack_require__.r(workspace_create_namespaceObject);
|
|
235
|
+
__webpack_require__.d(workspace_create_namespaceObject, {
|
|
236
|
+
handler: ()=>workspace_create_handler,
|
|
237
|
+
schema: ()=>workspace_create_schema
|
|
238
|
+
});
|
|
239
|
+
var package_namespaceObject = JSON.parse('{"rE":"10.10.2","El":{"OP":"4.1.31-canary.1791055414.4bbb683a"}}');
|
|
231
240
|
const ENVELOPE_SCHEMA = 1;
|
|
232
241
|
const collectWarnings = (warnings)=>{
|
|
233
242
|
if (!warnings) return [];
|
|
@@ -273,6 +282,10 @@ function pinnedCliVersion() {
|
|
|
273
282
|
function exactVersion(spec) {
|
|
274
283
|
return spec.trim().replace(/^[\^~]/, "").replace(/^v/, "");
|
|
275
284
|
}
|
|
285
|
+
function describeExtensionInvocation(projectDir) {
|
|
286
|
+
const { command, prefixArgs } = resolveExtensionInvocation(projectDir);
|
|
287
|
+
return prefixArgs.length ? `${command} ${prefixArgs.join(" ")} (this server's pinned engine; the project has no node_modules/.bin/extension)` : `${command} (the project's own Extension.js)`;
|
|
288
|
+
}
|
|
276
289
|
function resolveExtensionInvocation(projectDir) {
|
|
277
290
|
if (projectDir) {
|
|
278
291
|
const bin = node_path.join(projectDir, "node_modules", ".bin", "win32" === process.platform ? "extension.cmd" : "extension");
|
|
@@ -378,7 +391,7 @@ function spawnExtensionCli(args, options) {
|
|
|
378
391
|
...prefixArgs,
|
|
379
392
|
...args
|
|
380
393
|
], {
|
|
381
|
-
cwd: options?.cwd,
|
|
394
|
+
cwd: options?.cwd ?? options?.projectDir,
|
|
382
395
|
detached: true,
|
|
383
396
|
stdio: [
|
|
384
397
|
"ignore",
|
|
@@ -438,31 +451,85 @@ function credentialsPath() {
|
|
|
438
451
|
const base = xdg || node_path.join(node_os.homedir(), ".config");
|
|
439
452
|
return node_path.join(base, "extension-dev", "auth.json");
|
|
440
453
|
}
|
|
441
|
-
function
|
|
454
|
+
function credentialKey(workspaceSlug, projectSlug) {
|
|
455
|
+
return `${workspaceSlug}/${projectSlug}`.toLowerCase();
|
|
456
|
+
}
|
|
457
|
+
function readEntry(data) {
|
|
458
|
+
if (!data || "object" != typeof data) return null;
|
|
459
|
+
const entry = data;
|
|
460
|
+
if (1 !== entry.version) return null;
|
|
461
|
+
const token = String(entry.token || "").trim();
|
|
462
|
+
if (!token) return null;
|
|
463
|
+
const provider = "extensiondev" === entry.provider ? entry.provider : void 0;
|
|
464
|
+
return {
|
|
465
|
+
version: 1,
|
|
466
|
+
token,
|
|
467
|
+
workspaceSlug: String(entry.workspaceSlug || ""),
|
|
468
|
+
projectSlug: String(entry.projectSlug || ""),
|
|
469
|
+
expiresAt: Number(entry.expiresAt || 0),
|
|
470
|
+
api: String(entry.api || ""),
|
|
471
|
+
...provider ? {
|
|
472
|
+
provider
|
|
473
|
+
} : {}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function readCredentialStore() {
|
|
477
|
+
let data;
|
|
442
478
|
try {
|
|
443
|
-
|
|
444
|
-
const data = JSON.parse(raw);
|
|
445
|
-
if (!data || "object" != typeof data) return null;
|
|
446
|
-
if (1 !== data.version) return null;
|
|
447
|
-
const token = String(data.token || "").trim();
|
|
448
|
-
if (!token) return null;
|
|
449
|
-
const provider = "extensiondev" === data.provider ? data.provider : void 0;
|
|
450
|
-
return {
|
|
451
|
-
version: 1,
|
|
452
|
-
token,
|
|
453
|
-
workspaceSlug: String(data.workspaceSlug || ""),
|
|
454
|
-
projectSlug: String(data.projectSlug || ""),
|
|
455
|
-
expiresAt: Number(data.expiresAt || 0),
|
|
456
|
-
api: String(data.api || ""),
|
|
457
|
-
...provider ? {
|
|
458
|
-
provider
|
|
459
|
-
} : {}
|
|
460
|
-
};
|
|
479
|
+
data = JSON.parse(node_fs.readFileSync(credentialsPath(), "utf8"));
|
|
461
480
|
} catch {
|
|
462
481
|
return null;
|
|
463
482
|
}
|
|
483
|
+
if (!data || "object" != typeof data) return null;
|
|
484
|
+
const record = data;
|
|
485
|
+
if (1 === record.version) {
|
|
486
|
+
const only = readEntry(record);
|
|
487
|
+
if (!only) return null;
|
|
488
|
+
const key = credentialKey(only.workspaceSlug, only.projectSlug);
|
|
489
|
+
return {
|
|
490
|
+
version: 2,
|
|
491
|
+
active: key,
|
|
492
|
+
entries: {
|
|
493
|
+
[key]: only
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (2 !== record.version || !record.entries || "object" != typeof record.entries) return null;
|
|
498
|
+
const entries = {};
|
|
499
|
+
for (const [key, value] of Object.entries(record.entries)){
|
|
500
|
+
const entry = readEntry(value);
|
|
501
|
+
if (entry) entries[key.toLowerCase()] = entry;
|
|
502
|
+
}
|
|
503
|
+
const keys = Object.keys(entries);
|
|
504
|
+
if (0 === keys.length) return null;
|
|
505
|
+
const active = "string" == typeof record.active && entries[record.active.toLowerCase()] ? record.active.toLowerCase() : keys[0] ?? null;
|
|
506
|
+
return {
|
|
507
|
+
version: 2,
|
|
508
|
+
active,
|
|
509
|
+
entries
|
|
510
|
+
};
|
|
464
511
|
}
|
|
465
|
-
function
|
|
512
|
+
function selectEntry(store, selector) {
|
|
513
|
+
const wanted = String(selector?.project ?? "").trim().toLowerCase();
|
|
514
|
+
if (!wanted) return store.active && store.entries[store.active] || Object.values(store.entries)[0] || null;
|
|
515
|
+
if (wanted.includes("/")) return store.entries[wanted] ?? null;
|
|
516
|
+
const bySlug = Object.values(store.entries).filter((entry)=>entry.projectSlug.toLowerCase() === wanted);
|
|
517
|
+
return 1 === bySlug.length ? bySlug[0] ?? null : null;
|
|
518
|
+
}
|
|
519
|
+
function readCredentials(selector) {
|
|
520
|
+
const store = readCredentialStore();
|
|
521
|
+
return store ? selectEntry(store, selector) : null;
|
|
522
|
+
}
|
|
523
|
+
function listCredentials() {
|
|
524
|
+
const store = readCredentialStore();
|
|
525
|
+
if (!store) return [];
|
|
526
|
+
return Object.entries(store.entries).map(([key, entry])=>({
|
|
527
|
+
...entry,
|
|
528
|
+
key,
|
|
529
|
+
active: key === store.active
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
function writeStore(store) {
|
|
466
533
|
const file = credentialsPath();
|
|
467
534
|
const dir = node_path.dirname(file);
|
|
468
535
|
node_fs.mkdirSync(dir, {
|
|
@@ -472,7 +539,7 @@ function writeCredentials(creds) {
|
|
|
472
539
|
try {
|
|
473
540
|
node_fs.chmodSync(dir, 448);
|
|
474
541
|
} catch {}
|
|
475
|
-
node_fs.writeFileSync(file, JSON.stringify(
|
|
542
|
+
node_fs.writeFileSync(file, JSON.stringify(store, null, 2) + "\n", {
|
|
476
543
|
mode: 384
|
|
477
544
|
});
|
|
478
545
|
try {
|
|
@@ -480,27 +547,100 @@ function writeCredentials(creds) {
|
|
|
480
547
|
} catch {}
|
|
481
548
|
return file;
|
|
482
549
|
}
|
|
483
|
-
function
|
|
550
|
+
function writeCredentials(creds) {
|
|
551
|
+
const key = credentialKey(creds.workspaceSlug, creds.projectSlug);
|
|
552
|
+
const existing = readCredentialStore();
|
|
553
|
+
const entries = {
|
|
554
|
+
...existing?.entries ?? {},
|
|
555
|
+
[key]: creds
|
|
556
|
+
};
|
|
557
|
+
return writeStore({
|
|
558
|
+
version: 2,
|
|
559
|
+
active: key,
|
|
560
|
+
entries
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
function clearCredentials(selector) {
|
|
484
564
|
const file = credentialsPath();
|
|
485
|
-
|
|
565
|
+
const store = readCredentialStore();
|
|
566
|
+
const wanted = String(selector?.project ?? "").trim();
|
|
567
|
+
if (!wanted) try {
|
|
486
568
|
node_fs.unlinkSync(file);
|
|
487
569
|
return {
|
|
488
570
|
cleared: true,
|
|
489
|
-
path: file
|
|
571
|
+
path: file,
|
|
572
|
+
removed: store ? Object.keys(store.entries) : [],
|
|
573
|
+
remaining: []
|
|
490
574
|
};
|
|
491
575
|
} catch {
|
|
492
576
|
return {
|
|
493
577
|
cleared: false,
|
|
494
|
-
path: file
|
|
578
|
+
path: file,
|
|
579
|
+
removed: [],
|
|
580
|
+
remaining: []
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (!store) return {
|
|
584
|
+
cleared: false,
|
|
585
|
+
path: file,
|
|
586
|
+
removed: [],
|
|
587
|
+
remaining: []
|
|
588
|
+
};
|
|
589
|
+
const entry = selectEntry(store, {
|
|
590
|
+
project: wanted
|
|
591
|
+
});
|
|
592
|
+
if (!entry) return {
|
|
593
|
+
cleared: false,
|
|
594
|
+
path: file,
|
|
595
|
+
removed: [],
|
|
596
|
+
remaining: Object.keys(store.entries)
|
|
597
|
+
};
|
|
598
|
+
const key = credentialKey(entry.workspaceSlug, entry.projectSlug);
|
|
599
|
+
const entries = {
|
|
600
|
+
...store.entries
|
|
601
|
+
};
|
|
602
|
+
delete entries[key];
|
|
603
|
+
const remaining = Object.keys(entries);
|
|
604
|
+
if (0 === remaining.length) {
|
|
605
|
+
try {
|
|
606
|
+
node_fs.unlinkSync(file);
|
|
607
|
+
} catch {}
|
|
608
|
+
return {
|
|
609
|
+
cleared: true,
|
|
610
|
+
path: file,
|
|
611
|
+
removed: [
|
|
612
|
+
key
|
|
613
|
+
],
|
|
614
|
+
remaining: []
|
|
495
615
|
};
|
|
496
616
|
}
|
|
617
|
+
writeStore({
|
|
618
|
+
version: 2,
|
|
619
|
+
active: store.active === key ? remaining[0] ?? null : store.active,
|
|
620
|
+
entries
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
cleared: true,
|
|
624
|
+
path: file,
|
|
625
|
+
removed: [
|
|
626
|
+
key
|
|
627
|
+
],
|
|
628
|
+
remaining
|
|
629
|
+
};
|
|
497
630
|
}
|
|
498
|
-
function readValidCredentials(nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
499
|
-
const creds = readCredentials();
|
|
631
|
+
function readValidCredentials(nowSeconds = Math.floor(Date.now() / 1000), selector) {
|
|
632
|
+
const creds = readCredentials(selector);
|
|
500
633
|
if (!creds) return null;
|
|
501
634
|
if (creds.expiresAt && creds.expiresAt <= nowSeconds) return null;
|
|
502
635
|
return creds;
|
|
503
636
|
}
|
|
637
|
+
const PROJECT_TOKEN_INPUT = {
|
|
638
|
+
type: "string",
|
|
639
|
+
description: "Which stored login to use, as '<workspace>/<project>' (or a project slug that matches one login), when extension_auth has signed in to more than one project on this machine. Omitted, the most recent login is used, after EXTENSION_DEV_TOKEN when that is set. Named, it outranks EXTENSION_DEV_TOKEN. extension_auth (action: status) lists the stored logins."
|
|
640
|
+
};
|
|
641
|
+
function noStoredLoginHint(project) {
|
|
642
|
+
return `No stored login for ${project}. extension_auth (action: status) lists the logins on this machine; extension_auth (action: login, project: "${project}") adds one. Logging in to another project no longer replaces this one.`;
|
|
643
|
+
}
|
|
504
644
|
const DEFAULT_API = PROD_ORIGINS.www;
|
|
505
645
|
function tokenTtlNote(workspaceSlug, projectSlug) {
|
|
506
646
|
const tokensUrl = workspaceSlug && projectSlug ? consoleProjectUrl({
|
|
@@ -1059,6 +1199,10 @@ function parseChannels(json) {
|
|
|
1059
1199
|
}
|
|
1060
1200
|
return out;
|
|
1061
1201
|
}
|
|
1202
|
+
function isSuccessfulBuild(item) {
|
|
1203
|
+
const status = String(item?.status ?? "").trim().toLowerCase();
|
|
1204
|
+
return "success" === status || "ready" === status;
|
|
1205
|
+
}
|
|
1062
1206
|
function parseBuildIndex(json) {
|
|
1063
1207
|
const items = json?.items;
|
|
1064
1208
|
if (!Array.isArray(items)) return [];
|
|
@@ -1525,6 +1669,8 @@ const LAUNCHABLE_BROWSERS = [
|
|
|
1525
1669
|
"firefox",
|
|
1526
1670
|
"waterfox",
|
|
1527
1671
|
"librewolf",
|
|
1672
|
+
"zen",
|
|
1673
|
+
"floorp",
|
|
1528
1674
|
"safari",
|
|
1529
1675
|
"chromium-based",
|
|
1530
1676
|
"gecko-based",
|
|
@@ -1542,6 +1688,8 @@ const REAL_BROWSERS = [
|
|
|
1542
1688
|
"firefox",
|
|
1543
1689
|
"waterfox",
|
|
1544
1690
|
"librewolf",
|
|
1691
|
+
"zen",
|
|
1692
|
+
"floorp",
|
|
1545
1693
|
"safari"
|
|
1546
1694
|
];
|
|
1547
1695
|
const MANAGED_BROWSERS = [
|
|
@@ -1584,7 +1732,12 @@ async function requestDeviceCode(args) {
|
|
|
1584
1732
|
accept: "application/json"
|
|
1585
1733
|
},
|
|
1586
1734
|
body: JSON.stringify({
|
|
1587
|
-
|
|
1735
|
+
...args.project ? {
|
|
1736
|
+
project: args.project
|
|
1737
|
+
} : {},
|
|
1738
|
+
...args.workspace ? {
|
|
1739
|
+
workspace: args.workspace
|
|
1740
|
+
} : {},
|
|
1588
1741
|
clientName: args.clientName ?? "extension-mcp",
|
|
1589
1742
|
...args.intent ? {
|
|
1590
1743
|
intent: args.intent
|
|
@@ -1630,7 +1783,12 @@ async function pollDeviceGrant(args) {
|
|
|
1630
1783
|
},
|
|
1631
1784
|
body: JSON.stringify({
|
|
1632
1785
|
device_code: args.deviceCode,
|
|
1633
|
-
|
|
1786
|
+
...args.project ? {
|
|
1787
|
+
project: args.project
|
|
1788
|
+
} : {},
|
|
1789
|
+
...args.workspace ? {
|
|
1790
|
+
workspace: args.workspace
|
|
1791
|
+
} : {}
|
|
1634
1792
|
})
|
|
1635
1793
|
});
|
|
1636
1794
|
const text = await res.text();
|
|
@@ -1772,7 +1930,26 @@ const project_create_schema = {
|
|
|
1772
1930
|
outputDirectory: {
|
|
1773
1931
|
type: "string",
|
|
1774
1932
|
default: "dist/chrome",
|
|
1775
|
-
description: "Directory the build writes the loadable extension into."
|
|
1933
|
+
description: "Directory the build writes the loadable extension into. With more than one browser, `<browser>` in the path becomes each browser's name (Extension.js writes `dist/<browser>`), and when it is left out every browser defaults to `dist/<browser>`."
|
|
1934
|
+
},
|
|
1935
|
+
browsers: {
|
|
1936
|
+
type: "array",
|
|
1937
|
+
items: {
|
|
1938
|
+
type: "string",
|
|
1939
|
+
enum: [
|
|
1940
|
+
"chrome",
|
|
1941
|
+
"edge",
|
|
1942
|
+
"firefox"
|
|
1943
|
+
]
|
|
1944
|
+
},
|
|
1945
|
+
default: [
|
|
1946
|
+
"chrome"
|
|
1947
|
+
],
|
|
1948
|
+
description: "Browsers the platform builds, each enabled with the same install and build command and its own output directory. Pass every browser the extension targets, for example [\"chrome\", \"edge\", \"firefox\"], so the project needs no console visit to go cross-browser."
|
|
1949
|
+
},
|
|
1950
|
+
outputDirectories: {
|
|
1951
|
+
type: "object",
|
|
1952
|
+
description: "Per-browser output directory overrides, for example {\"edge\": \"build/manifestv3\"}. A browser named here wins over `outputDirectory`."
|
|
1776
1953
|
},
|
|
1777
1954
|
deviceCode: {
|
|
1778
1955
|
type: "string",
|
|
@@ -1786,7 +1963,7 @@ const project_create_schema = {
|
|
|
1786
1963
|
]
|
|
1787
1964
|
}
|
|
1788
1965
|
};
|
|
1789
|
-
function fail(name, message, status, code) {
|
|
1966
|
+
function fail(name, message, status, code, hint) {
|
|
1790
1967
|
return envelope_envelope({
|
|
1791
1968
|
ok: false,
|
|
1792
1969
|
command: project_create_COMMAND,
|
|
@@ -1795,9 +1972,15 @@ function fail(name, message, status, code) {
|
|
|
1795
1972
|
code,
|
|
1796
1973
|
name,
|
|
1797
1974
|
message
|
|
1798
|
-
}
|
|
1975
|
+
},
|
|
1976
|
+
...hint ? {
|
|
1977
|
+
hint
|
|
1978
|
+
} : {}
|
|
1799
1979
|
});
|
|
1800
1980
|
}
|
|
1981
|
+
function laneClosedHint() {
|
|
1982
|
+
return `Create the project in the console at ${consoleBase()} (workspace page, New project), then run extension_auth (action: login) against it. While the public hold is on, headless creation is open only to workspaces on the platform's allowlist (WWW_MCP_ACTION_ALLOWED_WORKSPACES), the same list that already lets publish, submit and promote run headlessly; this workspace is not on it, so the console is the route.`;
|
|
1983
|
+
}
|
|
1801
1984
|
function pendingEnvelope(start) {
|
|
1802
1985
|
const complete = String(start.verificationUriComplete || "").trim();
|
|
1803
1986
|
const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
|
|
@@ -1824,12 +2007,23 @@ function pendingEnvelope(start) {
|
|
|
1824
2007
|
function buildCreateBody(args) {
|
|
1825
2008
|
const installCommand = String(args.installCommand || "npm install").trim();
|
|
1826
2009
|
const buildCommand = String(args.buildCommand || "npm run build").trim();
|
|
1827
|
-
const
|
|
1828
|
-
|
|
1829
|
-
|
|
2010
|
+
const wanted = new Set((Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [
|
|
2011
|
+
"chrome"
|
|
2012
|
+
]).map((name)=>String(name).trim().toLowerCase()));
|
|
2013
|
+
const several = wanted.size > 1;
|
|
2014
|
+
const outputFor = (name)=>{
|
|
2015
|
+
const override = args.outputDirectories?.[name];
|
|
2016
|
+
if ("string" == typeof override && override.trim()) return override.trim();
|
|
2017
|
+
const pattern = String(args.outputDirectory || "").trim();
|
|
2018
|
+
if (pattern.includes("<browser>")) return pattern.replaceAll("<browser>", name);
|
|
2019
|
+
if (pattern && !several) return pattern;
|
|
2020
|
+
return `dist/${name}`;
|
|
2021
|
+
};
|
|
2022
|
+
const browser = (name)=>({
|
|
2023
|
+
enabled: wanted.has(name),
|
|
1830
2024
|
installCommand,
|
|
1831
2025
|
buildCommand,
|
|
1832
|
-
outputDirectory
|
|
2026
|
+
outputDirectory: outputFor(name)
|
|
1833
2027
|
});
|
|
1834
2028
|
return {
|
|
1835
2029
|
info: {
|
|
@@ -1839,9 +2033,9 @@ function buildCreateBody(args) {
|
|
|
1839
2033
|
description: String(args.description || `Browser extension project for ${args.owner}/${args.repoName}.`).trim()
|
|
1840
2034
|
},
|
|
1841
2035
|
build: {
|
|
1842
|
-
chrome: browser(
|
|
1843
|
-
edge: browser(
|
|
1844
|
-
firefox: browser(
|
|
2036
|
+
chrome: browser("chrome"),
|
|
2037
|
+
edge: browser("edge"),
|
|
2038
|
+
firefox: browser("firefox")
|
|
1845
2039
|
},
|
|
1846
2040
|
deployment: {
|
|
1847
2041
|
branch: "",
|
|
@@ -1896,7 +2090,7 @@ async function project_create_handler(args) {
|
|
|
1896
2090
|
const message = err?.message || "Could not start the device flow.";
|
|
1897
2091
|
const laneClosed = /CLI_PROJECT_CREATE_DISABLED|403/.test(String(message));
|
|
1898
2092
|
const serverMessage = "string" == typeof err?.serverMessage ? err.serverMessage.trim() : "";
|
|
1899
|
-
return fail("CreateStartError", laneClosed ? serverMessage || `Headless project creation is not open on this host yet. Create the project in the console instead, then run extension_auth (action: login) against it. (${message})` : String(message), laneClosed ? "lane-closed" : "create-failed", "E_PLATFORM");
|
|
2093
|
+
return fail("CreateStartError", laneClosed ? serverMessage || `Headless project creation is not open on this host yet. Create the project in the console instead, then run extension_auth (action: login) against it. (${message})` : String(message), laneClosed ? "lane-closed" : "create-failed", "E_PLATFORM", laneClosed ? laneClosedHint() : void 0);
|
|
1900
2094
|
}
|
|
1901
2095
|
deviceCode = start.deviceCode;
|
|
1902
2096
|
interval = start.interval;
|
|
@@ -1980,7 +2174,9 @@ async function finishFromPoll(poll, ctx) {
|
|
|
1980
2174
|
description: ctx.args.description,
|
|
1981
2175
|
installCommand: ctx.args.installCommand,
|
|
1982
2176
|
buildCommand: ctx.args.buildCommand,
|
|
1983
|
-
outputDirectory: ctx.args.outputDirectory
|
|
2177
|
+
outputDirectory: ctx.args.outputDirectory,
|
|
2178
|
+
browsers: ctx.args.browsers,
|
|
2179
|
+
outputDirectories: ctx.args.outputDirectories
|
|
1984
2180
|
});
|
|
1985
2181
|
const url = `${ctx.apiBase}/api/cli/projects/create`;
|
|
1986
2182
|
let res;
|
|
@@ -2017,7 +2213,7 @@ async function finishFromPoll(poll, ctx) {
|
|
|
2017
2213
|
project: wantProject
|
|
2018
2214
|
}
|
|
2019
2215
|
});
|
|
2020
|
-
if ("CLI_PROJECT_CREATE_DISABLED" === code) return fail("CreateClosed", String(data.message || "Headless project creation is not open yet."), "lane-closed", "E_PLATFORM");
|
|
2216
|
+
if ("CLI_PROJECT_CREATE_DISABLED" === code) return fail("CreateClosed", String(data.message || "Headless project creation is not open yet."), "lane-closed", "E_PLATFORM", laneClosedHint());
|
|
2021
2217
|
const connectUrl = String(data.connectUrl || "").trim();
|
|
2022
2218
|
if ("INSTALLATION_ABSENT" === code || "INSTALLATION_ORG_UNSUPPORTED" === code || "INSTALLATION_AMBIGUOUS" === code) return envelope_envelope({
|
|
2023
2219
|
ok: false,
|
|
@@ -2076,7 +2272,254 @@ async function finishFromPoll(poll, ctx) {
|
|
|
2076
2272
|
hint: `Project ${finalWorkspace}/${finalProject} exists and its first build was dispatched. The provisioning grant is now spent and nothing was stored on this machine. Next: extension_auth (action: login, project: '${finalWorkspace}/${finalProject}') to mint the project token, then extension_publish to share it.`
|
|
2077
2273
|
});
|
|
2078
2274
|
}
|
|
2079
|
-
var templates_meta_snapshot_namespaceObject = JSON.parse('{"version":"2","sourceRepo":"extension-js/examples","generatorVersion":"2","commit":"8a565020924a0f8be3ae9fa1c807999ba1b938e4","generatedAt":"2026-08-05T08:25:06.981Z","templates":[{"slug":"action","name":"action","title":"Action (Popup)","version":"1.0.0","manifest_version":3,"description":"Adds a toolbar popup to the browser with a simple page.","uiContext":["action","background"],"surfaces":["action","background"],"entrypoints":["./action/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["action","popup","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the toolbar icon to open the action popup","Edit src/action/index.html and watch HMR update the popup"],"useCases":["Toolbar popup as the primary UI surface","Quick actions that don\'t need a full sidebar or page","Starting point for popup-based browser extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/manifest-json","files":["extension.config.js","src/action/index.html","src/action/scripts.js","src/action/styles.css","src/background.js","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/action/screenshot.png","icon":"examples/action/src/images/icon.png"},{"slug":"action-locales","name":"action-locales","title":"Action Locales","version":"1.0.0","manifest_version":3,"description":"Adds a toolbar popup whose text follows your browser\'s language.","uiContext":["action","background"],"surfaces":["action","background"],"entrypoints":["./action/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/implementation-guide/locales","files":["_locales/en/messages.json","_locales/pt_BR/messages.json","extension.config.js","src/action/index.html","src/action/scripts.js","src/action/styles.css","src/background.js","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/action-locales/screenshot.png","icon":"examples/action-locales/src/images/icon.png"},{"slug":"ai-chatgpt","name":"ai-chatgpt","title":"ChatGPT AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with ChatGPT about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","activeTab","scripting","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","chatgpt","openai","gpt","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your ChatGPT API key (from platform.openai.com)","Ask ChatGPT about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using ChatGPT","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware ChatGPT extensions"],"docsUrl":"https://platform.openai.com/docs/api-reference/chat","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-chatgpt/screenshot.png","icon":"examples/ai-chatgpt/src/images/icon.png"},{"slug":"ai-claude","name":"ai-claude","title":"Claude AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with Claude about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","activeTab","scripting","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","claude","anthropic","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your Claude API key (from console.anthropic.com)","Ask Claude about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using Claude","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware Claude extensions"],"docsUrl":"https://docs.anthropic.com/en/api/getting-started","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-claude/screenshot.png","icon":"examples/ai-claude/src/images/icon.png"},{"slug":"ai-gemini","name":"ai-gemini","title":"Gemini AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with Gemini about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","activeTab","scripting","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","gemini","google","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your Gemini API key (from aistudio.google.com)","Ask Gemini about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using Gemini","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware Gemini extensions"],"docsUrl":"https://ai.google.dev/gemini-api/docs","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-gemini/screenshot.png","icon":"examples/ai-gemini/src/images/icon.png"},{"slug":"ai-perplexity","name":"ai-perplexity","title":"Perplexity AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with Perplexity about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","activeTab","scripting","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","perplexity","search","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your Perplexity API key (from perplexity.ai/settings/api)","Ask Perplexity about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using Perplexity","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware Perplexity extensions"],"docsUrl":"https://docs.perplexity.ai/","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-perplexity/screenshot.png","icon":"examples/ai-perplexity/src/images/icon.png"},{"slug":"content","name":"@extension-js/content","title":"Content scripts","version":"1.0.0","manifest_version":3,"description":"Injects a small badge into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.css","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content/screenshot.png","icon":"examples/content/src/images/icon.png"},{"slug":"content-css-modules","name":"@extension-js/content-css-modules","title":"Content CSS Modules","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/css-modules","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.module.css","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-css-modules/screenshot.png","icon":"examples/content-css-modules/src/images/icon.png"},{"slug":"content-custom-font","name":"content-custom-font","title":"Content Custom Font","version":"1.0.0","manifest_version":3,"description":"Injects a badge rendered in a custom font into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["postcss.config.js","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/implementation-guide/css","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.css","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-custom-font/screenshot.png","icon":"examples/content-custom-font/src/images/icon.png"},{"slug":"content-env","name":"content-env","title":"Content Env","version":"1.0.0","manifest_version":3,"description":"Injects a small panel showing the extension\'s environment values into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.ts","content/scripts.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/features/environment-variables","files":["extension.config.js","src/background.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-env/screenshot.png","icon":"examples/content-env/src/images/icon.png"},{"slug":"content-less","name":"@extension-js/content-less","title":"Content Less","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"less","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/less","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.less","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-less/screenshot.png","icon":"examples/content-less/src/images/icon.png"},{"slug":"content-less-modules","name":"@extension-js/content-less-modules","title":"Content Less Modules","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"less","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/less","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.module.less","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-less-modules/screenshot.png","icon":"examples/content-less-modules/src/images/icon.png"},{"slug":"content-main-world","name":"content-main-world","title":"Main-world Content Script","version":"1.0.0","manifest_version":3,"description":"Injects a small UI into every web page from both the extension and the page\'s own JavaScript world.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/isolated-one.js","content/isolated-two.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["content-scripts","main-world","isolated-world","javascript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any page and watch the main-world script run alongside isolated entries","Inspect window.postMessage bridges between isolated and main worlds"],"useCases":["Hook into page-level globals that isolated content scripts cannot reach","Bridge isolated and main-world execution contexts cleanly","Reference for multi-group content_scripts in MV3"],"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","files":["extension.config.js","src/background.js","src/content/isolated-one.js","src/content/isolated-two.js","src/content/scripts.js","src/content/styles.css","src/content/utils/constants.js","src/content/utils/create-badge.js","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-main-world/screenshot.png","icon":"examples/content-main-world/src/images/icon.png"},{"slug":"content-multi-one-entry","name":"content-multi-one-entry","title":"Content Multi One Entry","version":"1.0.0","manifest_version":3,"description":"Injects four small elements into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/script-top-left.js","content/script-top-right.js","content/script-bottom-left.js","content/script-bottom-right.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","files":["extension.config.js","src/background.js","src/content/script-bottom-left.js","src/content/script-bottom-right.js","src/content/script-top-left.js","src/content/script-top-right.js","src/content/styles.css","src/content/utils/constants.js","src/content/utils/create-badge.js","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-multi-one-entry/screenshot.png","icon":"examples/content-multi-one-entry/src/images/icon.png"},{"slug":"content-multi-three-entries","name":"content-multi-three-entries","title":"Multi-entry Content Scripts","version":"1.0.0","manifest_version":3,"description":"Injects four small elements into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/script-top-left.js","content/script-top-right.js","content/script-bottom-left.js","content/script-bottom-right.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["content-scripts","multi-entry","javascript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any page and three separate content scripts inject small elements","Edit any of the entries in src/content/ to see HMR update independently"],"useCases":["Split content-script logic across multiple entries with different match rules","Target different page contexts with focused, independent bundles","Reference for the multi-entry content_scripts pattern in MV3"],"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","files":["extension.config.js","src/background.js","src/content/script-bottom-left.js","src/content/script-bottom-right.js","src/content/script-top-left.js","src/content/script-top-right.js","src/content/styles.css","src/content/utils/constants.js","src/content/utils/create-badge.js","src/images/icon.png","src/manifest.json","src/screenshot.png"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-multi-three-entries/screenshot.png","icon":"examples/content-multi-three-entries/src/images/icon.png"},{"slug":"content-preact","name":"content-preact","title":"Content Preact","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.ts","./content/scripts.tsx"],"uiFramework":"preact","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/preact","files":["extension.config.js","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/chromeWindow.png","src/images/icon.png","src/images/preact.png","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-preact/screenshot.png","icon":"examples/content-preact/src/images/icon.png"},{"slug":"content-react","name":"content-react","title":"Content React","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.ts","./content/scripts.tsx"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/react","files":["extension.config.js","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/chromeWindow.png","src/images/icon.png","src/images/react.png","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-react/screenshot.png","icon":"examples/content-react/src/images/icon.png"},{"slug":"content-sass","name":"@extension-js/content-sass","title":"Content Sass","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"sass","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/implementation-guide/css","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.scss","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-sass/screenshot.png","icon":"examples/content-sass/src/images/icon.png"},{"slug":"content-sass-modules","name":"@extension-js/content-sass-modules","title":"Content Sass Modules","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"sass","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"build":"extension build"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/sass","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.module.scss","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-sass-modules/screenshot.png","icon":"examples/content-sass-modules/src/images/icon.png"},{"slug":"content-svelte","name":"content-svelte","title":"Content Svelte","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.ts","./content/scripts.ts"],"uiFramework":"svelte","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/svelte","files":["extension.config.js","src/background.ts","src/content/ContentApp.svelte","src/content/scripts.ts","src/content/styles.css","src/content/svelte.d.ts","src/images/chromeWindow.png","src/images/icon.png","src/images/svelte.png","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-svelte/screenshot.png","icon":"examples/content-svelte/src/images/icon.png"},{"slug":"content-typescript","name":"content-typescript","title":"Content TypeScript","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.ts","content/scripts.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/typescript","files":["extension.config.js","src/background.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-typescript/screenshot.png","icon":"examples/content-typescript/src/images/icon.png"},{"slug":"content-vue","name":"content-vue","title":"Content Vue","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.ts","./content/scripts.ts"],"uiFramework":"vue","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/vue","files":["extension.config.js","src/background.ts","src/content/ContentApp.vue","src/content/scripts.ts","src/content/shims-vue.d.ts","src/content/styles.css","src/images/chromeWindow.png","src/images/icon.png","src/images/logo.svg","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/images/vue.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-vue/screenshot.png","icon":"examples/content-vue/src/images/logo.svg"},{"slug":"init","name":"init","title":"Initial","version":"1.0.0","manifest_version":3,"description":"A minimal extension that loads in your browser, ready to build on.","uiContext":[],"surfaces":[],"entrypoints":[],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/getting-started/immediately","files":["extension.config.js","src/images/icon.png","src/images/javascript.png","src/index.js","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/init/screenshot.png","icon":"examples/init/src/images/icon.png"},{"slug":"javascript","name":"javascript","title":"JavaScript","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/getting-started/templates/javascript","files":["extension.config.js","src/background.js","src/content/ContentApp.js","src/content/scripts.js","src/content/styles.css","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.js","src/sidebar/index.html","src/sidebar/scripts.js","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/javascript/screenshot.png","icon":"examples/javascript/src/images/icon.png"},{"slug":"new","name":"new","title":"New Tab","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page you can customize.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/implementation-guide/html","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new/screenshot.png","icon":"examples/new/src/images/icon.png"},{"slug":"new-browser-flags","name":"new-browser-flags","title":"Newtab Browser Flags","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a launcher that opens the extension\'s sidebar panel.","uiContext":["content","newtab","sidebar","background"],"surfaces":["content","newtab","sidebar","background"],"entrypoints":["newtab/index.html","sidebar/index.html","background.js","content/content.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"build":"extension build"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting","notifications"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/browsers/browser-flags","files":["extension.config.js","src/background.js","src/content/content.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/newtab.css","src/newtab/newtab.js","src/sidebar/index.html","src/sidebar/sidebar.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-browser-flags/screenshot.png","icon":"examples/new-browser-flags/src/images/icon.png"},{"slug":"new-config-eslint","name":"new-config-eslint","title":"Newtab Config ESLint","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js","eslint.config.mjs"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/integrations/eslint","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-config-eslint/screenshot.png","icon":"examples/new-config-eslint/src/images/icon.png"},{"slug":"new-config-prettier","name":"new-config-prettier","title":"Newtab Config Prettier","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js",".prettierrc"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/integrations/prettier","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-config-prettier/screenshot.png","icon":"examples/new-config-prettier/src/images/icon.png"},{"slug":"new-config-stylelint","name":"new-config-stylelint","title":"Newtab Config Stylelint","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"sass","configFiles":[".stylelintrc.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/integrations/stylelint","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.scss"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-config-stylelint/screenshot.png","icon":"examples/new-config-stylelint/src/images/icon.png"},{"slug":"new-crypto","name":"new-crypto","title":"Newtab Crypto","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a page that hashes any text you type using SHA-256.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/node","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-crypto/screenshot.png","icon":"examples/new-crypto/src/images/icon.png"},{"slug":"new-env","name":"new-env","title":"Environment Variables","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a page showing the extension\'s environment values.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["env","configuration","new-tab","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Copy .env.example to .env and edit the values","Run npm run dev to launch the extension","Open a new tab and the page reads injected EXTENSION_PUBLIC_* values"],"useCases":["Inject build-time configuration without leaking secrets","Switch behavior per browser, mode, or deployment target","Reference for safe env-var usage across JS, JSON, and HTML"],"docsUrl":"https://extension.js.org/docs/features/environment-variables","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-env/screenshot.png","icon":"examples/new-env/src/images/icon.png"},{"slug":"new-less","name":"new-less","title":"Newtab Less","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple styled page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"less","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/less","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.less"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-less/screenshot.png","icon":"examples/new-less/src/images/icon.png"},{"slug":"new-preact","name":"new-preact","title":"Newtab Preact","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple Preact page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"preact","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/preact","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.tsx","src/newtab/index.html","src/newtab/scripts.tsx","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-preact/screenshot.png","icon":"examples/new-preact/src/images/icon.png"},{"slug":"new-react","name":"new-react","title":"Newtab React","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple React page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"react","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/react","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.tsx","src/newtab/index.html","src/newtab/scripts.tsx","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-react/screenshot.png","icon":"examples/new-react/src/images/icon.png"},{"slug":"new-react-router","name":"new-react-router","title":"Newtab React Router","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a small React app you can navigate across routes.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"react","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/react","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.tsx","src/newtab/index.html","src/newtab/scripts.tsx","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-react-router/screenshot.png","icon":"examples/new-react-router/src/images/icon.png"},{"slug":"new-sass","name":"new-sass","title":"Newtab Sass","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple styled page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"sass","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/sass","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.scss"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-sass/screenshot.png","icon":"examples/new-sass/src/images/icon.png"},{"slug":"new-svelte","name":"new-svelte","title":"Newtab Svelte","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple Svelte page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"svelte","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/svelte","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.svelte","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-svelte/screenshot.png","icon":"examples/new-svelte/src/images/icon.png"},{"slug":"new-typescript","name":"new-typescript","title":"Newtab TypeScript","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/typescript","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-typescript/screenshot.png","icon":"examples/new-typescript/src/images/icon.png"},{"slug":"new-vue","name":"new-vue","title":"Newtab Vue","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple Vue page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"vue","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/vue","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.vue","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/new-vue/screenshot.png","icon":"examples/new-vue/src/images/icon.png"},{"slug":"playwright","name":"playwright","title":"Playwright E2E Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser, covered end to end by Playwright tests.","uiContext":["sidebar","action","background"],"surfaces":["action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"test":"playwright test"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["testing","playwright","e2e","sidebar","typescript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":4,"firstSteps":["Run npm install to set up Playwright deps","Run npm run dev to launch the extension","Run npm test to execute the Playwright spec against the loaded extension"],"useCases":["End-to-end testing for browser-extension surfaces (popup, sidebar, content)","CI-ready Playwright setup wired to Extension.js builds","Reference for automating extension flows in headed and headless modes"],"docsUrl":"https://extension.js.org/docs/workflows/playwright-e2e","files":["extension.config.js","src/background.ts","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.ts","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/playwright/screenshot.png","icon":"examples/playwright/src/images/icon.png"},{"slug":"preact","name":"preact","title":"Preact","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple Preact page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.tsx"],"uiFramework":"preact","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/getting-started/templates/preact","files":["extension.config.js","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/icon.png","src/manifest.json","src/public/extension.svg","src/public/logo.svg","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/preact/screenshot.png","icon":"examples/preact/src/images/icon.png"},{"slug":"react","name":"react","title":"React","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple React page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.tsx"],"uiFramework":"react","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/getting-started/templates/react","files":["extension.config.js","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/react/screenshot.png","icon":"examples/react/src/images/icon.png"},{"slug":"sidebar","name":"sidebar","title":"Sidebar page","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple page.","uiContext":["sidebar","action","background"],"surfaces":["action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/implementation-guide/html","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/sidebar/index.html","src/sidebar/scripts.js","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar/screenshot.png","icon":"examples/sidebar/src/images/icon.png"},{"slug":"sidebar-antd","name":"sidebar-antd","title":"Sidebar Ant Design","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser showing Ant Design components.","uiContext":["sidebar","action","background"],"surfaces":["action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js"],"uiFramework":"react","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":false,"docsUrl":"https://ant.design/docs/react/introduce","files":["extension.config.js","src/background.js","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.jsx","src/sidebar/index.html","src/sidebar/scripts.jsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar-antd/screenshot.png","icon":"examples/sidebar-antd/src/images/icon.png"},{"slug":"sidebar-monorepo-turborepo","name":"sidebar-monorepo-turborepo","title":"Sidebar Turborepo","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel and shows a small overlay UI on every web page you visit.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"dev":"extension dev packages/extension","build":"extension build packages/extension"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://turborepo.dev/docs","files":["extension.config.js","packages/analytics/src/index.js","packages/extension/src/background.js","packages/extension/src/content/scripts.js","packages/extension/src/content/styles.css","packages/extension/src/images/icon.png","packages/extension/src/manifest.json","packages/extension/src/sidebar/SidebarApp.js","packages/extension/src/sidebar/index.html","packages/extension/src/sidebar/scripts.js","packages/extension/src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar-monorepo-turborepo/screenshot.png","icon":null},{"slug":"sidebar-shadcn","name":"sidebar-shadcn","title":"Sidebar shadcn","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser built with shadcn/ui components.","uiContext":["sidebar","action","background"],"surfaces":["action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/integrations/shadcnui","files":["extension.config.js","src/background.ts","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/label.tsx","src/components/ui/switch.tsx","src/images/icon.png","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar-shadcn/screenshot.png","icon":"examples/sidebar-shadcn/src/images/icon.png"},{"slug":"special-folders-pages","name":"special-folders-pages","title":"Special Folders: pages/","version":"1.0.0","manifest_version":3,"description":"Opens a welcome page in a new tab when the extension loads.","uiContext":["background"],"surfaces":["background"],"entrypoints":["background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["special-folders","pages","welcome-page","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","A welcome page from src/pages/ opens automatically on install","Add another HTML file under src/pages/ and it ships as a separate page"],"useCases":["Onboarding, help, and welcome pages that ship with the extension","Standalone HTML routes without manifest plumbing","Reference for Extension.js\'s pages/ special folder convention"],"docsUrl":"https://extension.js.org/docs/features/special-folders","files":["extension.config.js","pages/custom.html","pages/main.html","pages/main.js","pages/welcome.html","public/css/file.css","public/html/file.html","public/js/file.js","src/background.js","src/images/icon.png","src/images/javascript.png","src/manifest.json","src/sandbox/index.html","src/sandbox/scripts.js","src/sandbox/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/special-folders-pages/screenshot.png","icon":"examples/special-folders-pages/src/images/icon.png"},{"slug":"special-folders-scripts","name":"special-folders-scripts","title":"Special Folders Scripts","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay on web pages and a toolbar popup that runs the extension\'s bundled scripts.","uiContext":["content","action","background"],"surfaces":["content","action","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"docsUrl":"https://extension.js.org/docs/features/special-folders","files":["extension.config.js","scripts/script-one.js","scripts/script-three.js","scripts/script-two.js","src/background.js","src/content/scripts.js","src/content/styles.css","src/images/icon.png","src/images/javascript.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/special-folders-scripts/screenshot.png","icon":"examples/special-folders-scripts/src/images/icon.png"},{"slug":"svelte","name":"svelte","title":"Svelte","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple Svelte page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"svelte","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/getting-started/templates/svelte","files":["extension.config.js","src/background.ts","src/content/ContentApp.svelte","src/content/scripts.ts","src/content/styles.css","src/images/icon.png","src/manifest.json","src/public/extension.svg","src/sidebar/SidebarApp.svelte","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/svelte/screenshot.png","icon":"examples/svelte/src/images/icon.png"},{"slug":"transformers-js","name":"transformers-js","title":"Transformers.js","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel that classifies the active page or your selected text with on-device AI models.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","unlimitedStorage","activeTab","scripting","tabs","contextMenus"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/webassembly","files":["extension.config.js","src/background.js","src/constants.js","src/content/scripts.js","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.js","src/sidebar/index.html","src/sidebar/sakura.css","src/sidebar/scripts.js","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/transformers-js/screenshot.png","icon":"examples/transformers-js/src/images/icon.png"},{"slug":"typescript","name":"typescript","title":"TypeScript","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/getting-started/templates/typescript","files":["extension.config.js","src/background.ts","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/content/types.d.ts","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.ts","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/typescript/screenshot.png","icon":"examples/typescript/src/images/icon.png"},{"slug":"vue","name":"vue","title":"Vue","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple Vue page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"vue","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"docsUrl":"https://extension.js.org/docs/getting-started/templates/vue","files":["extension.config.js","src/background.ts","src/content/ContentApp.vue","src/content/scripts.ts","src/content/styles.css","src/content/types.d.ts","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.vue","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/vue/screenshot.png","icon":"examples/vue/src/images/icon.png"}]}');
|
|
2275
|
+
const workspace_create_COMMAND = "extension_workspace_create";
|
|
2276
|
+
const workspace_create_FIRST_CALL_BUDGET_MS = 8000;
|
|
2277
|
+
const workspace_create_RESUME_BUDGET_MS = 22000;
|
|
2278
|
+
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/i;
|
|
2279
|
+
const workspace_create_schema = {
|
|
2280
|
+
name: workspace_create_COMMAND,
|
|
2281
|
+
description: "Create an extension.dev workspace that does not exist yet, without opening the console. Use it before extension_project_create when the project's workspace is not there: project creation can only target an existing workspace, and this tool is what brings one into existence. Two-phase, like login: the first call returns a code and a URL where a signed-in GitHub user approves creating exactly this workspace and becomes its owner; call again with the returned deviceCode to finish. Check `ownerGithubLogin` in the answer: whoever approved the code owns the workspace. The approval mints a grant that lives minutes, can only create the one named workspace, names no project, and is never stored on this machine. Then run extension_project_create against '<workspace>/<project>'.",
|
|
2282
|
+
inputSchema: {
|
|
2283
|
+
type: "object",
|
|
2284
|
+
properties: {
|
|
2285
|
+
workspace: {
|
|
2286
|
+
type: "string",
|
|
2287
|
+
description: "Slug of the new workspace, lowercase letters, digits and hyphens, no slash. It must not exist yet; a personal workspace (the GitHub login) already exists for every signed-in user, so this is for a shared or organization-style workspace."
|
|
2288
|
+
},
|
|
2289
|
+
displayName: {
|
|
2290
|
+
type: "string",
|
|
2291
|
+
description: "Human name for the workspace. Defaults to the slug."
|
|
2292
|
+
},
|
|
2293
|
+
description: {
|
|
2294
|
+
type: "string",
|
|
2295
|
+
description: "Short workspace description. Optional."
|
|
2296
|
+
},
|
|
2297
|
+
developerUrl: {
|
|
2298
|
+
type: "string",
|
|
2299
|
+
description: "Public URL for the workspace. Optional."
|
|
2300
|
+
},
|
|
2301
|
+
deviceCode: {
|
|
2302
|
+
type: "string",
|
|
2303
|
+
description: "Resume token from the prior call's `deviceCode`; omit on the first call."
|
|
2304
|
+
},
|
|
2305
|
+
api: API_BASE
|
|
2306
|
+
},
|
|
2307
|
+
required: [
|
|
2308
|
+
"workspace"
|
|
2309
|
+
]
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
function workspace_create_fail(name, message, status, code, hint) {
|
|
2313
|
+
return envelope_envelope({
|
|
2314
|
+
ok: false,
|
|
2315
|
+
command: workspace_create_COMMAND,
|
|
2316
|
+
status,
|
|
2317
|
+
error: {
|
|
2318
|
+
code,
|
|
2319
|
+
name,
|
|
2320
|
+
message
|
|
2321
|
+
},
|
|
2322
|
+
...hint ? {
|
|
2323
|
+
hint
|
|
2324
|
+
} : {}
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
function workspace_create_laneClosedHint() {
|
|
2328
|
+
return `Create the workspace in the console at ${consoleBase()} (workspace switcher, New workspace), then run extension_project_create against it. Headless workspace creation opens with the platform; during the public hold only an allowlisted workspace slug can be created this way.`;
|
|
2329
|
+
}
|
|
2330
|
+
function workspace_create_pendingEnvelope(start) {
|
|
2331
|
+
const complete = String(start.verificationUriComplete || "").trim();
|
|
2332
|
+
const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
|
|
2333
|
+
const message = hasCompleteLink ? `Open ${complete} and approve creating the workspace (code ${start.userCode} is pre-filled), then call ${workspace_create_COMMAND} again with this deviceCode and the same arguments. If the page asks for a code, enter ${start.userCode} at ${start.verificationUri}. The GitHub account that approves becomes the workspace owner.` : `Open ${start.verificationUri}, enter code ${start.userCode}, approve creating the workspace, then call ${workspace_create_COMMAND} again with this deviceCode and the same arguments. The GitHub account that approves becomes the workspace owner.`;
|
|
2334
|
+
return envelope_envelope({
|
|
2335
|
+
ok: false,
|
|
2336
|
+
command: workspace_create_COMMAND,
|
|
2337
|
+
status: "authorization-pending",
|
|
2338
|
+
error: {
|
|
2339
|
+
code: "E_AUTH_PENDING",
|
|
2340
|
+
message
|
|
2341
|
+
},
|
|
2342
|
+
value: {
|
|
2343
|
+
userCode: start.userCode,
|
|
2344
|
+
verificationUri: start.verificationUri,
|
|
2345
|
+
...hasCompleteLink ? {
|
|
2346
|
+
verificationUriComplete: complete
|
|
2347
|
+
} : {},
|
|
2348
|
+
deviceCode: start.deviceCode
|
|
2349
|
+
},
|
|
2350
|
+
hint: message
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
async function workspace_create_handler(args) {
|
|
2354
|
+
const workspace = String(args.workspace || "").trim().toLowerCase();
|
|
2355
|
+
if (!SLUG_PATTERN.test(workspace)) return workspace_create_fail("BadRequest", "workspace must be a single slug: lowercase letters, digits and hyphens, no slash, at most 64 characters.", "bad-request", "E_BAD_REQUEST");
|
|
2356
|
+
const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
|
|
2357
|
+
if (!apiCheck.ok) return workspace_create_fail("ConfigError", apiCheck.message, "bad-request", "E_BAD_REQUEST");
|
|
2358
|
+
const apiBase = apiCheck.base;
|
|
2359
|
+
let config;
|
|
2360
|
+
try {
|
|
2361
|
+
config = await fetchLoginConfig(apiBase);
|
|
2362
|
+
} catch (err) {
|
|
2363
|
+
return workspace_create_fail("ConfigError", err?.message || "Could not load login config.", "create-failed", "E_PLATFORM");
|
|
2364
|
+
}
|
|
2365
|
+
let deviceCode = String(args.deviceCode || "").trim();
|
|
2366
|
+
let interval = 5;
|
|
2367
|
+
let budgetMs = workspace_create_RESUME_BUDGET_MS;
|
|
2368
|
+
if (!deviceCode) {
|
|
2369
|
+
let start;
|
|
2370
|
+
try {
|
|
2371
|
+
start = await requestDeviceCode({
|
|
2372
|
+
apiBase,
|
|
2373
|
+
path: config.deviceCodeUrl,
|
|
2374
|
+
workspace,
|
|
2375
|
+
intent: "create-workspace"
|
|
2376
|
+
});
|
|
2377
|
+
} catch (err) {
|
|
2378
|
+
const message = err?.message || "Could not start the device flow.";
|
|
2379
|
+
const laneClosed = /CLI_WORKSPACE_CREATE_DISABLED|403/.test(String(message));
|
|
2380
|
+
const serverMessage = "string" == typeof err?.serverMessage ? err.serverMessage.trim() : "";
|
|
2381
|
+
return workspace_create_fail("CreateStartError", laneClosed ? serverMessage || `Headless workspace creation is not open on this host yet. Create the workspace in the console instead. (${message})` : String(message), laneClosed ? "lane-closed" : "create-failed", "E_PLATFORM", laneClosed ? workspace_create_laneClosedHint() : void 0);
|
|
2382
|
+
}
|
|
2383
|
+
deviceCode = start.deviceCode;
|
|
2384
|
+
interval = start.interval;
|
|
2385
|
+
budgetMs = workspace_create_FIRST_CALL_BUDGET_MS;
|
|
2386
|
+
const early = await pollDeviceGrant({
|
|
2387
|
+
apiBase,
|
|
2388
|
+
path: config.deviceTokenUrl,
|
|
2389
|
+
workspace,
|
|
2390
|
+
deviceCode,
|
|
2391
|
+
interval,
|
|
2392
|
+
budgetMs
|
|
2393
|
+
});
|
|
2394
|
+
if (!early.ok && "pending" === early.reason) return workspace_create_pendingEnvelope(start);
|
|
2395
|
+
return workspace_create_finishFromPoll(early, {
|
|
2396
|
+
apiBase,
|
|
2397
|
+
workspace,
|
|
2398
|
+
args,
|
|
2399
|
+
verificationUri: config.verificationUri,
|
|
2400
|
+
deviceCode
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
const poll = await pollDeviceGrant({
|
|
2404
|
+
apiBase,
|
|
2405
|
+
path: config.deviceTokenUrl,
|
|
2406
|
+
workspace,
|
|
2407
|
+
deviceCode,
|
|
2408
|
+
interval,
|
|
2409
|
+
budgetMs
|
|
2410
|
+
});
|
|
2411
|
+
return workspace_create_finishFromPoll(poll, {
|
|
2412
|
+
apiBase,
|
|
2413
|
+
workspace,
|
|
2414
|
+
args,
|
|
2415
|
+
verificationUri: config.verificationUri,
|
|
2416
|
+
deviceCode
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
async function workspace_create_finishFromPoll(poll, ctx) {
|
|
2420
|
+
if (!poll.ok) {
|
|
2421
|
+
if ("pending" === poll.reason) return envelope_envelope({
|
|
2422
|
+
ok: false,
|
|
2423
|
+
command: workspace_create_COMMAND,
|
|
2424
|
+
status: "authorization-pending",
|
|
2425
|
+
error: {
|
|
2426
|
+
code: "E_AUTH_PENDING",
|
|
2427
|
+
message: `Still waiting for approval at ${ctx.verificationUri}. Approve there, then call ${workspace_create_COMMAND} again with this same deviceCode.`
|
|
2428
|
+
},
|
|
2429
|
+
value: {
|
|
2430
|
+
verificationUri: ctx.verificationUri,
|
|
2431
|
+
deviceCode: ctx.deviceCode
|
|
2432
|
+
}
|
|
2433
|
+
});
|
|
2434
|
+
if ("denied" === poll.reason) return workspace_create_fail("CreateDenied", "Creating the workspace was denied at extension.dev/device.", "create-denied", "E_AUTH_DENIED");
|
|
2435
|
+
if ("expired" === poll.reason) return workspace_create_fail("CreateExpired", `The device code expired. Run ${workspace_create_COMMAND} again to restart.`, "create-expired", "E_AUTH_EXPIRED");
|
|
2436
|
+
return workspace_create_fail("CreateAuthError", poll.message || "Device authorization failed.", "create-failed", "E_AUTH_FAILED");
|
|
2437
|
+
}
|
|
2438
|
+
const grant = poll.data;
|
|
2439
|
+
const token = String(grant.token || "").trim();
|
|
2440
|
+
const workspaceSlug = String(grant.workspaceSlug || "").trim().toLowerCase();
|
|
2441
|
+
if (workspaceSlug !== ctx.workspace) return workspace_create_fail("CreateScopeError", `The approval was scoped to workspace '${workspaceSlug}', not the requested '${ctx.workspace}'. Nothing was created. Run ${workspace_create_COMMAND} again with the intended workspace.`, "create-failed", "E_AUTH_FAILED");
|
|
2442
|
+
if ("workspace-provisioning" !== String(grant.tokenKind || "")) return envelope_envelope({
|
|
2443
|
+
ok: false,
|
|
2444
|
+
command: workspace_create_COMMAND,
|
|
2445
|
+
status: "workspace-exists",
|
|
2446
|
+
error: {
|
|
2447
|
+
code: "E_PLATFORM",
|
|
2448
|
+
message: `Workspace '${ctx.workspace}' already exists on this host, so there is nothing to create.`
|
|
2449
|
+
},
|
|
2450
|
+
hint: `Run extension_project_create with project '${ctx.workspace}/<project>' instead.`
|
|
2451
|
+
});
|
|
2452
|
+
const url = `${ctx.apiBase}/api/cli/workspaces/create`;
|
|
2453
|
+
let res;
|
|
2454
|
+
try {
|
|
2455
|
+
res = await fetch(url, {
|
|
2456
|
+
method: "POST",
|
|
2457
|
+
headers: {
|
|
2458
|
+
authorization: `Bearer ${token}`,
|
|
2459
|
+
"content-type": "application/json",
|
|
2460
|
+
...identityHeaders(workspace_create_COMMAND)
|
|
2461
|
+
},
|
|
2462
|
+
body: JSON.stringify({
|
|
2463
|
+
displayName: String(ctx.args.displayName || ctx.workspace).trim(),
|
|
2464
|
+
description: String(ctx.args.description || "").trim(),
|
|
2465
|
+
developerUrl: String(ctx.args.developerUrl || "").trim()
|
|
2466
|
+
})
|
|
2467
|
+
});
|
|
2468
|
+
} catch (err) {
|
|
2469
|
+
return workspace_create_fail("CreateNetworkError", `Could not reach ${url}: ${err?.message || err}`, "create-failed", "E_NETWORK");
|
|
2470
|
+
}
|
|
2471
|
+
const text = await res.text();
|
|
2472
|
+
let data;
|
|
2473
|
+
try {
|
|
2474
|
+
data = JSON.parse(text);
|
|
2475
|
+
} catch {
|
|
2476
|
+
data = {
|
|
2477
|
+
message: text
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
if (!res.ok) {
|
|
2481
|
+
const code = String(data.code || "");
|
|
2482
|
+
if (sawPlatformHold(res, data)) return platformHoldEnvelope({
|
|
2483
|
+
command: workspace_create_COMMAND,
|
|
2484
|
+
name: "CreateHeld",
|
|
2485
|
+
body: data,
|
|
2486
|
+
value: {
|
|
2487
|
+
workspace: ctx.workspace
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
if ("CLI_WORKSPACE_CREATE_DISABLED" === code) return workspace_create_fail("CreateClosed", String(data.message || "Headless workspace creation is not open yet."), "lane-closed", "E_PLATFORM", workspace_create_laneClosedHint());
|
|
2491
|
+
if ("WORKSPACE_EXISTS" === code || "WORKSPACE_SLUG_RESERVED" === code) return envelope_envelope({
|
|
2492
|
+
ok: false,
|
|
2493
|
+
command: workspace_create_COMMAND,
|
|
2494
|
+
status: "workspace-exists",
|
|
2495
|
+
error: {
|
|
2496
|
+
code: "E_PLATFORM",
|
|
2497
|
+
message: String(data.message || `Workspace '${ctx.workspace}' cannot be created.`)
|
|
2498
|
+
},
|
|
2499
|
+
hint: "WORKSPACE_EXISTS" === code ? `Run extension_project_create with project '${ctx.workspace}/<project>' instead.` : "Pick a different slug and run the tool again."
|
|
2500
|
+
});
|
|
2501
|
+
return workspace_create_fail("CreateError", `create failed (${res.status}): ${String(data.message || data.error || text || "unknown error").slice(0, 500)}`, "create-failed", "E_PLATFORM");
|
|
2502
|
+
}
|
|
2503
|
+
const finalSlug = String(data.slug || ctx.workspace);
|
|
2504
|
+
const owner = String(data.ownerGithubLogin || grant.ownerGithubLogin || "");
|
|
2505
|
+
return envelope_envelope({
|
|
2506
|
+
ok: true,
|
|
2507
|
+
command: workspace_create_COMMAND,
|
|
2508
|
+
status: "created",
|
|
2509
|
+
value: {
|
|
2510
|
+
workspaceSlug: finalSlug,
|
|
2511
|
+
workspaceId: data.id ?? null,
|
|
2512
|
+
displayName: data.displayName ?? finalSlug,
|
|
2513
|
+
ownerGithubLogin: owner || null,
|
|
2514
|
+
consoleUrl: `${consoleBase()}/${encodeURIComponent(finalSlug)}`,
|
|
2515
|
+
nextSteps: [
|
|
2516
|
+
`extension_project_create (project: '${finalSlug}/<project>', repo: '<owner>/<repo>')`
|
|
2517
|
+
]
|
|
2518
|
+
},
|
|
2519
|
+
hint: `Workspace ${finalSlug} exists${owner ? `, owned by ${owner}` : ""}. The grant is now spent and nothing was stored on this machine. If ${owner || "the approver"} is not who you meant to own it, stop here: ownership follows the GitHub account that approved the code. Next: extension_project_create with project '${finalSlug}/<project>'.`
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
var templates_meta_snapshot_namespaceObject = JSON.parse('{"version":"2","sourceRepo":"extension-js/examples","generatorVersion":"2","commit":"2c3a486af58dc1e0819d940db6a32e11426ef5eb","generatedAt":"2026-09-29T11:55:46.874Z","templates":[{"slug":"action","name":"action","title":"Action (Popup)","version":"1.0.0","manifest_version":3,"description":"Adds a toolbar popup to the browser with a simple page.","uiContext":["action","background"],"surfaces":["action","background"],"entrypoints":["./action/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["action","popup","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the toolbar icon to open the action popup","Edit src/action/index.html and watch HMR update the popup"],"useCases":["Toolbar popup as the primary UI surface","Quick actions that don\'t need a full sidebar or page","Starting point for popup-based browser extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/manifest-json","video":"AWMlWK934qE","files":["extension.config.js","src/action/index.html","src/action/scripts.js","src/action/styles.css","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/action/screenshot.png","icon":"examples/action/src/images/icon.png"},{"slug":"action-locales","name":"action-locales","title":"Action Locales","version":"1.0.0","manifest_version":3,"description":"Adds a toolbar popup whose text follows your browser\'s language.","uiContext":["action","background"],"surfaces":["action","background"],"entrypoints":["./action/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["action","popup","i18n","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the toolbar icon to see the popup in your browser\'s language","Add a folder under _locales/ and switch your browser language to see it"],"useCases":["A toolbar popup translated with the i18n API","Shipping _locales without hand-editing the manifest","Reference for localized extension UI across browsers"],"docsUrl":"https://extension.js.org/docs/implementation-guide/locales","video":"bY0Mtv77bVc","files":["_locales/en/messages.json","_locales/pt_BR/messages.json","extension.config.js","src/action/index.html","src/action/scripts.js","src/action/styles.css","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/action-locales/screenshot.png","icon":"examples/action-locales/src/images/icon.png"},{"slug":"ai-chatgpt","name":"ai-chatgpt","title":"ChatGPT AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with ChatGPT about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","chatgpt","openai","gpt","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your ChatGPT API key (from platform.openai.com)","Ask ChatGPT about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using ChatGPT","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware ChatGPT extensions"],"docsUrl":"https://platform.openai.com/docs/api-reference/chat","video":"oEiJ_b36EtE","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-chatgpt/screenshot.png","icon":"examples/ai-chatgpt/src/images/icon.png"},{"slug":"ai-claude","name":"ai-claude","title":"Claude AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with Claude about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","claude","anthropic","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your Claude API key (from console.anthropic.com)","Ask Claude about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using Claude","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware Claude extensions"],"docsUrl":"https://docs.anthropic.com/en/api/getting-started","video":"z3JmyUlmiUw","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-claude/screenshot.png","icon":"examples/ai-claude/src/images/icon.png"},{"slug":"ai-gemini","name":"ai-gemini","title":"Gemini AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with Gemini about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","gemini","google","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your Gemini API key (from aistudio.google.com)","Ask Gemini about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using Gemini","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware Gemini extensions"],"docsUrl":"https://ai.google.dev/gemini-api/docs","video":"I2uhXovfurE","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-gemini/screenshot.png","icon":"examples/ai-gemini/src/images/icon.png"},{"slug":"ai-perplexity","name":"ai-perplexity","title":"Perplexity AI Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel where you can chat with Perplexity about the page you are viewing.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","tabs"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["ai","perplexity","search","chat","sidebar","content-scripts","react","shadcn"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any web page and click the extension icon to open the sidebar","Enter your Perplexity API key (from perplexity.ai/settings/api)","Ask Perplexity about the active tab, and the content script feeds the page text into the conversation"],"useCases":["Chat with the active tab\'s content using Perplexity","Summarize or question articles, docs, and dashboards in place","Starting point for page-aware Perplexity extensions"],"docsUrl":"https://docs.perplexity.ai/","video":"W9j9rUrevjI","files":["extension.config.js","src/background.ts","src/components/ApiKeyForm.tsx","src/components/ChatInput.tsx","src/components/ChatMessage.tsx","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/scroll-area.tsx","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/lib/client.ts","src/lib/page-context.ts","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/ai-perplexity/screenshot.png","icon":"examples/ai-perplexity/src/images/icon.png"},{"slug":"content","name":"@extension-js/content","title":"Content scripts","version":"1.0.0","manifest_version":3,"description":"Injects a small badge into every web page you visit, with an options page that moves it to the left or right edge.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content","options","storage","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the badge injected into the page","Tick the setting and watch the badge move to the left edge"],"useCases":["A plain JavaScript UI injected into every page","One position setting shared between an options page and a content script","Starting point for content scripts that need preferences"],"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","video":"GflWcgdS9Tw","files":["extension.config.js","src/background.js","src/content/greet.js","src/content/scripts.js","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.js","src/options/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content/screenshot.png","icon":"examples/content/src/images/icon.png"},{"slug":"content-css-modules","name":"@extension-js/content-css-modules","title":"Content CSS Modules","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content-scripts","css-modules","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open any web page to see the badge this template injects","Edit src/content/styles.module.css and save to restyle the badge"],"useCases":["A content script styled with CSS modules","Class names hashed so page styles never collide","Starting point for content scripts that need scoped CSS"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/css-modules","video":"ondh3B28ieQ","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.module.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-css-modules/screenshot.png","icon":"examples/content-css-modules/src/images/icon.png"},{"slug":"content-custom-font","name":"content-custom-font","title":"Custom fonts in a content script","version":"1.0.0","manifest_version":3,"description":"Injects a badge rendered in a custom font into every web page you visit, with an options page that turns the custom font on and off.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["postcss.config.js","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content","fonts","options","storage","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the badge injected into the page","Untick the setting and watch the badge fall back to the system font"],"useCases":["A content script that ships its own typeface","One font setting shared between an options page and a content script","Starting point for injected UI that must not inherit host page type"],"docsUrl":"https://extension.js.org/docs/implementation-guide/css","video":"IAbckjfGO5Y","files":["extension.config.js","public/fonts/MomoSignature-Regular.ttf","public/fonts/MomoSignature-Regular.woff2","public/fonts/OFL.txt","src/background.js","src/content/scripts.js","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.js","src/options/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-custom-font/screenshot.png","icon":"examples/content-custom-font/src/images/icon.png"},{"slug":"content-env","name":"content-env","title":"Content Env","version":"1.0.0","manifest_version":3,"description":"Injects a small panel showing the extension\'s environment values into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.ts","content/scripts.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content-scripts","env","configuration","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open any web page to see the panel print the injected values","Edit .env.chrome or .env.firefox to change what the panel reads"],"useCases":["Reading build-time configuration inside a content script","Switching behavior per browser without touching the source","Reference for EXTENSION_PUBLIC_* values in typed sources"],"docsUrl":"https://extension.js.org/docs/features/environment-variables","video":"Q-ugTIZVZ-U","files":["extension.config.js","src/background.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-env/screenshot.png","icon":"examples/content-env/src/images/icon.png"},{"slug":"content-less","name":"@extension-js/content-less","title":"Content Less","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit, with a Less options page that moves it to the left or right edge.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"less","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["content","options","storage","less"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the badge injected into the page","Tick the setting and watch the badge move to the left edge"],"useCases":["A Less-styled badge injected into every page","One position setting shared between an options page and a content script","Starting point for Less extensions that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/less","video":"VGssDbxBeMM","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.less","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.js","src/options/styles.less"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-less/screenshot.png","icon":"examples/content-less/src/images/icon.png"},{"slug":"content-less-modules","name":"@extension-js/content-less-modules","title":"Content Less Modules","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit, with a Less modules options page that moves it to the left or right edge.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"less","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content","options","storage","less-modules"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the badge injected into the page","Tick the setting and watch the badge move to the left edge"],"useCases":["Hashed class names shared by a content script and an options page","One position setting shared between an options page and a content script","Starting point for CSS Modules extensions that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/less","video":"Q1fNVB2ZuNw","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.module.less","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.js","src/options/styles.module.less"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-less-modules/screenshot.png","icon":"examples/content-less-modules/src/images/icon.png"},{"slug":"content-main-world","name":"content-main-world","title":"Main-world Content Script","version":"1.0.0","manifest_version":3,"description":"Injects a small UI into every web page from both the extension and the page\'s own JavaScript world, with an options page that moves it to the left or right edge.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.js","content/isolated-one.js","content/isolated-two.js","content/scripts.js","content/isolated-options.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["content-scripts","main-world","isolated-world","options","storage","javascript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the UI injected into the page","Tick the setting and watch the main-world UI move to the left edge"],"useCases":["Hook into page-level globals that isolated content scripts cannot reach","Bridge isolated and main-world execution contexts cleanly","Read a stored setting from a main-world script that has no chrome APIs"],"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","video":"3-C3wCE3fWU","files":["extension.config.js","src/background.js","src/content/isolated-one.js","src/content/isolated-options.js","src/content/isolated-two.js","src/content/scripts.js","src/content/styles.css","src/content/utils/bridge.js","src/content/utils/constants.js","src/content/utils/create-badge.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.js","src/options/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-main-world/screenshot.png","icon":"examples/content-main-world/src/images/icon.png"},{"slug":"content-multi-one-entry","name":"content-multi-one-entry","title":"Content Multi One Entry","version":"1.0.0","manifest_version":3,"description":"Injects four small elements into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/script-top-left.js","content/script-top-right.js","content/script-bottom-left.js","content/script-bottom-right.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content-scripts","multi-entry","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open any web page and an element appears in each of its four corners","Edit one script in src/content/ and save to see that corner update"],"useCases":["Four scripts shipped from a single content-script entry","One match rule driving several focused files","Reference for grouping content scripts under one entry"],"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","video":"l1QWq7lIKo8","files":["extension.config.js","src/background.js","src/content/script-bottom-left.js","src/content/script-bottom-right.js","src/content/script-top-left.js","src/content/script-top-right.js","src/content/styles.css","src/content/utils/constants.js","src/content/utils/create-badge.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-multi-one-entry/screenshot.png","icon":"examples/content-multi-one-entry/src/images/icon.png"},{"slug":"content-multi-three-entries","name":"content-multi-three-entries","title":"Multi-entry Content Scripts","version":"1.0.0","manifest_version":3,"description":"Injects four small elements into every web page you visit.","uiContext":["content","background"],"surfaces":["content","background"],"entrypoints":["background.js","content/script-top-left.js","content/script-top-right.js","content/script-bottom-left.js","content/script-bottom-right.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["content-scripts","multi-entry","javascript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open any page and three separate content scripts inject small elements","Edit any of the entries in src/content/ to see HMR update independently"],"useCases":["Split content-script logic across multiple entries with different match rules","Target different page contexts with focused, independent bundles","Reference for the multi-entry content_scripts pattern in MV3"],"docsUrl":"https://extension.js.org/docs/implementation-guide/content-scripts","video":"oDcdmGaIBjw","files":["extension.config.js","src/background.js","src/content/script-bottom-left.js","src/content/script-bottom-right.js","src/content/script-top-left.js","src/content/script-top-right.js","src/content/styles.css","src/content/utils/constants.js","src/content/utils/create-badge.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-multi-three-entries/screenshot.png","icon":"examples/content-multi-three-entries/src/images/icon.png"},{"slug":"content-preact","name":"content-preact","title":"Content Preact","version":"1.0.0","manifest_version":3,"description":"Shows an overlay UI on web pages and an options page that moves it from right to left.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.ts","./content/scripts.tsx"],"uiFramework":"preact","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["content","options","storage","preact","typescript","tailwind"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the overlay injected into the page","Tick the setting and watch the overlay move to the left edge"],"useCases":["An on-page UI written in Preact and isolated in a Shadow DOM","One setting shared between an options page and a content script","Starting point for Preact extensions that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/preact","video":"sYA3V9YIWwo","files":["extension.config.js","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/chromeWindow.png","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/preact.png","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/manifest.json","src/options/OptionsApp.tsx","src/options/index.html","src/options/scripts.tsx","src/options/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-preact/screenshot.png","icon":"examples/content-preact/src/images/icon.png"},{"slug":"content-react","name":"content-react","title":"Content React","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page, with a React options page that moves it from right to left.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.ts","./content/scripts.tsx"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["content","options","storage","react","typescript","tailwind"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the overlay injected into the page","Tick the setting and watch the overlay move to the left edge"],"useCases":["A React UI injected into any page, isolated in a Shadow DOM","One setting shared between a React options page and a content script","Starting point for content-script extensions that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/react","video":"oGNhj9k4bNs","files":["extension.config.js","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/chromeWindow.png","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/react.png","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/manifest.json","src/options/OptionsApp.tsx","src/options/index.html","src/options/scripts.tsx","src/options/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-react/screenshot.png","icon":"examples/content-react/src/images/icon.png"},{"slug":"content-sass","name":"@extension-js/content-sass","title":"Content scripts with Sass","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit, with a Sass options page that moves it to the left or right edge.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"sass","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["content","sass","options","storage"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the badge injected into the page","Tick the setting and watch the badge move to the left edge"],"useCases":["A badge injected into every page and styled with Sass","One position setting shared between an options page and a content script","Starting point for Sass content scripts that need preferences"],"docsUrl":"https://extension.js.org/docs/implementation-guide/css","video":"-VceQYe_KMk","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.scss","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.js","src/options/styles.scss"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-sass/screenshot.png","icon":"examples/content-sass/src/images/icon.png"},{"slug":"content-sass-modules","name":"@extension-js/content-sass-modules","title":"Content scripts with Sass modules","version":"1.0.0","manifest_version":3,"description":"Injects a small styled badge into every web page you visit, with a Sass modules options page that moves it to the left or right edge.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"sass","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"build":"extension build"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content","sass","css-modules","options","storage"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the badge injected into the page","Tick the setting and watch the badge move to the left edge"],"useCases":["A badge injected into every page and styled with hashed Sass module class names","One position setting shared between an options page and a content script","Starting point for Sass module content scripts that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/sass","video":"Ma9f_Q1C7Eo","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.module.scss","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.js","src/options/styles.module.scss"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-sass-modules/screenshot.png","icon":"examples/content-sass-modules/src/images/icon.png"},{"slug":"content-svelte","name":"content-svelte","title":"Content Svelte","version":"1.0.0","manifest_version":3,"description":"Shows a Svelte overlay on every web page you visit, with an options page that moves it from right to left.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.ts","./content/scripts.ts"],"uiFramework":"svelte","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["content","svelte","typescript","tailwind","options","storage"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the overlay injected into the page","Tick the setting and watch the overlay move to the left edge"],"useCases":["A Svelte overlay injected into every page, isolated in a Shadow DOM","One setting shared between a Svelte options page and a content script","Starting point for Svelte extensions that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/svelte","video":"g6-GZ6_Rn38","files":["extension.config.js","src/background.ts","src/content/ContentApp.svelte","src/content/scripts.ts","src/content/styles.css","src/content/svelte.d.ts","src/images/chromeWindow.png","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/svelte.png","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/manifest.json","src/options/OptionsApp.svelte","src/options/index.html","src/options/scripts.ts","src/options/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-svelte/screenshot.png","icon":"examples/content-svelte/src/images/icon.png"},{"slug":"content-typescript","name":"content-typescript","title":"Content TypeScript","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page, with an options page that moves it from right to left.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.ts","content/scripts.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["content","options","storage","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the overlay injected into the page","Tick the setting and watch the overlay move to the left edge"],"useCases":["A typed overlay injected into every page","One setting shared between an options page and a content script","Starting point for TypeScript extensions that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/typescript","video":"9OBuuh3N_cM","files":["extension.config.js","src/background.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/options/index.html","src/options/scripts.ts","src/options/styles.css","src/types.ts"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-typescript/screenshot.png","icon":"examples/content-typescript/src/images/icon.png"},{"slug":"content-vue","name":"content-vue","title":"Content Vue","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay UI on every web page, with a Vue options page that moves it from right to left.","uiContext":["content","options","background"],"surfaces":["content","options","background"],"entrypoints":["options/index.html","background.ts","./content/scripts.ts"],"uiFramework":"vue","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["storage"],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["content","options","storage","vue","typescript","tailwind"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click Open options on the overlay injected into the page","Tick the setting and watch the overlay move to the left edge"],"useCases":["A Vue overlay injected into every page, isolated in a Shadow DOM","One setting shared between a Vue options page and a content script","Starting point for Vue extensions that need preferences"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/vue","video":"LIfZxR-B2Nk","files":["extension.config.js","src/background.ts","src/content/ContentApp.vue","src/content/scripts.ts","src/content/shims-vue.d.ts","src/content/styles.css","src/images/chromeWindow.png","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/logo.svg","src/images/tailwind.png","src/images/tailwind_bg.png","src/images/typescript.png","src/images/vue.png","src/manifest.json","src/options/OptionsApp.vue","src/options/index.html","src/options/scripts.ts","src/options/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/content-vue/screenshot.png","icon":"examples/content-vue/src/images/logo.svg"},{"slug":"devtools","name":"devtools","title":"Devtools panel","version":"1.0.0","manifest_version":3,"description":"Adds a devtools panel to the browser that reads the inspected page.","uiContext":["devtools"],"surfaces":["devtools"],"entrypoints":["devtools/index.html"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["devtools","panel","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open the browser devtools with F12 or Cmd+Option+I, then pick the Example tab","Edit src/panel/index.html and reopen devtools to see the change"],"useCases":["A devtools panel as the primary UI surface","Inspecting the current page from inside the developer tools","Starting point for debugging tools built as browser extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/manifest-json","video":"kyJk6ksDOsU","files":["extension.config.js","src/devtools/index.html","src/devtools/scripts.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/panel/index.html","src/panel/scripts.js","src/panel/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/devtools/screenshot.png","icon":"examples/devtools/src/images/icon.png"},{"slug":"devtools-preact","name":"devtools-preact","title":"Devtools panel Preact","version":"1.0.0","manifest_version":3,"description":"Adds a devtools panel written in Preact that reads the inspected page.","uiContext":["devtools"],"surfaces":["devtools"],"entrypoints":["devtools/index.html"],"uiFramework":"preact","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["devtools","panel","preact","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open the browser devtools with F12 or Cmd+Option+I, then pick the Example tab","Edit src/panel/PanelApp.tsx and reopen devtools to see the change"],"useCases":["A devtools panel as the primary UI surface","Inspecting the current page from inside the developer tools","Starting point for Preact debugging tools built as browser extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/manifest-json","video":"WjPVn5SlGaU","files":["extension.config.js","src/devtools/index.html","src/devtools/scripts.ts","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/preact.png","src/manifest.json","src/panel/PanelApp.tsx","src/panel/index.html","src/panel/scripts.tsx","src/panel/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/devtools-preact/screenshot.png","icon":"examples/devtools-preact/src/images/icon.png"},{"slug":"devtools-react","name":"devtools-react","title":"React devtools panel","version":"1.0.0","manifest_version":3,"description":"Adds a React devtools panel to the browser that reads the inspected page.","uiContext":["devtools"],"surfaces":["devtools"],"entrypoints":["devtools/index.html"],"uiFramework":"react","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["devtools","panel","react","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open the browser devtools with F12 or Cmd+Option+I, then pick the Example tab","Edit src/panel/PanelApp.tsx and save to see the panel update"],"useCases":["A React devtools panel as the primary UI surface","Inspecting the current page from inside the developer tools","Starting point for debugging tools built as browser extensions"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/react","video":"mRZO9Eg2aUQ","files":["extension.config.js","src/devtools/index.html","src/devtools/scripts.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/react.png","src/manifest.json","src/panel/PanelApp.tsx","src/panel/index.html","src/panel/scripts.tsx","src/panel/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/devtools-react/screenshot.png","icon":"examples/devtools-react/src/images/icon.png"},{"slug":"devtools-svelte","name":"devtools-svelte","title":"Devtools panel with Svelte","version":"1.0.0","manifest_version":3,"description":"Adds a Svelte devtools panel to the browser that reads the inspected page.","uiContext":["devtools"],"surfaces":["devtools"],"entrypoints":["devtools/index.html"],"uiFramework":"svelte","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["devtools","panel","svelte","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open the browser devtools with F12 or Cmd+Option+I, then pick the Example tab","Edit src/panel/PanelApp.svelte and reopen devtools to see the change"],"useCases":["A devtools panel as the primary UI surface","Inspecting the current page from inside the developer tools","Starting point for debugging tools built as browser extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/manifest-json","video":"SqOxa9hRg1s","files":["extension.config.js","src/devtools/index.html","src/devtools/scripts.ts","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/svelte.png","src/manifest.json","src/panel/PanelApp.svelte","src/panel/index.html","src/panel/scripts.ts","src/panel/styles.css","src/panel/svelte.d.ts"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/devtools-svelte/screenshot.png","icon":"examples/devtools-svelte/src/images/icon.png"},{"slug":"devtools-typescript","name":"devtools-typescript","title":"Devtools panel (TypeScript)","version":"1.0.0","manifest_version":3,"description":"Adds a devtools panel to the browser that reads the inspected page.","uiContext":["devtools"],"surfaces":["devtools"],"entrypoints":["devtools/index.html"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["devtools","panel","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open the browser devtools with F12 or Cmd+Option+I, then pick the Example tab","Edit src/panel/index.html and reopen devtools to see the change"],"useCases":["A devtools panel as the primary UI surface","Inspecting the current page from inside the developer tools","Starting point for debugging tools built as browser extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/manifest-json","video":"iI1Th6Hi_ck","files":["extension.config.js","src/devtools/index.html","src/devtools/scripts.ts","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/typescript.png","src/manifest.json","src/panel/index.html","src/panel/scripts.ts","src/panel/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/devtools-typescript/screenshot.png","icon":"examples/devtools-typescript/src/images/icon.png"},{"slug":"devtools-vue","name":"devtools-vue","title":"Devtools panel with Vue","version":"1.0.0","manifest_version":3,"description":"Adds a devtools panel built with Vue that reads the inspected page.","uiContext":["devtools"],"surfaces":["devtools"],"entrypoints":["devtools/index.html"],"uiFramework":"vue","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["devtools","panel","vue","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open the browser devtools with F12 or Cmd+Option+I, then pick the Example tab","Edit src/panel/PanelApp.vue and reopen devtools to see the change"],"useCases":["A devtools panel as the primary UI surface","Inspecting the current page from inside the developer tools","Starting point for debugging tools built as browser extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/manifest-json","video":"sp-bjexg2X8","files":["extension.config.js","src/devtools/index.html","src/devtools/scripts.ts","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/vue.png","src/manifest.json","src/panel/PanelApp.vue","src/panel/index.html","src/panel/scripts.ts","src/panel/shims-vue.d.ts","src/panel/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/devtools-vue/screenshot.png","icon":"examples/devtools-vue/src/images/icon.png"},{"slug":"init","name":"init","title":"Initial","version":"1.0.0","manifest_version":3,"description":"A minimal extension that loads in your browser, ready to build on.","uiContext":[],"surfaces":[],"entrypoints":[],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":false,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["minimal","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","The extension loads in a fresh profile with no surface of its own","Add src/newtab/index.html and it ships on the next reload"],"useCases":["The smallest Extension.js project that still runs","A blank slate for whichever surface you choose","Reference for the minimum an MV3 extension needs"],"docsUrl":"https://extension.js.org/docs/getting-started/immediately","files":["extension.config.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/javascript.png","src/index.js","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/init/screenshot.png","icon":"examples/init/src/images/icon.png"},{"slug":"javascript","name":"javascript","title":"JavaScript","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["sidebar","javascript","content-scripts"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the sidebar panel","Edit src/sidebar/index.html and save to watch the panel update"],"useCases":["A sidebar panel as the primary UI surface","Plain JavaScript with no compiler config to maintain","Starting point for extensions written in JavaScript"],"docsUrl":"https://extension.js.org/docs/getting-started/templates/javascript","video":"Ryjuq2o-kzc","files":["extension.config.js","src/background.js","src/content/ContentApp.js","src/content/scripts.js","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.js","src/sidebar/index.html","src/sidebar/scripts.js","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/javascript/screenshot.png","icon":"examples/javascript/src/images/icon.png"},{"slug":"newtab","name":"newtab","title":"New Tab","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page you can customize.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab and this page loads in place of the default","Edit src/newtab/index.html and save to watch the tab update"],"useCases":["A new tab page as the primary UI surface","Dashboards, launchers, and start pages","Starting point for new tab extensions in JavaScript"],"docsUrl":"https://extension.js.org/docs/implementation-guide/html","video":"fouoMkXLyvQ","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab/screenshot.png","icon":"examples/newtab/src/images/icon.png"},{"slug":"newtab-browser-flags","name":"newtab-browser-flags","title":"New Tab Browser Flags","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a launcher that opens the extension\'s sidebar panel.","uiContext":["content","newtab","sidebar","background"],"surfaces":["content","newtab","sidebar","background"],"entrypoints":["newtab/index.html","sidebar/index.html","background.js","content/content.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"build":"extension build"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["new-tab","sidebar","configuration","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","The browser starts with the flags this template sets in extension.config.js","Open a new tab and use the launcher to open the sidebar panel"],"useCases":["Passing custom browser flags through extension.config.js","A new tab launcher that opens another extension surface","Reference for browser-specific differences across targets"],"docsUrl":"https://extension.js.org/docs/browsers/browser-flags","files":["extension.config.js","src/background.js","src/content/content.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/newtab.css","src/newtab/newtab.js","src/sidebar/index.html","src/sidebar/sidebar.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-browser-flags/screenshot.png","icon":"examples/newtab-browser-flags/src/images/icon.png"},{"slug":"newtab-config-eslint","name":"newtab-config-eslint","title":"New Tab Config ESLint","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js","eslint.config.mjs"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","configuration","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the page this template ships","Run npx eslint src to check the source with the bundled config"],"useCases":["A new tab extension that lints its own source","ESLint wired into an Extension.js project without ejecting","Reference for adding a linter to an extension codebase"],"docsUrl":"https://extension.js.org/docs/integrations/eslint","video":"CoS-_Cv-SvM","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-config-eslint/screenshot.png","icon":"examples/newtab-config-eslint/src/images/icon.png"},{"slug":"newtab-config-prettier","name":"newtab-config-prettier","title":"New Tab Config Prettier","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js",".prettierrc"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","configuration","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the page this template ships","Run npx prettier --check src to see the rules this template ships"],"useCases":["A new tab extension that formats its own source","Prettier wired into an Extension.js project without ejecting","Reference for adding a formatter to an extension codebase"],"docsUrl":"https://extension.js.org/docs/integrations/prettier","video":"JnNNaUzefTg","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-config-prettier/screenshot.png","icon":"examples/newtab-config-prettier/src/images/icon.png"},{"slug":"newtab-config-stylelint","name":"newtab-config-stylelint","title":"New Tab Config Stylelint","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"sass","configFiles":[".stylelintrc.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","sass","configuration"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the styled page this template ships","Run npx stylelint src to check the Sass with the bundled config"],"useCases":["A new tab extension that lints its own stylesheets","Stylelint and Sass wired into an Extension.js project","Reference for adding a style linter to an extension codebase"],"docsUrl":"https://extension.js.org/docs/integrations/stylelint","video":"3DVh7oDKFxo","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.scss"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-config-stylelint/screenshot.png","icon":"examples/newtab-config-stylelint/src/images/icon.png"},{"slug":"newtab-crypto","name":"newtab-crypto","title":"New Tab Crypto","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a page that hashes any text you type using SHA-256.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","web-crypto","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab and type any text to see its SHA-256 hash","Edit src/newtab/scripts.ts to hash with a different algorithm"],"useCases":["Hashing text with the Web Crypto API inside an extension","A new tab page that does real work with no network","Reference for browser crypto primitives under MV3"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/node","video":"dPdh5H_sTgw","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-crypto/screenshot.png","icon":"examples/newtab-crypto/src/images/icon.png"},{"slug":"newtab-env","name":"newtab-env","title":"Environment Variables","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a page showing the extension\'s environment values.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["env","configuration","new-tab","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Copy .env.example to .env and edit the values","Run npm run dev to launch the extension","Open a new tab and the page reads injected EXTENSION_PUBLIC_* values"],"useCases":["Inject build-time configuration without leaking secrets","Switch behavior per browser, mode, or deployment target","Reference for safe env-var usage across JS, JSON, and HTML"],"docsUrl":"https://extension.js.org/docs/features/environment-variables","video":"8XP2acacpK8","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-env/screenshot.png","icon":"examples/newtab-env/src/images/icon.png"},{"slug":"newtab-less","name":"newtab-less","title":"New Tab Less","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple styled page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"less","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","less","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the styled page this template ships","Edit src/newtab/styles.less and save to watch the styles reload"],"useCases":["A new tab page styled with Less","Less compiled by Extension.js with no extra config","Starting point for extensions that prefer Less to plain CSS"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/less","video":"T4Bgw8pCYY0","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.less"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-less/screenshot.png","icon":"examples/newtab-less/src/images/icon.png"},{"slug":"newtab-preact","name":"newtab-preact","title":"New Tab Preact","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple Preact page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"preact","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","preact","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the Preact page this template ships","Edit src/newtab/NewTabApp.tsx and save to see fast refresh update it"],"useCases":["A new tab page rendered by Preact","Preact and JSX compiled with no extra config","Starting point for Preact extensions with a new tab"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/preact","video":"ezN6818j744","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.tsx","src/newtab/index.html","src/newtab/scripts.tsx","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-preact/screenshot.png","icon":"examples/newtab-preact/src/images/icon.png"},{"slug":"newtab-react","name":"newtab-react","title":"New Tab React","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple React page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"react","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","react","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the React page this template ships","Edit src/newtab/NewTabApp.tsx and save to see fast refresh update it"],"useCases":["A new tab page rendered by React","React and JSX compiled with no extra config","Starting point for React extensions with a new tab"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/react","video":"jt4HgQPdJX8","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.tsx","src/newtab/index.html","src/newtab/scripts.tsx","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-react/screenshot.png","icon":"examples/newtab-react/src/images/icon.png"},{"slug":"newtab-react-router","name":"newtab-react-router","title":"New Tab React Router","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a small React app you can navigate across routes.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"react","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","react","routing","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Open a new tab and follow the links to move between routes","Add a route under src/newtab/ and it renders with no server"],"useCases":["A multi-route new tab app with client-side routing","React Router running inside an extension page","Reference for routing with no server under MV3"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/react","video":"ysvv7Q10p3Q","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.tsx","src/newtab/index.html","src/newtab/scripts.tsx","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-react-router/screenshot.png","icon":"examples/newtab-react-router/src/images/icon.png"},{"slug":"newtab-sass","name":"newtab-sass","title":"New Tab Sass","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple styled page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"sass","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","sass","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the styled page this template ships","Edit src/newtab/styles.scss and save to watch the styles reload"],"useCases":["A new tab page styled with Sass","Sass compiled by Extension.js with no extra config","Starting point for extensions that prefer Sass to plain CSS"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/sass","video":"bUWLBJeWzpc","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.js","src/newtab/styles.scss"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-sass/screenshot.png","icon":"examples/newtab-sass/src/images/icon.png"},{"slug":"newtab-svelte","name":"newtab-svelte","title":"New Tab Svelte","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple Svelte page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"svelte","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","svelte","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the Svelte page this template ships","Edit src/newtab/NewTabApp.svelte and save to watch it update"],"useCases":["A new tab page rendered by Svelte","Svelte components compiled with no extra config","Starting point for Svelte extensions with a new tab"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/svelte","video":"uq-HVZb51U8","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.svelte","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-svelte/screenshot.png","icon":"examples/newtab-svelte/src/images/icon.png"},{"slug":"newtab-typescript","name":"newtab-typescript","title":"New Tab TypeScript","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the page this template ships","Edit src/newtab/scripts.ts and save to watch the tab update"],"useCases":["A typed new tab page","TypeScript compiled by Extension.js with no extra config","Starting point for TypeScript extensions with a new tab"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/typescript","video":"0sRMGlx7MbA","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-typescript/screenshot.png","icon":"examples/newtab-typescript/src/images/icon.png"},{"slug":"newtab-vue","name":"newtab-vue","title":"New Tab Vue","version":"1.0.0","manifest_version":3,"description":"Replaces your new tab page with a simple Vue page.","uiContext":["newtab","background"],"surfaces":["newtab","background"],"entrypoints":["newtab/index.html","background.js"],"uiFramework":"vue","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["new-tab","vue","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open a new tab to see the Vue page this template ships","Edit src/newtab/NewTabApp.vue and save to watch it update"],"useCases":["A new tab page rendered by Vue","Single-file components compiled with no extra config","Starting point for Vue extensions with a new tab"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/vue","video":"g4rjynbMI5c","files":["extension.config.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/newtab/NewTabApp.vue","src/newtab/index.html","src/newtab/scripts.ts","src/newtab/shims-vue.d.ts","src/newtab/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/newtab-vue/screenshot.png","icon":"examples/newtab-vue/src/images/icon.png"},{"slug":"playwright","name":"playwright","title":"Playwright E2E Sidebar","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser, covered end to end by Playwright tests.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"test":"playwright test"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["testing","playwright","e2e","sidebar","typescript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":4,"firstSteps":["Run npm install to set up Playwright deps","Run npm run dev to launch the extension","Run npm test to execute the Playwright spec against the loaded extension"],"useCases":["End-to-end testing for browser-extension surfaces (popup, sidebar, content)","CI-ready Playwright setup wired to Extension.js builds","Reference for automating extension flows in headed and headless modes"],"docsUrl":"https://extension.js.org/docs/workflows/playwright-e2e","video":"k8PP2Gs1AsU","files":["extension.config.js","src/background.ts","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.ts","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/playwright/screenshot.png","icon":"examples/playwright/src/images/icon.png"},{"slug":"preact","name":"preact","title":"Preact","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple Preact page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.tsx"],"uiFramework":"preact","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","preact","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the Preact sidebar panel","Edit the component in src/sidebar/ to see fast refresh update it"],"useCases":["A sidebar panel rendered by Preact","Preact and JSX compiled with no extra config","Starting point for Preact extensions"],"docsUrl":"https://extension.js.org/docs/getting-started/templates/preact","video":"VcAPgX8KHgI","files":["extension.config.js","public/extension.svg","public/logo.svg","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/preact/screenshot.png","icon":"examples/preact/public/logo.svg"},{"slug":"react","name":"react","title":"React","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple React page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.tsx"],"uiFramework":"react","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","react","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the React sidebar panel","Edit the component in src/sidebar/ to see fast refresh update it"],"useCases":["A sidebar panel rendered by React","React and JSX compiled with no extra config","Starting point for React extensions"],"docsUrl":"https://extension.js.org/docs/getting-started/templates/react","video":"mRgWdPgv1hE","files":["extension.config.js","src/background.ts","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/react/screenshot.png","icon":"examples/react/src/images/icon.png"},{"slug":"sidebar","name":"sidebar","title":"Sidebar page","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the sidebar panel","Edit src/sidebar/index.html and save to watch the panel update"],"useCases":["A sidebar panel that stays open while you browse","Reading and acting on the page beside it","Starting point for sidebar-first extensions"],"docsUrl":"https://extension.js.org/docs/implementation-guide/html","video":"bgGUqLiaZFI","files":["extension.config.js","src/background.js","src/content/scripts.js","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/index.html","src/sidebar/scripts.js","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar/screenshot.png","icon":"examples/sidebar/src/images/icon.png"},{"slug":"sidebar-antd","name":"sidebar-antd","title":"Sidebar Ant Design","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser showing Ant Design components.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.jsx"],"uiFramework":"react","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":false,"tags":["sidebar","react","antd"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the sidebar panel","Swap an Ant Design component in src/sidebar/ to see it update"],"useCases":["A sidebar built from Ant Design components","A component library bundled into an extension surface","Starting point for extensions with a ready-made design system"],"docsUrl":"https://ant.design/docs/react/introduce","video":"7YSGpO-RmVE","files":["extension.config.js","src/background.js","src/content/ContentApp.jsx","src/content/scripts.jsx","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.jsx","src/sidebar/index.html","src/sidebar/scripts.jsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar-antd/screenshot.png","icon":"examples/sidebar-antd/src/images/icon.png"},{"slug":"sidebar-monorepo-nx","name":"sidebar-monorepo-nx","title":"Sidebar Nx","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel and shows a small overlay UI on every web page you visit.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"dev":"nx dev sidebar-monorepo-nx-packages-extension","build":"nx build sidebar-monorepo-nx-packages-extension"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","monorepo","nx","javascript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":4,"firstSteps":["Run npm install at the workspace root to link the packages","Run npm run dev to launch the extension from the workspace","Edit the shared package and both the sidebar and the overlay follow"],"useCases":["An extension that lives inside an Nx workspace","Sharing code between the extension and other packages","Reference for Extension.js in a monorepo with no plugin"],"docsUrl":"https://nx.dev/docs","video":"2zuBnmCVvqg","files":["extension.config.js","packages/analytics/src/index.js","packages/extension/src/background.js","packages/extension/src/content/scripts.js","packages/extension/src/content/styles.css","packages/extension/src/images/icon-128.png","packages/extension/src/images/icon-16.png","packages/extension/src/images/icon-48.png","packages/extension/src/images/icon.png","packages/extension/src/manifest.json","packages/extension/src/sidebar/SidebarApp.js","packages/extension/src/sidebar/index.html","packages/extension/src/sidebar/scripts.js","packages/extension/src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar-monorepo-nx/screenshot.png","icon":null},{"slug":"sidebar-monorepo-turborepo","name":"sidebar-monorepo-turborepo","title":"Sidebar Turborepo","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel and shows a small overlay UI on every web page you visit.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{"dev":"extension dev packages/extension","build":"extension build packages/extension"},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","monorepo","turborepo","javascript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":4,"firstSteps":["Run npm install at the workspace root to link the packages","Run npm run dev to launch the extension from the workspace","Edit the shared package and both the sidebar and the overlay follow"],"useCases":["An extension that lives inside a Turborepo workspace","Sharing code between the extension and other packages","Reference for Extension.js in a monorepo with no plugin"],"docsUrl":"https://turborepo.dev/docs","video":"l3Z90k_4c3I","files":["extension.config.js","packages/analytics/src/index.js","packages/extension/src/background.js","packages/extension/src/content/scripts.js","packages/extension/src/content/styles.css","packages/extension/src/images/icon-128.png","packages/extension/src/images/icon-16.png","packages/extension/src/images/icon-48.png","packages/extension/src/images/icon.png","packages/extension/src/manifest.json","packages/extension/src/sidebar/SidebarApp.js","packages/extension/src/sidebar/index.html","packages/extension/src/sidebar/scripts.js","packages/extension/src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar-monorepo-turborepo/screenshot.png","icon":null},{"slug":"sidebar-shadcn","name":"sidebar-shadcn","title":"Sidebar shadcn","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser built with shadcn/ui components.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.tsx"],"uiFramework":"react","css":"css","configFiles":["postcss.config.js","tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","react","shadcn","tailwind"],"difficulty":"beginner","timeToFirstSuccessMinutes":3,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the sidebar panel","Add a shadcn/ui component and save to see it in the panel"],"useCases":["A sidebar built from shadcn/ui components","Tailwind and Radix primitives inside an extension surface","Starting point for extensions with a modern component kit"],"docsUrl":"https://extension.js.org/docs/integrations/shadcnui","video":"gTpQ7v7zSuY","files":["extension.config.js","src/background.ts","src/components/ui/button.tsx","src/components/ui/card.tsx","src/components/ui/label.tsx","src/components/ui/switch.tsx","src/content/ContentApp.tsx","src/content/scripts.tsx","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/lib/utils.ts","src/manifest.json","src/sidebar/SidebarApp.tsx","src/sidebar/index.html","src/sidebar/scripts.tsx","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/sidebar-shadcn/screenshot.png","icon":"examples/sidebar-shadcn/src/images/icon.png"},{"slug":"special-folders-pages","name":"special-folders-pages","title":"Special Folders: pages/","version":"1.0.0","manifest_version":3,"description":"Opens a welcome page in a new tab when the extension loads.","uiContext":["background"],"surfaces":["background"],"entrypoints":["background.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":[],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["special-folders","pages","welcome-page","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","A welcome page from src/pages/ opens automatically on install","Add another HTML file under src/pages/ and it ships as a separate page"],"useCases":["Onboarding, help, and welcome pages that ship with the extension","Standalone HTML routes without manifest plumbing","Reference for Extension.js\'s pages/ special folder convention"],"docsUrl":"https://extension.js.org/docs/features/special-folders","files":["extension.config.js","pages/custom.html","pages/main.html","pages/main.js","pages/welcome.html","public/css/file.css","public/html/file.html","public/js/file.js","src/background.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/javascript.png","src/manifest.json","src/sandbox/eval.js","src/sandbox/index.html","src/sandbox/scripts.js","src/sandbox/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/special-folders-pages/screenshot.png","icon":"examples/special-folders-pages/src/images/icon.png"},{"slug":"special-folders-scripts","name":"special-folders-scripts","title":"Special Folders Scripts","version":"1.0.0","manifest_version":3,"description":"Shows a small overlay on web pages and a toolbar popup that runs the extension\'s bundled scripts.","uiContext":["content","action","background"],"surfaces":["content","action","background"],"entrypoints":["background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["activeTab","scripting"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":false,"tags":["special-folders","scripts","javascript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Open any page for the overlay, then click the toolbar icon for the popup","Add a file under scripts/ and it ships as its own bundle"],"useCases":["Scripts shared between a popup and a content script","Standalone script bundles without manifest plumbing","Reference for the scripts/ special folder convention"],"docsUrl":"https://extension.js.org/docs/features/special-folders","video":"P8Q8TDQqbQE","files":["extension.config.js","scripts/script-one.js","scripts/script-three.js","scripts/script-two.js","src/background.js","src/content/scripts.js","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/images/javascript.png","src/manifest.json"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/special-folders-scripts/screenshot.png","icon":"examples/special-folders-scripts/src/images/icon.png"},{"slug":"svelte","name":"svelte","title":"Svelte","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple Svelte page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"svelte","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","svelte","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the Svelte sidebar panel","Edit the component in src/sidebar/ and save to watch it update"],"useCases":["A sidebar panel rendered by Svelte","Svelte components compiled with no extra config","Starting point for Svelte extensions"],"docsUrl":"https://extension.js.org/docs/getting-started/templates/svelte","video":"RgMo9t-oLL4","files":["extension.config.js","public/extension.svg","src/background.ts","src/content/ContentApp.svelte","src/content/scripts.ts","src/content/styles.css","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.svelte","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/svelte/screenshot.png","icon":"examples/svelte/src/images/icon.png"},{"slug":"transformers-js","name":"transformers-js","title":"Transformers.js","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel that classifies the active page or your selected text with on-device AI models.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.js","content/scripts.js"],"uiFramework":"","css":"css","configFiles":["extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel","storage","tabs","contextMenus"],"host_permissions":["<all_urls>"],"optional_permissions":[],"featured":true,"tags":["sidebar","ai","transformers","javascript"],"difficulty":"intermediate","timeToFirstSuccessMinutes":4,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the side panel","Type in the text box, or use the page text or your selection, and the model classifies it on your machine"],"useCases":["Running Transformers.js models inside an extension","Classifying page text with no server and no API key","Reference for shipping on-device AI under MV3"],"docsUrl":"https://extension.js.org/docs/languages-and-frameworks/webassembly","video":"cTxqV5HXXWE","files":["extension.config.js","src/background.js","src/constants.js","src/content/scripts.js","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.js","src/sidebar/index.html","src/sidebar/sakura.css","src/sidebar/scripts.js","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/transformers-js/screenshot.png","icon":"examples/transformers-js/src/images/icon.png"},{"slug":"typescript","name":"typescript","title":"TypeScript","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the sidebar panel","Edit the typed sources in src/sidebar/ to watch the panel update"],"useCases":["A typed sidebar extension with no compiler config","TypeScript compiled by Extension.js out of the box","Starting point for TypeScript extensions"],"docsUrl":"https://extension.js.org/docs/getting-started/templates/typescript","video":"UX91cqUH2DU","files":["extension.config.js","src/background.ts","src/content/ContentApp.ts","src/content/scripts.ts","src/content/styles.css","src/content/types.d.ts","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/sidebar/SidebarApp.ts","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/typescript/screenshot.png","icon":"examples/typescript/src/images/icon.png"},{"slug":"vue","name":"vue","title":"Vue","version":"1.0.0","manifest_version":3,"description":"Adds a sidebar panel to the browser with a simple Vue page.","uiContext":["content","sidebar","action","background"],"surfaces":["content","action","sidebar","background"],"entrypoints":["sidebar/index.html","background.ts","content/scripts.ts"],"uiFramework":"vue","css":"css","configFiles":["tsconfig.json","extension.config.js"],"hasBackground":true,"hasEnv":false,"setup":{"packageManager":"pnpm","scripts":{},"env":{"hasEnvExample":false,"hasEnv":false}},"permissions":["sidePanel"],"host_permissions":[],"optional_permissions":[],"featured":true,"tags":["sidebar","vue","typescript"],"difficulty":"beginner","timeToFirstSuccessMinutes":2,"firstSteps":["Run npm run dev to launch the extension","Click the extension icon to open the Vue sidebar panel","Edit the component in src/sidebar/ and save to watch it update"],"useCases":["A sidebar panel rendered by Vue","Single-file components compiled with no extra config","Starting point for Vue extensions"],"docsUrl":"https://extension.js.org/docs/getting-started/templates/vue","video":"x55b3Md2zIY","files":["extension.config.js","src/background.ts","src/content/ContentApp.vue","src/content/scripts.ts","src/content/styles.css","src/content/types.d.ts","src/images/icon-128.png","src/images/icon-16.png","src/images/icon-32.png","src/images/icon-48.png","src/images/icon-64.png","src/images/icon.png","src/manifest.json","src/shims-vue.d.ts","src/sidebar/SidebarApp.vue","src/sidebar/index.html","src/sidebar/scripts.ts","src/sidebar/styles.css"],"browsers":["chrome","edge","firefox"],"screenshot":"examples/vue/screenshot.png","icon":"examples/vue/src/images/icon.png"}]}');
|
|
2080
2523
|
const CACHE_DIR = node_path.join(node_os.homedir(), ".cache", "extension-js");
|
|
2081
2524
|
const CACHE_FILE = node_path.join(CACHE_DIR, "templates-meta.json");
|
|
2082
2525
|
const CACHE_TTL_MS = 3600000;
|
|
@@ -2699,11 +3142,110 @@ function removeSession(projectPath, browser, pid) {
|
|
|
2699
3142
|
function listSessions() {
|
|
2700
3143
|
return Array.from(sessions.values());
|
|
2701
3144
|
}
|
|
3145
|
+
const PROJECT_MANIFEST_FILENAMES = [
|
|
3146
|
+
"package.json",
|
|
3147
|
+
"deno.jsonc",
|
|
3148
|
+
"deno.json"
|
|
3149
|
+
];
|
|
3150
|
+
const PACKAGE_MANIFEST = "package.json";
|
|
3151
|
+
const DENO_MANIFESTS = [
|
|
3152
|
+
"deno.jsonc",
|
|
3153
|
+
"deno.json"
|
|
3154
|
+
];
|
|
3155
|
+
const EXTENSION_DEPENDENCY_NAMES = [
|
|
3156
|
+
"extension",
|
|
3157
|
+
"extension-develop",
|
|
3158
|
+
"extension-create"
|
|
3159
|
+
];
|
|
3160
|
+
const EXTENSION_CONFIG_FILENAMES = [
|
|
3161
|
+
"extension.config.js",
|
|
3162
|
+
"extension.config.mjs",
|
|
3163
|
+
"extension.config.cjs"
|
|
3164
|
+
];
|
|
3165
|
+
function isFile(file) {
|
|
3166
|
+
try {
|
|
3167
|
+
return node_fs.statSync(file).isFile();
|
|
3168
|
+
} catch {
|
|
3169
|
+
return false;
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
function nearestAbove(startDir, names) {
|
|
3173
|
+
let current = startDir;
|
|
3174
|
+
for(;;){
|
|
3175
|
+
for (const name of names)if (isFile(node_path.join(current, name))) return node_path.join(current, name);
|
|
3176
|
+
const parent = node_path.dirname(current);
|
|
3177
|
+
if (parent === current) return null;
|
|
3178
|
+
current = parent;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
function extensionManifestPath(projectPath) {
|
|
3182
|
+
const src = node_path.join(projectPath, "src", "manifest.json");
|
|
3183
|
+
if (isFile(src)) return src;
|
|
3184
|
+
const root = node_path.join(projectPath, "manifest.json");
|
|
3185
|
+
return isFile(root) ? root : null;
|
|
3186
|
+
}
|
|
3187
|
+
function declaresExtension(projectManifestPath) {
|
|
3188
|
+
let parsed;
|
|
3189
|
+
try {
|
|
3190
|
+
parsed = JSON.parse(node_fs.readFileSync(projectManifestPath, "utf8"));
|
|
3191
|
+
} catch {
|
|
3192
|
+
return false;
|
|
3193
|
+
}
|
|
3194
|
+
if (!parsed || "object" != typeof parsed) return false;
|
|
3195
|
+
const record = parsed;
|
|
3196
|
+
const named = [];
|
|
3197
|
+
for (const field of [
|
|
3198
|
+
"dependencies",
|
|
3199
|
+
"devDependencies"
|
|
3200
|
+
]){
|
|
3201
|
+
const deps = record[field];
|
|
3202
|
+
if (deps && "object" == typeof deps) named.push(...Object.keys(deps));
|
|
3203
|
+
}
|
|
3204
|
+
const imports = record.imports;
|
|
3205
|
+
if (imports && "object" == typeof imports) for (const [key, value] of Object.entries(imports)){
|
|
3206
|
+
named.push(key);
|
|
3207
|
+
if ("string" == typeof value) named.push(value);
|
|
3208
|
+
}
|
|
3209
|
+
return named.some((entry)=>EXTENSION_DEPENDENCY_NAMES.some((name)=>entry === name || entry.includes(`${name}@`) || entry === `npm:${name}`));
|
|
3210
|
+
}
|
|
3211
|
+
function ownsManifest(projectManifestPath, manifestPath) {
|
|
3212
|
+
const projectDir = node_path.resolve(node_path.dirname(projectManifestPath));
|
|
3213
|
+
const manifestDir = node_path.resolve(node_path.dirname(manifestPath));
|
|
3214
|
+
if (projectDir === manifestDir) return true;
|
|
3215
|
+
if ("src" === node_path.basename(manifestDir) && node_path.dirname(manifestDir) === projectDir) return true;
|
|
3216
|
+
if (declaresExtension(projectManifestPath)) return true;
|
|
3217
|
+
return EXTENSION_CONFIG_FILENAMES.some((name)=>isFile(node_path.join(projectDir, name)));
|
|
3218
|
+
}
|
|
3219
|
+
function engineProjectRoot(projectPath) {
|
|
3220
|
+
const start = node_path.resolve(projectPath);
|
|
3221
|
+
const manifest = extensionManifestPath(start);
|
|
3222
|
+
if (!manifest) {
|
|
3223
|
+
const nearest = nearestAbove(start, PROJECT_MANIFEST_FILENAMES);
|
|
3224
|
+
return nearest ? node_path.dirname(nearest) : start;
|
|
3225
|
+
}
|
|
3226
|
+
const manifestDir = node_path.dirname(manifest);
|
|
3227
|
+
const packageJson = nearestAbove(manifestDir, [
|
|
3228
|
+
PACKAGE_MANIFEST
|
|
3229
|
+
]);
|
|
3230
|
+
if (packageJson && ownsManifest(packageJson, manifest)) return node_path.dirname(packageJson);
|
|
3231
|
+
const deno = nearestAbove(manifestDir, DENO_MANIFESTS);
|
|
3232
|
+
if (deno && ownsManifest(deno, manifest)) return node_path.dirname(deno);
|
|
3233
|
+
return start;
|
|
3234
|
+
}
|
|
3235
|
+
const rooted = (fn)=>(projectPath, ...rest)=>fn(engineProjectRoot(projectPath), ...rest);
|
|
3236
|
+
rooted(actionsPath);
|
|
3237
|
+
rooted(browserArtifactsDir);
|
|
3238
|
+
const session_paths_buildSummaryPath = rooted(buildSummaryPath);
|
|
3239
|
+
rooted(eventsPath);
|
|
3240
|
+
const session_paths_logsPath = rooted(logsPath);
|
|
3241
|
+
const session_paths_readyContractPath = rooted(bridge_readyContractPath);
|
|
3242
|
+
const session_paths_sessionArtifactsRootDir = rooted(sessionArtifactsRootDir);
|
|
3243
|
+
rooted(bridge_sessionStateDir);
|
|
2702
3244
|
function sessionPathHint(file) {
|
|
2703
3245
|
return `Looked at ${file} (the session-state layout this MCP's pinned engine publishes). If the project runs an older Extension.js, its layout may differ and this path will never appear.`;
|
|
2704
3246
|
}
|
|
2705
3247
|
function profilesRootDir(projectPath) {
|
|
2706
|
-
return node_path.join(
|
|
3248
|
+
return node_path.join(session_paths_sessionArtifactsRootDir(projectPath), "profiles");
|
|
2707
3249
|
}
|
|
2708
3250
|
function browserProfileRootDir(projectPath, browser) {
|
|
2709
3251
|
return node_path.join(profilesRootDir(projectPath), `${browser}-profile`);
|
|
@@ -2721,7 +3263,7 @@ function profileRemediation(input) {
|
|
|
2721
3263
|
return `The engine keeps this session's profile in a directory inside ${root}, one per run: "${PERSISTED_PROFILE_DIR_NAME}" when the profile is persisted, otherwise three random words drawn fresh on every start, which no caller can predict. List ${root} to see which run directories exist and remove the one the stuck browser holds, or remove ${root} entirely once no session is running.`;
|
|
2722
3264
|
}
|
|
2723
3265
|
function contractSightings(projectPath) {
|
|
2724
|
-
const root =
|
|
3266
|
+
const root = session_paths_sessionArtifactsRootDir(projectPath);
|
|
2725
3267
|
let dirs;
|
|
2726
3268
|
try {
|
|
2727
3269
|
dirs = node_fs.readdirSync(root);
|
|
@@ -2730,7 +3272,7 @@ function contractSightings(projectPath) {
|
|
|
2730
3272
|
}
|
|
2731
3273
|
const sightings = [];
|
|
2732
3274
|
for (const dir of dirs){
|
|
2733
|
-
const readyPath =
|
|
3275
|
+
const readyPath = session_paths_readyContractPath(projectPath, dir);
|
|
2734
3276
|
try {
|
|
2735
3277
|
const stat = node_fs.statSync(readyPath);
|
|
2736
3278
|
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
@@ -2791,15 +3333,17 @@ function liveProjectSessions(projectPath) {
|
|
|
2791
3333
|
...out.values()
|
|
2792
3334
|
];
|
|
2793
3335
|
}
|
|
2794
|
-
function deadReadySession(projectPath) {
|
|
2795
|
-
for (const sighting of contractSightings(projectPath))if (
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
3336
|
+
function deadReadySession(projectPath, browser) {
|
|
3337
|
+
for (const sighting of contractSightings(projectPath))if (!browser || sighting.browser === browser) {
|
|
3338
|
+
if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
|
|
3339
|
+
browser: sighting.browser,
|
|
3340
|
+
pid: sighting.pid
|
|
3341
|
+
};
|
|
3342
|
+
}
|
|
2799
3343
|
return null;
|
|
2800
3344
|
}
|
|
2801
3345
|
function contractBoundPort(projectPath, browser, since) {
|
|
2802
|
-
const readyPath =
|
|
3346
|
+
const readyPath = session_paths_readyContractPath(projectPath, browser);
|
|
2803
3347
|
try {
|
|
2804
3348
|
const stat = node_fs.statSync(readyPath);
|
|
2805
3349
|
if (stat.mtimeMs < since) return null;
|
|
@@ -2849,6 +3393,8 @@ const GECKO_FAMILY = new Set([
|
|
|
2849
3393
|
"firefox",
|
|
2850
3394
|
"waterfox",
|
|
2851
3395
|
"librewolf",
|
|
3396
|
+
"zen",
|
|
3397
|
+
"floorp",
|
|
2852
3398
|
"gecko-based",
|
|
2853
3399
|
"firefox-based"
|
|
2854
3400
|
]);
|
|
@@ -3227,7 +3773,7 @@ function readZipEntryNames(zipPath) {
|
|
|
3227
3773
|
}
|
|
3228
3774
|
const build_COMMAND = "extension_build";
|
|
3229
3775
|
function readBuildSummary(projectPath, browser, since) {
|
|
3230
|
-
const file =
|
|
3776
|
+
const file = session_paths_buildSummaryPath(projectPath, browser);
|
|
3231
3777
|
try {
|
|
3232
3778
|
const stat = node_fs.statSync(file);
|
|
3233
3779
|
if (stat.mtimeMs >= since) {
|
|
@@ -3779,6 +4325,11 @@ async function build_handler(args) {
|
|
|
3779
4325
|
}
|
|
3780
4326
|
const engineFailure = "string" == typeof engine.frame?.error?.message ? engine.frame.error.message.trim() : "";
|
|
3781
4327
|
const message = engineFailure || stderr.trim() || out || `extension build exited with code ${code}`;
|
|
4328
|
+
const compileErrors = buildCompileErrors(args.projectPath, browser, start);
|
|
4329
|
+
const tail = [
|
|
4330
|
+
out,
|
|
4331
|
+
stderr.trim()
|
|
4332
|
+
].filter(Boolean).join("\n").trim();
|
|
3782
4333
|
return envelope_envelope({
|
|
3783
4334
|
ok: false,
|
|
3784
4335
|
command: build_COMMAND,
|
|
@@ -3789,12 +4340,30 @@ async function build_handler(args) {
|
|
|
3789
4340
|
},
|
|
3790
4341
|
value: {
|
|
3791
4342
|
browser,
|
|
3792
|
-
duration
|
|
4343
|
+
duration,
|
|
4344
|
+
...compileErrors.length ? {
|
|
4345
|
+
errors: compileErrors
|
|
4346
|
+
} : {},
|
|
4347
|
+
...tail && !compileErrors.length ? {
|
|
4348
|
+
output: tail.slice(-4000)
|
|
4349
|
+
} : {}
|
|
3793
4350
|
},
|
|
3794
4351
|
warnings,
|
|
3795
|
-
hint:
|
|
4352
|
+
hint: compileErrors.length ? `Fix the ${compileErrors.length} compile error${1 === compileErrors.length ? "" : "s"} in value.errors (file, loader and message as the bundler printed them) and build again. extension_manifest_validate covers the manifest; a missing dependency is installed by the build itself.` : "Read value.output for the bundler's own report. The manifest may live at the project root or under src; a failure here is usually a compile error, a manifest the engine refuses, or a Safari toolchain the host does not have."
|
|
3796
4353
|
});
|
|
3797
4354
|
}
|
|
4355
|
+
const ANSI_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
4356
|
+
function buildCompileErrors(projectPath, browser, since) {
|
|
4357
|
+
try {
|
|
4358
|
+
const file = session_paths_readyContractPath(projectPath, browser);
|
|
4359
|
+
if (node_fs.statSync(file).mtimeMs < since) return [];
|
|
4360
|
+
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
4361
|
+
const errors = Array.isArray(contract?.errors) ? contract.errors : [];
|
|
4362
|
+
return errors.filter((e)=>"string" == typeof e && e.trim()).map((e)=>e.replace(ANSI_SEQUENCE, "").trim()).slice(0, 20);
|
|
4363
|
+
} catch {
|
|
4364
|
+
return [];
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
3798
4367
|
const MERGED_FD_CHATTER = [
|
|
3799
4368
|
/^npm warn Unknown project config/i,
|
|
3800
4369
|
/This will stop working in the next major version of npm/i,
|
|
@@ -3821,7 +4390,7 @@ const LEGACY_FIDELITY_WARNING = "This session ran a CLI that does not stamp the
|
|
|
3821
4390
|
const READY_STAMP_PROFILE_LOCKED_CODE = "profile_locked";
|
|
3822
4391
|
function readContract(projectPath, browser, since) {
|
|
3823
4392
|
try {
|
|
3824
|
-
const file =
|
|
4393
|
+
const file = session_paths_readyContractPath(projectPath, browser);
|
|
3825
4394
|
const stat = node_fs.statSync(file);
|
|
3826
4395
|
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3827
4396
|
if (!contract || "object" != typeof contract) return null;
|
|
@@ -4084,7 +4653,7 @@ function projectPathForms(projectPath) {
|
|
|
4084
4653
|
...forms
|
|
4085
4654
|
];
|
|
4086
4655
|
}
|
|
4087
|
-
const PLAUSIBLE_SESSION_BINARY = /chrom|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari|node|electron|extension/i;
|
|
4656
|
+
const PLAUSIBLE_SESSION_BINARY = /chrom|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|zen|floorp|safari|node|electron|extension/i;
|
|
4088
4657
|
function processCommand(pid) {
|
|
4089
4658
|
try {
|
|
4090
4659
|
const cmdline = node_fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
|
@@ -4104,8 +4673,31 @@ function processCommand(pid) {
|
|
|
4104
4673
|
return "";
|
|
4105
4674
|
}
|
|
4106
4675
|
}
|
|
4107
|
-
function
|
|
4676
|
+
function contractProcessHints(projectPath, browser) {
|
|
4677
|
+
try {
|
|
4678
|
+
const contract = JSON.parse(node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8"));
|
|
4679
|
+
const pids = [
|
|
4680
|
+
contract.browserPid,
|
|
4681
|
+
contract.launcherPid
|
|
4682
|
+
].filter((pid)=>"number" == typeof pid && Number.isInteger(pid) && pid > 0);
|
|
4683
|
+
return {
|
|
4684
|
+
..."string" == typeof contract.profilePath && contract.profilePath.trim() ? {
|
|
4685
|
+
profilePath: contract.profilePath
|
|
4686
|
+
} : {},
|
|
4687
|
+
pids
|
|
4688
|
+
};
|
|
4689
|
+
} catch {
|
|
4690
|
+
return {
|
|
4691
|
+
pids: []
|
|
4692
|
+
};
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
function sessionProcessPids(projectPath, hints = {
|
|
4696
|
+
pids: []
|
|
4697
|
+
}) {
|
|
4108
4698
|
const pids = new Set();
|
|
4699
|
+
if (hints.profilePath) for (const pid of pgrepPids(escapeRegex(hints.profilePath)))pids.add(pid);
|
|
4700
|
+
for (const pid of hints.pids)if (pid !== process.pid && isAlive(pid)) pids.add(pid);
|
|
4109
4701
|
for (const form of projectPathForms(projectPath)){
|
|
4110
4702
|
const escaped = escapeRegex(form);
|
|
4111
4703
|
const patterns = [
|
|
@@ -4118,8 +4710,10 @@ function sessionProcessPids(projectPath) {
|
|
|
4118
4710
|
...pids
|
|
4119
4711
|
].filter((pid)=>PLAUSIBLE_SESSION_BINARY.test(processCommand(pid)));
|
|
4120
4712
|
}
|
|
4121
|
-
function reapSessionProcesses(projectPath
|
|
4122
|
-
|
|
4713
|
+
function reapSessionProcesses(projectPath, hints = {
|
|
4714
|
+
pids: []
|
|
4715
|
+
}) {
|
|
4716
|
+
const pids = sessionProcessPids(projectPath, hints);
|
|
4123
4717
|
for (const pid of pids)try {
|
|
4124
4718
|
process.kill(pid, "SIGKILL");
|
|
4125
4719
|
} catch {}
|
|
@@ -4148,7 +4742,7 @@ function stop_signal(pid, sig) {
|
|
|
4148
4742
|
}
|
|
4149
4743
|
function pidFromReadyContract(projectPath, browser) {
|
|
4150
4744
|
try {
|
|
4151
|
-
const raw = node_fs.readFileSync(
|
|
4745
|
+
const raw = node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8");
|
|
4152
4746
|
const contract = JSON.parse(raw);
|
|
4153
4747
|
return "number" == typeof contract.pid ? contract.pid : null;
|
|
4154
4748
|
} catch {
|
|
@@ -4158,8 +4752,9 @@ function pidFromReadyContract(projectPath, browser) {
|
|
|
4158
4752
|
async function stopOne(projectPath, browser) {
|
|
4159
4753
|
const session = findSessionInfo(projectPath, browser);
|
|
4160
4754
|
const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
|
|
4755
|
+
const hints = contractProcessHints(projectPath, browser);
|
|
4161
4756
|
if (null == pid) {
|
|
4162
|
-
const reaped = reapSessionProcesses(projectPath);
|
|
4757
|
+
const reaped = reapSessionProcesses(projectPath, hints);
|
|
4163
4758
|
removeSessionMarker(projectPath, browser);
|
|
4164
4759
|
return {
|
|
4165
4760
|
projectPath,
|
|
@@ -4181,15 +4776,15 @@ async function stopOne(projectPath, browser) {
|
|
|
4181
4776
|
}
|
|
4182
4777
|
detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
|
|
4183
4778
|
} else detail = "Process was already gone; cleaned up session records.";
|
|
4184
|
-
const reaped = reapSessionProcesses(projectPath);
|
|
4779
|
+
const reaped = reapSessionProcesses(projectPath, hints);
|
|
4185
4780
|
removeSession(projectPath, browser);
|
|
4186
4781
|
removeSessionMarker(projectPath, browser);
|
|
4187
4782
|
try {
|
|
4188
|
-
node_fs.rmSync(
|
|
4783
|
+
node_fs.rmSync(session_paths_readyContractPath(projectPath, browser), {
|
|
4189
4784
|
force: true
|
|
4190
4785
|
});
|
|
4191
4786
|
} catch {}
|
|
4192
|
-
const survivors = sessionProcessPids(projectPath);
|
|
4787
|
+
const survivors = sessionProcessPids(projectPath, hints);
|
|
4193
4788
|
const stopped = !isAlive(pid) && 0 === survivors.length;
|
|
4194
4789
|
if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
|
|
4195
4790
|
else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
|
|
@@ -4553,7 +5148,8 @@ async function dev_handler(args) {
|
|
|
4553
5148
|
} : {
|
|
4554
5149
|
requestedPort: args.port ?? 8080
|
|
4555
5150
|
};
|
|
4556
|
-
const
|
|
5151
|
+
const autoAssigned = 0 === args.port;
|
|
5152
|
+
const portNote = null !== boundPort ? void 0 !== args.port && args.port !== boundPort ? autoAssigned ? `Port 0 asked the engine for any free port and it picked ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).` : `Requested port ${args.port} was not available; the dev server bound ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).` : null : autoAssigned ? "Port 0 asked the engine for any free port, and it has not stamped its ready.json contract yet, so the port it picked is not known at response time. extension_wait reports it from that contract once it lands." : "The engine has not stamped its ready.json contract yet, so the bound port is not known at response time (a taken port makes the server bind the next free one). extension_wait reports the bound port from that contract once it lands; requestedPort above is only what was asked for.";
|
|
4557
5153
|
return envelope_envelope({
|
|
4558
5154
|
ok: true,
|
|
4559
5155
|
command: dev_schema.name,
|
|
@@ -4573,6 +5169,7 @@ async function dev_handler(args) {
|
|
|
4573
5169
|
} : {}
|
|
4574
5170
|
} : {},
|
|
4575
5171
|
capabilities,
|
|
5172
|
+
engine: describeExtensionInvocation(args.projectPath),
|
|
4576
5173
|
logPath
|
|
4577
5174
|
},
|
|
4578
5175
|
warnings: [
|
|
@@ -4585,7 +5182,7 @@ async function dev_handler(args) {
|
|
|
4585
5182
|
}
|
|
4586
5183
|
const start_schema = {
|
|
4587
5184
|
name: "extension_start",
|
|
4588
|
-
description: "Run the PRODUCTION build in a browser: build the project, serve it, and launch. There is no hot module replacement and no control channel, so your edits are not picked up and extension_eval, extension_storage, extension_reload, extension_open and extension_dom_snapshot cannot attach to this session. Use extension_dev while writing code, and this to check what actually ships. Pass build:false to launch an existing dist/<browser> without rebuilding.",
|
|
5185
|
+
description: "Run the PRODUCTION build in a browser: build the project, serve it, and launch. There is no hot module replacement and no control channel, so your edits are not picked up and extension_eval, extension_storage, extension_reload, extension_open and extension_dom_snapshot cannot attach to this session. Use extension_dev while writing code, and this to check what actually ships. Pass build:false to launch an existing dist/<browser> without rebuilding, or outputPath to launch any prebuilt unpacked extension directory, one another toolchain produced included, which implies build:false.",
|
|
4589
5186
|
inputSchema: {
|
|
4590
5187
|
type: "object",
|
|
4591
5188
|
properties: {
|
|
@@ -4610,6 +5207,10 @@ const start_schema = {
|
|
|
4610
5207
|
default: false,
|
|
4611
5208
|
description: "Serve without launching a browser"
|
|
4612
5209
|
},
|
|
5210
|
+
outputPath: {
|
|
5211
|
+
type: "string",
|
|
5212
|
+
description: "An existing unpacked extension directory to launch as it is (a manifest.json at its root), for an artifact built by another toolchain or an exact release candidate. Implies build:false; projectPath still names the project the session belongs to. Relative paths resolve against projectPath."
|
|
5213
|
+
},
|
|
4613
5214
|
...LAUNCH_FLAG_SCHEMA
|
|
4614
5215
|
},
|
|
4615
5216
|
required: [
|
|
@@ -4619,7 +5220,22 @@ const start_schema = {
|
|
|
4619
5220
|
};
|
|
4620
5221
|
async function start_handler(args) {
|
|
4621
5222
|
const browser = args.browser ?? "chrome";
|
|
4622
|
-
const
|
|
5223
|
+
const outputPath = "string" == typeof args.outputPath && args.outputPath.trim() ? node_path.resolve(args.projectPath, args.outputPath.trim()) : null;
|
|
5224
|
+
if (outputPath) {
|
|
5225
|
+
const manifest = node_path.join(outputPath, "manifest.json");
|
|
5226
|
+
if (!node_fs.existsSync(manifest)) return envelope_envelope({
|
|
5227
|
+
ok: false,
|
|
5228
|
+
command: start_schema.name,
|
|
5229
|
+
status: "no-unpacked-extension",
|
|
5230
|
+
error: {
|
|
5231
|
+
code: "E_NO_DIST",
|
|
5232
|
+
name: "NoUnpackedExtension",
|
|
5233
|
+
message: `outputPath ${outputPath} holds no manifest.json, so there is no unpacked extension to launch.`
|
|
5234
|
+
},
|
|
5235
|
+
hint: "Point outputPath at the directory that holds the built manifest.json (for an Extension.js project that is dist/<browser>; for another toolchain, its build output), or omit it to build and run the project."
|
|
5236
|
+
});
|
|
5237
|
+
}
|
|
5238
|
+
const building = false !== args.build && !outputPath;
|
|
4623
5239
|
const command = building ? "start" : "preview";
|
|
4624
5240
|
const cliArgs = [
|
|
4625
5241
|
command,
|
|
@@ -4627,6 +5243,7 @@ async function start_handler(args) {
|
|
|
4627
5243
|
"--browser",
|
|
4628
5244
|
browser
|
|
4629
5245
|
];
|
|
5246
|
+
if (outputPath) cliArgs.push("--output-path", outputPath);
|
|
4630
5247
|
if (building && false === args.polyfill) cliArgs.push("--polyfill", "false");
|
|
4631
5248
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
4632
5249
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
@@ -4743,7 +5360,7 @@ async function start_handler(args) {
|
|
|
4743
5360
|
});
|
|
4744
5361
|
}
|
|
4745
5362
|
function toMcpSpeak(text) {
|
|
4746
|
-
return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
|
|
5363
|
+
return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|zen|floorp|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/\bextension open (popup|options|sidebar|newtab|history|bookmarks)\b/g, 'extension_open with surface: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
|
|
4747
5364
|
}
|
|
4748
5365
|
const CONTROL_CHANNEL_DOWN_CODES = new Set([
|
|
4749
5366
|
"E_CONTROL_DENIED",
|
|
@@ -4755,18 +5372,22 @@ const CONTROL_CHANNEL_DOWN_CODES = new Set([
|
|
|
4755
5372
|
function legacyControlChannelScrape(message) {
|
|
4756
5373
|
return /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
|
|
4757
5374
|
}
|
|
4758
|
-
function withSessionContext(message, projectPath, code) {
|
|
5375
|
+
function withSessionContext(message, projectPath, code, browser) {
|
|
4759
5376
|
const isControlError = code ? CONTROL_CHANNEL_DOWN_CODES.has(code) : legacyControlChannelScrape(message);
|
|
4760
5377
|
if (!isControlError) return message;
|
|
4761
|
-
const dead = deadReadySession(projectPath);
|
|
5378
|
+
const dead = deadReadySession(projectPath, browser);
|
|
4762
5379
|
if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
|
|
4763
5380
|
const running = knownSessionBrowsers(projectPath);
|
|
4764
5381
|
if (0 === running.length) return message;
|
|
4765
5382
|
return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
|
|
4766
5383
|
}
|
|
4767
|
-
function
|
|
5384
|
+
function browserFlag(args) {
|
|
5385
|
+
const at = args.indexOf("--browser");
|
|
5386
|
+
return -1 !== at && "string" == typeof args[at + 1] ? args[at + 1] : void 0;
|
|
5387
|
+
}
|
|
5388
|
+
function translateFrame(frame, projectPath, browser) {
|
|
4768
5389
|
if (!frame || false !== frame.ok) return frame;
|
|
4769
|
-
if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath, "string" == typeof frame.error.code ? frame.error.code : void 0);
|
|
5390
|
+
if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath, "string" == typeof frame.error.code ? frame.error.code : void 0, browser);
|
|
4770
5391
|
if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
|
|
4771
5392
|
if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
|
|
4772
5393
|
return frame;
|
|
@@ -4841,7 +5462,7 @@ async function runActVerb(args, projectPath, timeoutMs, tool) {
|
|
|
4841
5462
|
});
|
|
4842
5463
|
const out = stdout.trim();
|
|
4843
5464
|
if (out) try {
|
|
4844
|
-
const frame = translateFrame(JSON.parse(out), projectPath);
|
|
5465
|
+
const frame = translateFrame(JSON.parse(out), projectPath, browserFlag(args));
|
|
4845
5466
|
if (isEnvelope(frame)) {
|
|
4846
5467
|
frame.command = command;
|
|
4847
5468
|
if (!Array.isArray(frame.warnings)) frame.warnings = [];
|
|
@@ -4868,7 +5489,7 @@ async function runActVerb(args, projectPath, timeoutMs, tool) {
|
|
|
4868
5489
|
error: {
|
|
4869
5490
|
code: "E_CLI",
|
|
4870
5491
|
name: "CliError",
|
|
4871
|
-
message: withSessionContext(toMcpSpeak(message), projectPath)
|
|
5492
|
+
message: withSessionContext(toMcpSpeak(message), projectPath, void 0, browserFlag(args))
|
|
4872
5493
|
}
|
|
4873
5494
|
});
|
|
4874
5495
|
}
|
|
@@ -5212,6 +5833,26 @@ class CDPClient extends CDPConnection {
|
|
|
5212
5833
|
if ("string" == typeof data.webSocketDebuggerUrl) return data.webSocketDebuggerUrl;
|
|
5213
5834
|
throw new Error("No webSocketDebuggerUrl in /json/version response");
|
|
5214
5835
|
}
|
|
5836
|
+
static async discoverBrowserVersion(port, host = "127.0.0.1") {
|
|
5837
|
+
try {
|
|
5838
|
+
const res = await fetch(`http://${host}:${port}/json/version`);
|
|
5839
|
+
if (!res.ok) return null;
|
|
5840
|
+
const data = await res.json();
|
|
5841
|
+
return "string" == typeof data.Browser ? data.Browser : null;
|
|
5842
|
+
} catch {
|
|
5843
|
+
return null;
|
|
5844
|
+
}
|
|
5845
|
+
}
|
|
5846
|
+
static async discoverUserAgent(port, host = "127.0.0.1") {
|
|
5847
|
+
try {
|
|
5848
|
+
const res = await fetch(`http://${host}:${port}/json/version`);
|
|
5849
|
+
if (!res.ok) return null;
|
|
5850
|
+
const data = await res.json();
|
|
5851
|
+
return "string" == typeof data["User-Agent"] ? data["User-Agent"] : null;
|
|
5852
|
+
} catch {
|
|
5853
|
+
return null;
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5215
5856
|
static async discoverTargets(port, host = "127.0.0.1") {
|
|
5216
5857
|
const res = await fetch(`http://${host}:${port}/json`);
|
|
5217
5858
|
if (!res.ok) throw new Error(`CDP /json failed: ${res.status}`);
|
|
@@ -5336,7 +5977,7 @@ class CDPClient extends CDPConnection {
|
|
|
5336
5977
|
async function resolveContractPort(projectPath, browser, field, options) {
|
|
5337
5978
|
const waitMs = options?.waitMs ?? 20000;
|
|
5338
5979
|
const graceMs = options?.graceMs ?? 2500;
|
|
5339
|
-
const readyPath =
|
|
5980
|
+
const readyPath = session_paths_readyContractPath(projectPath, browser);
|
|
5340
5981
|
const deadline = Date.now() + waitMs;
|
|
5341
5982
|
let contractSeen = false;
|
|
5342
5983
|
let contractSeenAt = null;
|
|
@@ -5413,7 +6054,7 @@ function webdriver_define_property(obj, key, value) {
|
|
|
5413
6054
|
const WEBDRIVER_SESSION_MISSING_HINT = "A Safari page is readable only through a safaridriver session the dev session records in ready.json (webdriverPort and webdriverSessionId), and no Extension.js release opens one today: Safari grants one automation session at a time, so this server never opens its own. What Safari does give you is the dev session's log file, which extension_logs and the log-based assertions read once the extension is enabled; the rest is Web Inspector, attended.";
|
|
5414
6055
|
function readWebDriverSession(projectPath, browser) {
|
|
5415
6056
|
try {
|
|
5416
|
-
const contract = JSON.parse(node_fs.readFileSync(
|
|
6057
|
+
const contract = JSON.parse(node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8"));
|
|
5417
6058
|
if ("number" == typeof contract.webdriverPort && Number.isFinite(contract.webdriverPort) && "string" == typeof contract.webdriverSessionId && contract.webdriverSessionId.length > 0) return {
|
|
5418
6059
|
port: contract.webdriverPort,
|
|
5419
6060
|
sessionId: contract.webdriverSessionId
|
|
@@ -5423,7 +6064,7 @@ function readWebDriverSession(projectPath, browser) {
|
|
|
5423
6064
|
}
|
|
5424
6065
|
function readyExtensionId(projectPath, browser) {
|
|
5425
6066
|
try {
|
|
5426
|
-
const contract = JSON.parse(node_fs.readFileSync(
|
|
6067
|
+
const contract = JSON.parse(node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8"));
|
|
5427
6068
|
const id = String(contract.extensionId || "").trim();
|
|
5428
6069
|
return id.length > 0 ? id : null;
|
|
5429
6070
|
} catch {
|
|
@@ -5693,11 +6334,18 @@ async function pollForBridgeTab(projectPath, browser, url, budgetMs) {
|
|
|
5693
6334
|
}
|
|
5694
6335
|
}
|
|
5695
6336
|
const UNKNOWN_VERB = /unknown command/i;
|
|
5696
|
-
async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool = "extension_open") {
|
|
6337
|
+
async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool = "extension_open", target = {}) {
|
|
5697
6338
|
const viaVerb = await runActVerb([
|
|
5698
6339
|
"navigate",
|
|
5699
6340
|
url,
|
|
5700
6341
|
projectPath,
|
|
6342
|
+
...null != target.tab ? [
|
|
6343
|
+
"--tab",
|
|
6344
|
+
String(target.tab)
|
|
6345
|
+
] : [],
|
|
6346
|
+
...null == target.tab && target.newTab ? [
|
|
6347
|
+
"--new-tab"
|
|
6348
|
+
] : [],
|
|
5701
6349
|
"--browser",
|
|
5702
6350
|
browser,
|
|
5703
6351
|
...null != timeout ? [
|
|
@@ -5784,7 +6432,47 @@ async function navigateToUrlViaBackgroundEval(projectPath, browser, url, timeout
|
|
|
5784
6432
|
hint: "Inspect it with extension_dom_snapshot or extension_eval using url or this numeric tab id (context: 'page'/'content')."
|
|
5785
6433
|
});
|
|
5786
6434
|
}
|
|
6435
|
+
const UUIDS_PREF = /user_pref\("extensions\.webextensions\.uuids",\s*"((?:[^"\\]|\\.)*)"\)/;
|
|
6436
|
+
function geckoAddonId(projectPath, browser) {
|
|
6437
|
+
const read = readBuiltManifest(projectPath, browser);
|
|
6438
|
+
const manifest = read?.manifest;
|
|
6439
|
+
const settings = manifest?.browser_specific_settings ?? manifest?.["firefox:browser_specific_settings"] ?? manifest?.["gecko:browser_specific_settings"] ?? manifest?.applications;
|
|
6440
|
+
const id = settings?.gecko?.id;
|
|
6441
|
+
return "string" == typeof id && id ? id : null;
|
|
6442
|
+
}
|
|
6443
|
+
function sessionProfilePath(projectPath, browser) {
|
|
6444
|
+
try {
|
|
6445
|
+
const contract = JSON.parse(node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8"));
|
|
6446
|
+
return "string" == typeof contract.profilePath && contract.profilePath.trim() ? contract.profilePath : null;
|
|
6447
|
+
} catch {
|
|
6448
|
+
return null;
|
|
6449
|
+
}
|
|
6450
|
+
}
|
|
6451
|
+
function readGeckoBaseUrlFromProfile(projectPath, browser) {
|
|
6452
|
+
const addonId = geckoAddonId(projectPath, browser);
|
|
6453
|
+
const profile = sessionProfilePath(projectPath, browser);
|
|
6454
|
+
if (!addonId || !profile) return null;
|
|
6455
|
+
let prefs;
|
|
6456
|
+
try {
|
|
6457
|
+
prefs = node_fs.readFileSync(node_path.join(profile, "prefs.js"), "utf8");
|
|
6458
|
+
} catch {
|
|
6459
|
+
return null;
|
|
6460
|
+
}
|
|
6461
|
+
const match = UUIDS_PREF.exec(prefs);
|
|
6462
|
+
if (!match) return null;
|
|
6463
|
+
try {
|
|
6464
|
+
const map = JSON.parse(JSON.parse(`"${match[1]}"`));
|
|
6465
|
+
const uuid = map[addonId];
|
|
6466
|
+
return "string" == typeof uuid && uuid ? `moz-extension://${uuid}/` : null;
|
|
6467
|
+
} catch {
|
|
6468
|
+
return null;
|
|
6469
|
+
}
|
|
6470
|
+
}
|
|
5787
6471
|
async function resolveBridgeBaseUrl(projectPath, browser, timeout) {
|
|
6472
|
+
const fromRelay = await resolveBridgeBaseUrlThroughRelay(projectPath, browser, timeout);
|
|
6473
|
+
return fromRelay ?? readGeckoBaseUrlFromProfile(projectPath, browser);
|
|
6474
|
+
}
|
|
6475
|
+
async function resolveBridgeBaseUrlThroughRelay(projectPath, browser, timeout) {
|
|
5788
6476
|
const raw = await runActVerb([
|
|
5789
6477
|
"eval",
|
|
5790
6478
|
'(typeof browser !== "undefined" ? browser : chrome).runtime.getURL("")',
|
|
@@ -5816,6 +6504,17 @@ function filterPageTargets(raw) {
|
|
|
5816
6504
|
async function listPageTargets(port) {
|
|
5817
6505
|
return filterPageTargets(await CDPClient.discoverTargets(port));
|
|
5818
6506
|
}
|
|
6507
|
+
function filterDocumentTargets(raw) {
|
|
6508
|
+
return raw.filter((t)=>("page" === t.type || "iframe" === t.type) && !String(t.url ?? "").startsWith("devtools://")).map((t)=>({
|
|
6509
|
+
targetId: String(t.id),
|
|
6510
|
+
type: String(t.type),
|
|
6511
|
+
url: String(t.url ?? ""),
|
|
6512
|
+
title: String(t.title ?? "")
|
|
6513
|
+
}));
|
|
6514
|
+
}
|
|
6515
|
+
async function listDocumentTargets(port) {
|
|
6516
|
+
return filterDocumentTargets(await CDPClient.discoverTargets(port));
|
|
6517
|
+
}
|
|
5819
6518
|
function matchTargetsByUrl(targets, needle) {
|
|
5820
6519
|
const wanted = needle.toLowerCase();
|
|
5821
6520
|
const byUrl = targets.filter((t)=>t.url.toLowerCase().includes(wanted));
|
|
@@ -5825,8 +6524,7 @@ function matchTargetsByUrl(targets, needle) {
|
|
|
5825
6524
|
const RUNTIME_EVALUATE_DEFAULTS = {
|
|
5826
6525
|
returnByValue: true,
|
|
5827
6526
|
awaitPromise: true,
|
|
5828
|
-
userGesture: true
|
|
5829
|
-
replMode: true
|
|
6527
|
+
userGesture: true
|
|
5830
6528
|
};
|
|
5831
6529
|
function stripHash(url) {
|
|
5832
6530
|
return url.replace(/#.*$/, "");
|
|
@@ -5838,9 +6536,67 @@ function matchExtensionPageTargets(targets, wantedUrl) {
|
|
|
5838
6536
|
return url === wanted || url.startsWith(wanted);
|
|
5839
6537
|
});
|
|
5840
6538
|
}
|
|
6539
|
+
const WORKER_TARGET_TYPES = new Set([
|
|
6540
|
+
"service_worker",
|
|
6541
|
+
"background_page",
|
|
6542
|
+
"worker"
|
|
6543
|
+
]);
|
|
6544
|
+
async function findExtensionWorkerTargets(port, extensionId) {
|
|
6545
|
+
try {
|
|
6546
|
+
const origin = `chrome-extension://${extensionId}/`;
|
|
6547
|
+
return (await CDPClient.discoverTargets(port)).filter((t)=>WORKER_TARGET_TYPES.has(String(t.type)) && String(t.url ?? "").startsWith(origin)).map((t)=>({
|
|
6548
|
+
targetId: String(t.id),
|
|
6549
|
+
type: String(t.type),
|
|
6550
|
+
url: String(t.url ?? "")
|
|
6551
|
+
}));
|
|
6552
|
+
} catch {
|
|
6553
|
+
return [];
|
|
6554
|
+
}
|
|
6555
|
+
}
|
|
6556
|
+
async function wakeExtensionWorker(port, extensionId) {
|
|
6557
|
+
const cdp = new CDPClient();
|
|
6558
|
+
const scopeURL = `chrome-extension://${extensionId}/`;
|
|
6559
|
+
try {
|
|
6560
|
+
await cdp.connect(await CDPClient.discoverBrowserWsUrl(port));
|
|
6561
|
+
const pages = (await CDPClient.discoverTargets(port)).filter((t)=>"page" === t.type && !String(t.url ?? "").startsWith("devtools://"));
|
|
6562
|
+
const host = pages.find((t)=>String(t.url ?? "").startsWith(scopeURL)) ?? pages[0];
|
|
6563
|
+
if (!host) return {
|
|
6564
|
+
woken: false,
|
|
6565
|
+
reason: "the session has no page target to issue ServiceWorker.startWorker from"
|
|
6566
|
+
};
|
|
6567
|
+
const sessionId = await cdp.attachToTarget(String(host.id));
|
|
6568
|
+
await cdp.sendCommand("ServiceWorker.enable", {}, sessionId);
|
|
6569
|
+
await cdp.sendCommand("ServiceWorker.startWorker", {
|
|
6570
|
+
scopeURL
|
|
6571
|
+
}, sessionId);
|
|
6572
|
+
const deadline = Date.now() + 3000;
|
|
6573
|
+
for(;;){
|
|
6574
|
+
const targets = await findExtensionWorkerTargets(port, extensionId);
|
|
6575
|
+
if (targets.length > 0) return {
|
|
6576
|
+
woken: true,
|
|
6577
|
+
targets
|
|
6578
|
+
};
|
|
6579
|
+
if (Date.now() >= deadline) break;
|
|
6580
|
+
await new Promise((r)=>setTimeout(r, 150));
|
|
6581
|
+
}
|
|
6582
|
+
return {
|
|
6583
|
+
woken: false,
|
|
6584
|
+
reason: "ServiceWorker.startWorker answered but no worker target was listed within 3s (the extension may declare no background, or the worker exited at once)"
|
|
6585
|
+
};
|
|
6586
|
+
} catch (error) {
|
|
6587
|
+
return {
|
|
6588
|
+
woken: false,
|
|
6589
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
6590
|
+
};
|
|
6591
|
+
} finally{
|
|
6592
|
+
try {
|
|
6593
|
+
cdp.disconnect();
|
|
6594
|
+
} catch {}
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
5841
6597
|
async function findExtensionPageTargets(port, wantedUrl) {
|
|
5842
6598
|
try {
|
|
5843
|
-
return matchExtensionPageTargets(await
|
|
6599
|
+
return matchExtensionPageTargets(await listDocumentTargets(port), wantedUrl);
|
|
5844
6600
|
} catch {
|
|
5845
6601
|
return [];
|
|
5846
6602
|
}
|
|
@@ -5858,15 +6614,30 @@ function describeException(details) {
|
|
|
5858
6614
|
if (exception && "value" in exception) return String(exception.value);
|
|
5859
6615
|
return details?.text || "the expression threw";
|
|
5860
6616
|
}
|
|
6617
|
+
const TOP_LEVEL_AWAIT_REFUSAL = /await is only valid in async functions/i;
|
|
5861
6618
|
async function evaluateOnExtensionPage(port, targetId, expression) {
|
|
5862
6619
|
const cdp = new CDPClient();
|
|
5863
6620
|
try {
|
|
5864
6621
|
await cdp.connect(await CDPClient.discoverBrowserWsUrl(port));
|
|
5865
6622
|
const sessionId = await cdp.attachToTarget(targetId);
|
|
5866
|
-
|
|
6623
|
+
let response = await cdp.sendCommand("Runtime.evaluate", {
|
|
5867
6624
|
expression,
|
|
5868
6625
|
...RUNTIME_EVALUATE_DEFAULTS
|
|
5869
6626
|
}, sessionId);
|
|
6627
|
+
if (response?.exceptionDetails && TOP_LEVEL_AWAIT_REFUSAL.test(describeException(response.exceptionDetails))) {
|
|
6628
|
+
const asPromise = await cdp.sendCommand("Runtime.evaluate", {
|
|
6629
|
+
expression,
|
|
6630
|
+
returnByValue: false,
|
|
6631
|
+
awaitPromise: false,
|
|
6632
|
+
userGesture: true,
|
|
6633
|
+
replMode: true
|
|
6634
|
+
}, sessionId);
|
|
6635
|
+
const promiseObjectId = asPromise?.result?.objectId;
|
|
6636
|
+
response = asPromise?.exceptionDetails || "string" != typeof promiseObjectId ? asPromise : await cdp.sendCommand("Runtime.awaitPromise", {
|
|
6637
|
+
promiseObjectId,
|
|
6638
|
+
returnByValue: true
|
|
6639
|
+
}, sessionId);
|
|
6640
|
+
}
|
|
5870
6641
|
if (response?.exceptionDetails) return {
|
|
5871
6642
|
ok: false,
|
|
5872
6643
|
thrown: true,
|
|
@@ -6037,6 +6808,146 @@ async function openSidePanelWithSyntheticGesture(port, hostUrl) {
|
|
|
6037
6808
|
} catch {}
|
|
6038
6809
|
}
|
|
6039
6810
|
}
|
|
6811
|
+
const PANEL_IDS_EXPRESSION = "import('./ui/legacy/legacy.js').then((m) => m.InspectorView.InspectorView.instance().tabbedPane.tabIds())";
|
|
6812
|
+
function showPanelExpression(panelId) {
|
|
6813
|
+
return `import('./ui/legacy/legacy.js').then((m) => m.InspectorView.InspectorView.instance().showPanel(${JSON.stringify(panelId)})).then(() => "shown")`;
|
|
6814
|
+
}
|
|
6815
|
+
function panelIdPrefix(extensionId) {
|
|
6816
|
+
return `chrome-extension://${extensionId}`;
|
|
6817
|
+
}
|
|
6818
|
+
function panelTitleFromId(panelId, extensionId) {
|
|
6819
|
+
return panelId.slice(panelIdPrefix(extensionId).length);
|
|
6820
|
+
}
|
|
6821
|
+
function isDevToolsFrontend(target) {
|
|
6822
|
+
return String(target.url ?? "").startsWith("devtools://");
|
|
6823
|
+
}
|
|
6824
|
+
async function sleep(ms) {
|
|
6825
|
+
await new Promise((r)=>setTimeout(r, ms));
|
|
6826
|
+
}
|
|
6827
|
+
async function openDevToolsPanel(port, options) {
|
|
6828
|
+
const budgetMs = options.budgetMs ?? 15000;
|
|
6829
|
+
const cdp = new CDPClient();
|
|
6830
|
+
const listTargets = async ()=>await CDPClient.discoverTargets(port);
|
|
6831
|
+
try {
|
|
6832
|
+
await cdp.connect(await CDPClient.discoverBrowserWsUrl(port));
|
|
6833
|
+
const before = new Set((await listTargets().catch(()=>[])).filter(isDevToolsFrontend).map((t)=>t.id));
|
|
6834
|
+
let devtoolsTargetId = null;
|
|
6835
|
+
try {
|
|
6836
|
+
const opened = await cdp.sendCommand("Target.openDevTools", {
|
|
6837
|
+
targetId: options.inspectedTargetId
|
|
6838
|
+
});
|
|
6839
|
+
if ("string" == typeof opened?.targetId) devtoolsTargetId = opened.targetId;
|
|
6840
|
+
} catch (error) {
|
|
6841
|
+
return {
|
|
6842
|
+
opened: false,
|
|
6843
|
+
stage: "open",
|
|
6844
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
6845
|
+
};
|
|
6846
|
+
}
|
|
6847
|
+
const deadline = Date.now() + budgetMs;
|
|
6848
|
+
while(!devtoolsTargetId && Date.now() < deadline){
|
|
6849
|
+
await sleep(200);
|
|
6850
|
+
const fresh = (await listTargets().catch(()=>[])).find((t)=>isDevToolsFrontend(t) && !before.has(t.id));
|
|
6851
|
+
if (fresh) devtoolsTargetId = fresh.id;
|
|
6852
|
+
}
|
|
6853
|
+
if (!devtoolsTargetId) return {
|
|
6854
|
+
opened: false,
|
|
6855
|
+
stage: "frontend",
|
|
6856
|
+
reason: "Target.openDevTools answered but no devtools:// frontend target appeared"
|
|
6857
|
+
};
|
|
6858
|
+
const sessionId = await cdp.attachToTarget(devtoolsTargetId);
|
|
6859
|
+
if (options.reloadInspected) {
|
|
6860
|
+
const frameDeadline = Date.now() + Math.min(5000, budgetMs / 2);
|
|
6861
|
+
while(Date.now() < frameDeadline){
|
|
6862
|
+
const frame = (await listTargets().catch(()=>[])).find((t)=>"iframe" === t.type && String(t.url ?? "").startsWith(options.devtoolsPageUrl));
|
|
6863
|
+
if (frame) break;
|
|
6864
|
+
await sleep(200);
|
|
6865
|
+
}
|
|
6866
|
+
await sleep(Math.min(2500, budgetMs / 4));
|
|
6867
|
+
try {
|
|
6868
|
+
const inspectedSession = await cdp.attachToTarget(options.inspectedTargetId);
|
|
6869
|
+
await cdp.sendCommand("Page.reload", {}, inspectedSession);
|
|
6870
|
+
} catch {}
|
|
6871
|
+
}
|
|
6872
|
+
const evaluate = async (expression)=>await cdp.sendCommand("Runtime.evaluate", {
|
|
6873
|
+
expression,
|
|
6874
|
+
returnByValue: true,
|
|
6875
|
+
awaitPromise: true
|
|
6876
|
+
}, sessionId).catch(()=>null);
|
|
6877
|
+
const wantedId = "string" == typeof options.panelTitle && options.panelTitle ? `${panelIdPrefix(options.extensionId)}${options.panelTitle}` : null;
|
|
6878
|
+
const prefix = panelIdPrefix(options.extensionId);
|
|
6879
|
+
let panels = [];
|
|
6880
|
+
let panelId = null;
|
|
6881
|
+
let nudged = false;
|
|
6882
|
+
const nudgeAt = Date.now() + Math.min(4000, budgetMs / 2);
|
|
6883
|
+
while(!panelId && Date.now() < deadline){
|
|
6884
|
+
const response = await evaluate(PANEL_IDS_EXPRESSION);
|
|
6885
|
+
const ids = response?.result?.value;
|
|
6886
|
+
if (Array.isArray(ids)) {
|
|
6887
|
+
panels = ids.map(String);
|
|
6888
|
+
panelId = panels.find((id)=>wantedId ? id === wantedId : id.startsWith(prefix)) ?? null;
|
|
6889
|
+
}
|
|
6890
|
+
if (panelId) break;
|
|
6891
|
+
if (!nudged && Date.now() >= nudgeAt) {
|
|
6892
|
+
nudged = true;
|
|
6893
|
+
const frame = (await listTargets().catch(()=>[])).find((t)=>"iframe" === t.type && String(t.url ?? "").startsWith(options.devtoolsPageUrl));
|
|
6894
|
+
if (frame) try {
|
|
6895
|
+
const frameSession = await cdp.attachToTarget(frame.id);
|
|
6896
|
+
await cdp.sendCommand("Runtime.evaluate", {
|
|
6897
|
+
expression: "1",
|
|
6898
|
+
returnByValue: true
|
|
6899
|
+
}, frameSession);
|
|
6900
|
+
} catch {}
|
|
6901
|
+
}
|
|
6902
|
+
await sleep(250);
|
|
6903
|
+
}
|
|
6904
|
+
if (!panelId) return {
|
|
6905
|
+
opened: false,
|
|
6906
|
+
stage: "panel",
|
|
6907
|
+
devtoolsTargetId,
|
|
6908
|
+
panels,
|
|
6909
|
+
reason: wantedId ? `no panel with id ${wantedId} registered within ${budgetMs}ms` : `no panel from ${prefix} registered within ${budgetMs}ms`
|
|
6910
|
+
};
|
|
6911
|
+
const framesBefore = new Set((await listTargets().catch(()=>[])).filter((t)=>"iframe" === t.type && String(t.url ?? "").startsWith(`${prefix}/`)).map((t)=>t.id));
|
|
6912
|
+
const shown = await evaluate(showPanelExpression(panelId));
|
|
6913
|
+
if (!shown || shown.exceptionDetails) return {
|
|
6914
|
+
opened: false,
|
|
6915
|
+
stage: "show",
|
|
6916
|
+
devtoolsTargetId,
|
|
6917
|
+
panels,
|
|
6918
|
+
reason: `showPanel(${panelId}) ${shown?.exceptionDetails ? `threw: ${shown.exceptionDetails.exception?.description ?? shown.exceptionDetails.text ?? "unknown"}` : "did not answer"}`
|
|
6919
|
+
};
|
|
6920
|
+
let panelTarget = null;
|
|
6921
|
+
const showDeadline = Date.now() + 3000;
|
|
6922
|
+
while(!panelTarget && Date.now() < showDeadline){
|
|
6923
|
+
const frames = (await listTargets().catch(()=>[])).filter((t)=>"iframe" === t.type && String(t.url ?? "").startsWith(`${prefix}/`) && !String(t.url ?? "").startsWith(options.devtoolsPageUrl));
|
|
6924
|
+
const fresh = frames.find((t)=>!framesBefore.has(t.id)) ?? frames[0];
|
|
6925
|
+
if (fresh) panelTarget = {
|
|
6926
|
+
targetId: fresh.id,
|
|
6927
|
+
url: String(fresh.url)
|
|
6928
|
+
};
|
|
6929
|
+
else await sleep(200);
|
|
6930
|
+
}
|
|
6931
|
+
return {
|
|
6932
|
+
opened: true,
|
|
6933
|
+
devtoolsTargetId,
|
|
6934
|
+
panelId,
|
|
6935
|
+
panelTitle: panelTitleFromId(panelId, options.extensionId),
|
|
6936
|
+
panelTarget,
|
|
6937
|
+
panels
|
|
6938
|
+
};
|
|
6939
|
+
} catch (error) {
|
|
6940
|
+
return {
|
|
6941
|
+
opened: false,
|
|
6942
|
+
stage: "open",
|
|
6943
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
6944
|
+
};
|
|
6945
|
+
} finally{
|
|
6946
|
+
try {
|
|
6947
|
+
cdp.disconnect();
|
|
6948
|
+
} catch {}
|
|
6949
|
+
}
|
|
6950
|
+
}
|
|
6040
6951
|
const OVERRIDE_SURFACES = [
|
|
6041
6952
|
"newtab",
|
|
6042
6953
|
"history",
|
|
@@ -6070,9 +6981,37 @@ async function pollForTarget(port, url, budgetMs, navigatedTargetId) {
|
|
|
6070
6981
|
await new Promise((r)=>setTimeout(r, 250));
|
|
6071
6982
|
}
|
|
6072
6983
|
}
|
|
6073
|
-
function
|
|
6984
|
+
async function landedOnErrorPage(port, targetId) {
|
|
6985
|
+
await new Promise((r)=>setTimeout(r, 400));
|
|
6986
|
+
try {
|
|
6987
|
+
const target = (await CDPClient.discoverTargets(port)).find((t)=>String(t.id) === targetId);
|
|
6988
|
+
const url = String(target?.url ?? "");
|
|
6989
|
+
if (/^(chrome|edge)-error:\/\//.test(url)) return {
|
|
6990
|
+
url,
|
|
6991
|
+
title: "string" == typeof target?.title ? target.title : void 0
|
|
6992
|
+
};
|
|
6993
|
+
} catch {}
|
|
6994
|
+
return null;
|
|
6995
|
+
}
|
|
6996
|
+
async function browserRunsHeadless(projectPath, browser) {
|
|
6997
|
+
if (!isChromiumFamily(browser)) return false;
|
|
6998
|
+
try {
|
|
6999
|
+
const resolved = await resolveCdpPort(projectPath, browser, {
|
|
7000
|
+
waitMs: 2000
|
|
7001
|
+
});
|
|
7002
|
+
if (!resolved) return false;
|
|
7003
|
+
const product = await CDPClient.discoverBrowserVersion(resolved.port);
|
|
7004
|
+
if ("string" == typeof product && /headless/i.test(product)) return true;
|
|
7005
|
+
const agent = "function" == typeof CDPClient.discoverUserAgent ? await CDPClient.discoverUserAgent(resolved.port) : null;
|
|
7006
|
+
return "string" == typeof agent && /headless/i.test(agent);
|
|
7007
|
+
} catch {
|
|
7008
|
+
return false;
|
|
7009
|
+
}
|
|
7010
|
+
}
|
|
7011
|
+
function isDisposableTab(tabUrl, destination, reuse) {
|
|
6074
7012
|
if (!tabUrl || "about:blank" === tabUrl) return true;
|
|
6075
7013
|
if (/^chrome:\/\/(newtab|new-tab-page)/.test(tabUrl)) return true;
|
|
7014
|
+
if ("blank" === reuse) return false;
|
|
6076
7015
|
const origin = destination.match(/^chrome-extension:\/\/[a-p]{32}\//)?.[0];
|
|
6077
7016
|
return Boolean(origin && tabUrl.startsWith(origin));
|
|
6078
7017
|
}
|
|
@@ -6106,9 +7045,15 @@ async function navigateToUrlViaWebDriver(projectPath, browser, url) {
|
|
|
6106
7045
|
});
|
|
6107
7046
|
}
|
|
6108
7047
|
}
|
|
6109
|
-
async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
7048
|
+
async function navigateToUrl(projectPath, browser, url, timeout, options = {}) {
|
|
7049
|
+
const reuse = options.reuse ?? "blank";
|
|
7050
|
+
if (null != options.tab) return navigateToUrlViaBridge(projectPath, browser, url, timeout, open_schema.name, {
|
|
7051
|
+
tab: options.tab
|
|
7052
|
+
});
|
|
6110
7053
|
if (WEBKIT_FAMILY.has(browser) && readWebDriverSession(projectPath, browser)) return navigateToUrlViaWebDriver(projectPath, browser, url);
|
|
6111
|
-
if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout
|
|
7054
|
+
if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout, open_schema.name, {
|
|
7055
|
+
newTab: true
|
|
7056
|
+
});
|
|
6112
7057
|
const resolved = await resolveCdpPort(projectPath, browser);
|
|
6113
7058
|
if (!resolved) return envelope_envelope({
|
|
6114
7059
|
ok: false,
|
|
@@ -6126,7 +7071,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
|
6126
7071
|
const pageTargets = targets.filter((t)=>"page" === t.type && !String(t.url || "").startsWith("devtools://"));
|
|
6127
7072
|
const browserWsUrl = await CDPClient.discoverBrowserWsUrl(resolved.port);
|
|
6128
7073
|
await cdp.connect(browserWsUrl);
|
|
6129
|
-
const reusable = pageTargets.find((t)=>isDisposableTab(String(t.url ?? ""), url));
|
|
7074
|
+
const reusable = pageTargets.find((t)=>isDisposableTab(String(t.url ?? ""), url, reuse));
|
|
6130
7075
|
let navigatedTargetId;
|
|
6131
7076
|
let openedNewTab = false;
|
|
6132
7077
|
if (reusable) {
|
|
@@ -6144,6 +7089,26 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
|
6144
7089
|
openedNewTab = true;
|
|
6145
7090
|
}
|
|
6146
7091
|
const settled = await pollForTarget(resolved.port, url, 6000, navigatedTargetId);
|
|
7092
|
+
const blocked = settled ? await landedOnErrorPage(resolved.port, settled.id) : null;
|
|
7093
|
+
if (blocked) return envelope_envelope({
|
|
7094
|
+
ok: false,
|
|
7095
|
+
command: open_schema.name,
|
|
7096
|
+
status: "navigate-blocked",
|
|
7097
|
+
error: {
|
|
7098
|
+
code: "E_NAVIGATE_FAILED",
|
|
7099
|
+
name: "PageBlocked",
|
|
7100
|
+
message: `The browser refused ${url} and shows its own error page instead${blocked.title ? ` ("${blocked.title}")` : ""}.`
|
|
7101
|
+
},
|
|
7102
|
+
value: {
|
|
7103
|
+
navigated: url,
|
|
7104
|
+
target: {
|
|
7105
|
+
targetId: settled.id,
|
|
7106
|
+
url: blocked.url,
|
|
7107
|
+
title: blocked.title
|
|
7108
|
+
}
|
|
7109
|
+
},
|
|
7110
|
+
hint: "Nothing is rendering the requested document. Read the tab title for the browser's reason (Edge, for one, blocks some extension pages with ERR_BLOCKED_BY_CLIENT); try another browser with extension_dev, or a different document of the same extension."
|
|
7111
|
+
});
|
|
6147
7112
|
if (!settled) {
|
|
6148
7113
|
const isExtensionPage = url.startsWith("chrome-extension://");
|
|
6149
7114
|
return envelope_envelope({
|
|
@@ -6251,7 +7216,7 @@ function declaredCommands(projectPath, browser) {
|
|
|
6251
7216
|
}
|
|
6252
7217
|
function readDistPath(projectPath, browser) {
|
|
6253
7218
|
try {
|
|
6254
|
-
const file =
|
|
7219
|
+
const file = session_paths_readyContractPath(projectPath, browser);
|
|
6255
7220
|
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
6256
7221
|
return "string" == typeof contract?.distPath ? contract.distPath : null;
|
|
6257
7222
|
} catch {
|
|
@@ -6267,7 +7232,7 @@ function surfaceDocument(projectPath, browser, surface) {
|
|
|
6267
7232
|
continue;
|
|
6268
7233
|
}
|
|
6269
7234
|
const action = manifest.action ?? manifest.browser_action;
|
|
6270
|
-
const ref = "popup" === surface || "action" === surface ? action?.default_popup : "options" === surface ? manifest.options_ui?.page ?? manifest.options_page : "sidebar" === surface ? manifest.side_panel?.default_path ?? manifest.sidebar_action?.default_panel : "newtab" === surface || "history" === surface || "bookmarks" === surface ? manifest.chrome_url_overrides?.[surface] : null;
|
|
7235
|
+
const ref = "popup" === surface || "action" === surface ? action?.default_popup : "options" === surface ? manifest.options_ui?.page ?? manifest.options_page : "sidebar" === surface ? manifest.side_panel?.default_path ?? manifest.sidebar_action?.default_panel : "devtools" === surface ? manifest.devtools_page : "newtab" === surface || "history" === surface || "bookmarks" === surface ? manifest.chrome_url_overrides?.[surface] : null;
|
|
6271
7236
|
if ("string" == typeof ref && ref) return ref.replace(/^\.?\//, "");
|
|
6272
7237
|
}
|
|
6273
7238
|
return null;
|
|
@@ -6276,6 +7241,7 @@ const SURFACE_MANIFEST_KEYS = {
|
|
|
6276
7241
|
popup: "action.default_popup",
|
|
6277
7242
|
options: "options_ui.page (or options_page)",
|
|
6278
7243
|
sidebar: "side_panel.default_path (or sidebar_action.default_panel)",
|
|
7244
|
+
devtools: "devtools_page",
|
|
6279
7245
|
newtab: "chrome_url_overrides.newtab",
|
|
6280
7246
|
history: "chrome_url_overrides.history",
|
|
6281
7247
|
bookmarks: "chrome_url_overrides.bookmarks"
|
|
@@ -6407,14 +7373,16 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
|
6407
7373
|
error: {
|
|
6408
7374
|
code: "E_NO_EXTENSION_ID",
|
|
6409
7375
|
name: "NoExtensionId",
|
|
6410
|
-
message: "Could not resolve the extension's moz-extension:// base URL
|
|
7376
|
+
message: "Could not resolve the extension's moz-extension:// base URL: the background eval of runtime.getURL did not answer (an extension CSP that forbids eval refuses it) and the profile's extensions.webextensions.uuids pref had no entry for the add-on id the built manifest declares."
|
|
6411
7377
|
},
|
|
6412
|
-
hint: "Confirm the session is ready (extension_wait) and was started with allowEval: true (extension_dev)."
|
|
7378
|
+
hint: "Confirm the session is ready (extension_wait) and was started with allowEval: true (extension_dev); the profile route needs browser_specific_settings.gecko.id in the built manifest and profilePath in the session contract."
|
|
6413
7379
|
});
|
|
6414
7380
|
url = `${base}${doc}`;
|
|
6415
7381
|
extensionId = base.replace(/^.*:\/\//, "").replace(/\/$/, "");
|
|
6416
7382
|
}
|
|
6417
|
-
const raw = await navigateToUrl(projectPath, browser, url
|
|
7383
|
+
const raw = await navigateToUrl(projectPath, browser, url, void 0, {
|
|
7384
|
+
reuse: "surface"
|
|
7385
|
+
});
|
|
6418
7386
|
try {
|
|
6419
7387
|
const parsed = JSON.parse(raw);
|
|
6420
7388
|
if (parsed?.ok) {
|
|
@@ -6481,7 +7449,7 @@ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
|
|
|
6481
7449
|
}
|
|
6482
7450
|
const open_schema = {
|
|
6483
7451
|
name: "extension_open",
|
|
6484
|
-
description: "Open an extension surface, or replay an event, in a running session. Pass surface:'popup', 'options' or 'sidebar' to open a UI surface, or 'newtab', 'history' or 'bookmarks' to open the matching chrome_url_overrides page in a tab (always a tab, resolved by the server, never sent to the engine). On Chromium, when Chrome refuses the sidebar for lack of a user gesture, the server opens the real panel through a synthetic click on the extension's own page and says so in warnings; if that fails too it renders the sidebar document as a tab. Pass surface:'action' to trigger the toolbar action, which opens its popup or replays chrome.action.onClicked when there is none. Pass surface:'command' with `name` to replay a chrome.commands.onCommand shortcut. Note that action and command replay invoke your listener without a user gesture, so the gesture-derived activeTab grant does not apply; the result reports gesture:false and warns when activeTab is declared. Start the session with allowControl:true (extension_dev).",
|
|
7452
|
+
description: "Open an extension surface, or replay an event, in a running session. Pass surface:'popup', 'options' or 'sidebar' to open a UI surface, or 'newtab', 'history' or 'bookmarks' to open the matching chrome_url_overrides page in a tab (always a tab, resolved by the server, never sent to the engine). On Chromium, when Chrome refuses the sidebar for lack of a user gesture, the server opens the real panel through a synthetic click on the extension's own page and says so in warnings; if that fails too it renders the sidebar document as a tab. Pass surface:'devtools' to open the browser's DevTools on a tab (the one `url` matches, else the first web page) and show the extension's panel there, picked by `panel` title when there are several: Chromium only, over CDP Target.openDevTools, headed or headless; the result names the panel document's url, which extension_eval reads with context 'page' and that url (the panel is no tab, so the tab-based readers do not reach it). Pass surface:'action' to trigger the toolbar action, which opens its popup or replays chrome.action.onClicked when there is none. Pass surface:'command' with `name` to replay a chrome.commands.onCommand shortcut. Note that action and command replay invoke your listener without a user gesture, so the gesture-derived activeTab grant does not apply; the result reports gesture:false and warns when activeTab is declared. Start the session with allowControl:true (extension_dev).",
|
|
6485
7453
|
inputSchema: {
|
|
6486
7454
|
type: "object",
|
|
6487
7455
|
properties: {
|
|
@@ -6492,6 +7460,7 @@ const open_schema = {
|
|
|
6492
7460
|
"popup",
|
|
6493
7461
|
"options",
|
|
6494
7462
|
"sidebar",
|
|
7463
|
+
"devtools",
|
|
6495
7464
|
"newtab",
|
|
6496
7465
|
"history",
|
|
6497
7466
|
"bookmarks",
|
|
@@ -6504,9 +7473,26 @@ const open_schema = {
|
|
|
6504
7473
|
type: "string",
|
|
6505
7474
|
description: "For surface 'command': the chrome.commands name to trigger."
|
|
6506
7475
|
},
|
|
7476
|
+
panel: {
|
|
7477
|
+
type: "string",
|
|
7478
|
+
description: "For surface 'devtools': the title the extension gave chrome.devtools.panels.create, when it registers more than one panel. Omitted, the extension's first panel is shown."
|
|
7479
|
+
},
|
|
7480
|
+
waitMs: {
|
|
7481
|
+
type: "number",
|
|
7482
|
+
description: "For surface 'devtools': how long to wait for the panel to register after DevTools opens (default 15000, up to 120000). Extensions that create their panel on a page event need longer, or `reload`."
|
|
7483
|
+
},
|
|
7484
|
+
reload: {
|
|
7485
|
+
type: "boolean",
|
|
7486
|
+
default: false,
|
|
7487
|
+
description: "For surface 'devtools': reload the inspected tab once DevTools is open, for extensions that create their panel only when the page reports to them on a load that starts with DevTools open (Preact Devtools). Discards the page state under test."
|
|
7488
|
+
},
|
|
6507
7489
|
url: {
|
|
6508
7490
|
type: "string",
|
|
6509
|
-
description: "Navigate a real tab here instead of opening a surface (
|
|
7491
|
+
description: "Navigate a real tab here instead of opening a surface, in a NEW tab unless `tab` names one (a blank or new-tab page is reused). An absolute url opens as given; a path with no scheme, such as pages/options.html, is resolved against the extension's own origin. Use for content-script test pages, or a surface as a page."
|
|
7492
|
+
},
|
|
7493
|
+
tab: {
|
|
7494
|
+
type: "number",
|
|
7495
|
+
description: "With `url`: navigate this chrome.tabs id in place instead of opening a new tab (rides the engine's navigate verb, so the session needs allowControl: true). Without it an existing page is never taken over."
|
|
6510
7496
|
},
|
|
6511
7497
|
asTab: {
|
|
6512
7498
|
type: "boolean",
|
|
@@ -6526,9 +7512,51 @@ function sessionIsHeadless() {
|
|
|
6526
7512
|
return /(^|\s|=)-{1,2}headless\b/i.test(process.env.EXTENSION_BROWSER_FLAGS ?? "");
|
|
6527
7513
|
}
|
|
6528
7514
|
const HEADED_RELAUNCH = "start a headed session: extension_dev with replace: true, and in the environment set EXTENSION_HEADLESS=0 AND clear EXTENSION_BROWSER_FLAGS (it may carry --headless=new, which keeps the window hidden even with EXTENSION_HEADLESS=0)";
|
|
7515
|
+
async function resolveExtensionDocumentUrl(projectPath, browser, relative) {
|
|
7516
|
+
const doc = relative.replace(/^\.?\//, "");
|
|
7517
|
+
if (isChromiumFamily(browser)) {
|
|
7518
|
+
const extensionId = await resolveExtensionId(projectPath, browser);
|
|
7519
|
+
if (extensionId) return `chrome-extension://${extensionId}/${doc}`;
|
|
7520
|
+
return {
|
|
7521
|
+
refusal: envelope_envelope({
|
|
7522
|
+
ok: false,
|
|
7523
|
+
command: open_schema.name,
|
|
7524
|
+
status: "no-extension-id",
|
|
7525
|
+
error: {
|
|
7526
|
+
code: "E_NO_EXTENSION_ID",
|
|
7527
|
+
name: "NoExtensionId",
|
|
7528
|
+
message: `"${relative}" has no scheme, so it was read as a document inside the extension, but the extension id could not be resolved from the live session's CDP targets.`
|
|
7529
|
+
},
|
|
7530
|
+
hint: `Confirm the session is ready (extension_wait), or pass the full chrome-extension://<id>/${doc} url. ${CDP_PORT_MISSING_HINT}`
|
|
7531
|
+
})
|
|
7532
|
+
};
|
|
7533
|
+
}
|
|
7534
|
+
const base = await resolveBridgeBaseUrl(projectPath, browser);
|
|
7535
|
+
if (base) return `${base}${doc}`;
|
|
7536
|
+
return {
|
|
7537
|
+
refusal: envelope_envelope({
|
|
7538
|
+
ok: false,
|
|
7539
|
+
command: open_schema.name,
|
|
7540
|
+
status: "no-extension-id",
|
|
7541
|
+
error: {
|
|
7542
|
+
code: "E_NO_EXTENSION_ID",
|
|
7543
|
+
name: "NoExtensionId",
|
|
7544
|
+
message: `"${relative}" has no scheme, so it was read as a document inside the extension, but the extension's moz-extension:// base could not be resolved from the live session.`
|
|
7545
|
+
},
|
|
7546
|
+
hint: `Pass the full moz-extension://<uuid>/${doc} url (extension_list_extensions reports the uuid), or start the session with allowEval: true so the base can be read from the background.`
|
|
7547
|
+
})
|
|
7548
|
+
};
|
|
7549
|
+
}
|
|
6529
7550
|
async function open_handler(args) {
|
|
6530
7551
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
6531
|
-
if (args.
|
|
7552
|
+
if ("devtools" === args.surface) return openDevToolsSurface(args, browser);
|
|
7553
|
+
if (args.url) {
|
|
7554
|
+
const absolute = /^[a-z][a-z0-9+.-]*:/i.test(args.url) ? args.url : await resolveExtensionDocumentUrl(args.projectPath, browser, args.url);
|
|
7555
|
+
if ("string" != typeof absolute) return absolute.refusal;
|
|
7556
|
+
return navigateToUrl(args.projectPath, browser, absolute, args.timeout, {
|
|
7557
|
+
tab: args.tab
|
|
7558
|
+
});
|
|
7559
|
+
}
|
|
6532
7560
|
const AS_TAB_SURFACES = [
|
|
6533
7561
|
"popup",
|
|
6534
7562
|
"options",
|
|
@@ -6568,6 +7596,20 @@ async function open_handler(args) {
|
|
|
6568
7596
|
const declared = declaredSurfaces(args.projectPath, browser);
|
|
6569
7597
|
if (declared && !declared.includes("popup")) return missingSurfaceError(args.projectPath, browser, "popup", "so there is no popup to open");
|
|
6570
7598
|
}
|
|
7599
|
+
if ([
|
|
7600
|
+
"popup",
|
|
7601
|
+
"options",
|
|
7602
|
+
"sidebar"
|
|
7603
|
+
].includes(args.surface) && !sessionIsHeadless() && await browserRunsHeadless(args.projectPath, browser)) {
|
|
7604
|
+
const asTab = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
7605
|
+
try {
|
|
7606
|
+
const parsedTab = JSON.parse(asTab);
|
|
7607
|
+
if (parsedTab?.ok) {
|
|
7608
|
+
addWarning(parsedTab, `The dev browser reports itself headless, and a headless browser closes a popup window before the next call and never shows an options or sidebar window, so the ${args.surface} was rendered as a tab instead (the same document and APIs). For the real window, ${HEADED_RELAUNCH}.`);
|
|
7609
|
+
return actFrameJson(parsedTab);
|
|
7610
|
+
}
|
|
7611
|
+
} catch {}
|
|
7612
|
+
}
|
|
6571
7613
|
const cli = [
|
|
6572
7614
|
"open",
|
|
6573
7615
|
args.surface,
|
|
@@ -6577,36 +7619,201 @@ async function open_handler(args) {
|
|
|
6577
7619
|
cli.push("--browser", browser);
|
|
6578
7620
|
if (null != args.timeout) cli.push("--timeout", String(args.timeout));
|
|
6579
7621
|
const raw = await runActVerb(cli, args.projectPath, args.timeout, open_schema.name);
|
|
6580
|
-
const
|
|
6581
|
-
if (
|
|
7622
|
+
const refusal = readWindowRefusal(raw, args.surface, browser);
|
|
7623
|
+
if (refusal) {
|
|
7624
|
+
if ("sidebar" === args.surface && isChromiumFamily(browser) && "gesture" === refusal.kind && !sessionIsHeadless() && !await browserRunsHeadless(args.projectPath, browser)) return openSidebarThroughGesture(args.projectPath, browser, refusal.frame);
|
|
7625
|
+
if ("sidebar" === args.surface && isGeckoFamily(browser)) return openGeckoSidebar(args.projectPath, browser, refusal.frame, args.timeout);
|
|
7626
|
+
const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
7627
|
+
const parsedFallback = tryParseEnvelope(fallback);
|
|
7628
|
+
if (parsedFallback?.ok) {
|
|
7629
|
+
addWarning(parsedFallback, windowRefusalWarning(refusal, args.surface, browser));
|
|
7630
|
+
return actFrameJson(parsedFallback);
|
|
7631
|
+
}
|
|
7632
|
+
addWarning(refusal.frame, `The browser refused the ${args.surface} window and the tab route failed too: ${String(parsedFallback?.error?.message ?? "no answer")}${"string" == typeof parsedFallback?.hint ? ` ${parsedFallback.hint}` : ""}`);
|
|
7633
|
+
if (!refusal.frame.hint) refusal.frame.hint = "gesture" === refusal.kind ? "This surface can only open from a real user gesture, which automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : `The dev browser has no window for this surface. Retry with asTab: true to render the surface document in a tab, or for the real window, ${HEADED_RELAUNCH}.`;
|
|
7634
|
+
return actFrameJson(refusal.frame);
|
|
7635
|
+
}
|
|
7636
|
+
if (!AS_TAB_SURFACES.includes(args.surface)) return raw;
|
|
7637
|
+
const confirmed = await confirmSurfaceTarget(args.projectPath, browser, args.surface, raw);
|
|
7638
|
+
const parsedConfirmed = tryParseEnvelope(confirmed);
|
|
7639
|
+
if (parsedConfirmed?.status !== "surface-did-not-open") return confirmed;
|
|
7640
|
+
const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
7641
|
+
const parsedFallback = tryParseEnvelope(fallback);
|
|
7642
|
+
if (parsedFallback?.ok) {
|
|
7643
|
+
addWarning(parsedFallback, `The engine reported the ${args.surface} as opened, but no document for it appeared within 3s (a headless browser never shows an options or popup window), so the ${args.surface} document was rendered in a tab instead (the same document and APIs). For the real window, ${HEADED_RELAUNCH}.`);
|
|
7644
|
+
patchValue(parsedFallback, {
|
|
7645
|
+
engineResult: parsedConfirmed.value?.engineResult ?? null
|
|
7646
|
+
});
|
|
7647
|
+
return actFrameJson(parsedFallback);
|
|
7648
|
+
}
|
|
7649
|
+
return confirmed;
|
|
7650
|
+
}
|
|
7651
|
+
function tryParseEnvelope(raw) {
|
|
7652
|
+
try {
|
|
7653
|
+
return JSON.parse(raw);
|
|
7654
|
+
} catch {
|
|
7655
|
+
return null;
|
|
7656
|
+
}
|
|
7657
|
+
}
|
|
7658
|
+
function readWindowRefusal(raw, surface, browser) {
|
|
7659
|
+
if (![
|
|
6582
7660
|
"popup",
|
|
6583
7661
|
"action",
|
|
6584
|
-
"sidebar"
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
7662
|
+
"sidebar",
|
|
7663
|
+
"options"
|
|
7664
|
+
].includes(surface)) return null;
|
|
7665
|
+
const gesture = readGestureRefusal(raw);
|
|
7666
|
+
if (gesture) return {
|
|
7667
|
+
kind: "gesture",
|
|
7668
|
+
frame: gesture
|
|
7669
|
+
};
|
|
7670
|
+
const parsed = tryParseEnvelope(raw);
|
|
7671
|
+
if (!parsed || false !== parsed.ok) return null;
|
|
7672
|
+
const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
|
|
7673
|
+
const message = String(parsed.error?.message ?? "");
|
|
7674
|
+
if ("E_TARGET_NOT_FOUND" === code || /active browser window|no active|headless/i.test(message)) return {
|
|
7675
|
+
kind: "no-window",
|
|
7676
|
+
frame: parsed
|
|
7677
|
+
};
|
|
7678
|
+
if (isGeckoFamily(browser) && "options" !== surface) {
|
|
7679
|
+
const unsupported = readUnsupportedRefusal(raw);
|
|
7680
|
+
if (unsupported || /popup is disabled|not implemented/i.test(message)) return {
|
|
7681
|
+
kind: "unsupported",
|
|
7682
|
+
frame: parsed
|
|
7683
|
+
};
|
|
7684
|
+
}
|
|
7685
|
+
return null;
|
|
7686
|
+
}
|
|
7687
|
+
function windowRefusalWarning(refusal, surface, browser) {
|
|
7688
|
+
const noun = "sidebar" === surface ? "sidebar" : "options" === surface ? "options page" : "popup";
|
|
7689
|
+
const said = String(refusal.frame.error?.message ?? "").replace(/\s+/g, " ").trim();
|
|
7690
|
+
if (isGeckoFamily(browser)) return "gesture" === refusal.kind ? `${browser} opens the ${noun} only from its toolbar; the engine refused with Chromium's gesture wording because it counts every non-Firefox name as Chromium (extension.js ledger 642). The ${noun} document was rendered in a tab instead: the same document and APIs, without the toolbar anchoring.` : `${browser} cannot open the ${noun} programmatically (${said}), so the ${noun} document was rendered in a tab instead: the same document and APIs, without the toolbar anchoring.`;
|
|
7691
|
+
return "gesture" === refusal.kind ? `Chromium opens the ${noun} only from a real user gesture, which automation cannot produce, so the ${noun} document was rendered in a tab instead (the same document and APIs). For the real window, ${HEADED_RELAUNCH} and click the toolbar icon.` : `The dev browser has no window to show the ${noun} in (${said}); a headless browser never shows one, so the ${noun} document was rendered in a tab instead (the same document and APIs). For the real window, ${HEADED_RELAUNCH}.`;
|
|
7692
|
+
}
|
|
7693
|
+
const DEVTOOLS_PANEL_BUDGET_MS = 15000;
|
|
7694
|
+
const DEVTOOLS_PANEL_BUDGET_MAX_MS = 120000;
|
|
7695
|
+
async function openDevToolsSurface(args, browser) {
|
|
7696
|
+
if (!isChromiumFamily(browser)) return envelope_envelope({
|
|
7697
|
+
ok: false,
|
|
7698
|
+
command: open_schema.name,
|
|
7699
|
+
status: "unsupported",
|
|
7700
|
+
error: {
|
|
7701
|
+
code: "E_UNSUPPORTED_BROWSER",
|
|
7702
|
+
name: "NoDevToolsAutomation",
|
|
7703
|
+
message: `${browser} exposes no protocol command that opens its developer tools or an extension panel, so this server cannot open the devtools surface there; Target.openDevTools is Chromium's.`
|
|
7704
|
+
},
|
|
7705
|
+
hint: WEBKIT_FAMILY.has(browser) ? "Open the Web Inspector by hand in the headed Safari window and switch to the panel; extension_logs shows what the devtools page writes." : "Open the developer tools by hand in the headed Firefox window (F12) and switch to the panel, or run the same project on chrome to drive the panel from here; extension_logs (context: ['devtools']) shows what the devtools page writes either way."
|
|
7706
|
+
});
|
|
7707
|
+
const doc = surfaceDocument(args.projectPath, browser, "devtools");
|
|
7708
|
+
if (!doc) return missingSurfaceError(args.projectPath, browser, "devtools", "so there is no DevTools page to register a panel");
|
|
7709
|
+
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
7710
|
+
if (!resolved) return envelope_envelope({
|
|
7711
|
+
ok: false,
|
|
7712
|
+
command: open_schema.name,
|
|
7713
|
+
status: "no-session",
|
|
7714
|
+
error: {
|
|
7715
|
+
code: "E_NO_SESSION",
|
|
7716
|
+
name: "NoSession",
|
|
7717
|
+
message: `No active dev session / CDP port for ${browser}. Start extension_dev and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
|
|
6603
7718
|
}
|
|
6604
|
-
}
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
7719
|
+
});
|
|
7720
|
+
const extensionId = await resolveExtensionId(args.projectPath, browser);
|
|
7721
|
+
if (!extensionId) return envelope_envelope({
|
|
7722
|
+
ok: false,
|
|
7723
|
+
command: open_schema.name,
|
|
7724
|
+
status: "no-extension-id",
|
|
7725
|
+
error: {
|
|
7726
|
+
code: "E_NO_EXTENSION_ID",
|
|
7727
|
+
name: "NoExtensionId",
|
|
7728
|
+
message: "Could not resolve the extension id from the live session's CDP targets."
|
|
7729
|
+
},
|
|
7730
|
+
hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
|
|
7731
|
+
});
|
|
7732
|
+
const pages = await listPageTargets(resolved.port).catch(()=>[]);
|
|
7733
|
+
const candidates = args.url ? matchTargetsByUrl(pages, args.url) : pages.filter((t)=>/^https?:/i.test(t.url) || !t.url.startsWith("chrome-extension://") && !t.url.startsWith("chrome://") && "about:blank" !== t.url);
|
|
7734
|
+
const inspected = candidates[0] ?? (args.url ? void 0 : pages[0]);
|
|
7735
|
+
if (!inspected) return envelope_envelope({
|
|
7736
|
+
ok: false,
|
|
7737
|
+
command: open_schema.name,
|
|
7738
|
+
status: "no-target",
|
|
7739
|
+
error: {
|
|
7740
|
+
code: "E_NO_TARGET",
|
|
7741
|
+
name: "NoTarget",
|
|
7742
|
+
message: args.url ? `No open tab matches ${args.url}, so there is nothing to open DevTools on.` : "No open tab to inspect: the session has no page target."
|
|
7743
|
+
},
|
|
7744
|
+
hint: "Open the page first with extension_open (url: the address), then open the devtools surface with the same url. extension_dom_snapshot with listTargets: true lists what is open."
|
|
7745
|
+
});
|
|
7746
|
+
const devtoolsPageUrl = `chrome-extension://${extensionId}/${doc}`;
|
|
7747
|
+
const budgetMs = "number" == typeof args.waitMs && args.waitMs > 0 ? Math.min(args.waitMs, DEVTOOLS_PANEL_BUDGET_MAX_MS) : "number" == typeof args.timeout && args.timeout > 0 ? Math.min(args.timeout, DEVTOOLS_PANEL_BUDGET_MS) : DEVTOOLS_PANEL_BUDGET_MS;
|
|
7748
|
+
const outcome = await openDevToolsPanel(resolved.port, {
|
|
7749
|
+
inspectedTargetId: inspected.targetId,
|
|
7750
|
+
extensionId,
|
|
7751
|
+
devtoolsPageUrl,
|
|
7752
|
+
panelTitle: args.panel,
|
|
7753
|
+
budgetMs,
|
|
7754
|
+
reloadInspected: true === args.reload
|
|
7755
|
+
});
|
|
7756
|
+
if (!outcome.opened) {
|
|
7757
|
+
if ("open" === outcome.stage || "frontend" === outcome.stage) return envelope_envelope({
|
|
7758
|
+
ok: false,
|
|
7759
|
+
command: open_schema.name,
|
|
7760
|
+
status: "cdp-failed",
|
|
7761
|
+
error: {
|
|
7762
|
+
code: "E_CDP",
|
|
7763
|
+
name: "CdpError",
|
|
7764
|
+
message: `Target.openDevTools on ${inspected.url} did not open a DevTools frontend: ${outcome.reason}.`
|
|
7765
|
+
},
|
|
7766
|
+
hint: "This browser build's protocol lacks or refused Target.openDevTools (Chrome 151 and Edge 154 honor it, headed or headless). Open DevTools by hand in a headed session, or run the project on a current Chrome; extension_logs (context: ['devtools']) reads the devtools page either way."
|
|
7767
|
+
});
|
|
7768
|
+
const registered = (outcome.panels ?? []).filter((id)=>id.startsWith(`chrome-extension://${extensionId}`));
|
|
7769
|
+
return envelope_envelope({
|
|
7770
|
+
ok: false,
|
|
7771
|
+
command: open_schema.name,
|
|
7772
|
+
status: "surface-did-not-open",
|
|
7773
|
+
error: {
|
|
7774
|
+
code: "E_SURFACE_DID_NOT_OPEN",
|
|
7775
|
+
name: "PanelDidNotRegister",
|
|
7776
|
+
message: "panel" === outcome.stage ? `DevTools opened on ${inspected.url} and ${doc} loaded in it, but ${outcome.reason}.${registered.length ? ` Panels this extension did register: ${registered.map((id)=>id.slice(`chrome-extension://${extensionId}`.length)).join(", ")}.` : ""}` : `DevTools opened on ${inspected.url} and the panel registered, but ${outcome.reason}.`
|
|
7777
|
+
},
|
|
7778
|
+
value: {
|
|
7779
|
+
inspected: {
|
|
7780
|
+
targetId: inspected.targetId,
|
|
7781
|
+
url: inspected.url
|
|
7782
|
+
},
|
|
7783
|
+
devtoolsTargetId: outcome.devtoolsTargetId ?? null,
|
|
7784
|
+
panels: outcome.panels ?? []
|
|
7785
|
+
},
|
|
7786
|
+
hint: args.panel && registered.length ? `No panel is titled "${args.panel}"; pass one of the registered titles as \`panel\`, or omit it for the first.` : `The devtools page registers panels with chrome.devtools.panels.create; extension_logs (context: ['devtools']) shows what ${doc} wrote or threw. An extension that creates its panel only when the page reports to it (Preact Devtools does, through its content script) needs the page loaded with DevTools already open: retry with reload: true, and waitMs for a longer wait. DevTools stays open on the tab.`
|
|
7787
|
+
});
|
|
6608
7788
|
}
|
|
6609
|
-
|
|
7789
|
+
const panelUrl = outcome.panelTarget?.url ?? null;
|
|
7790
|
+
return envelope_envelope({
|
|
7791
|
+
ok: true,
|
|
7792
|
+
command: open_schema.name,
|
|
7793
|
+
status: "opened",
|
|
7794
|
+
value: {
|
|
7795
|
+
surface: "devtools",
|
|
7796
|
+
inspected: {
|
|
7797
|
+
targetId: inspected.targetId,
|
|
7798
|
+
url: inspected.url,
|
|
7799
|
+
title: inspected.title
|
|
7800
|
+
},
|
|
7801
|
+
devtoolsTargetId: outcome.devtoolsTargetId,
|
|
7802
|
+
panel: {
|
|
7803
|
+
title: outcome.panelTitle,
|
|
7804
|
+
id: outcome.panelId,
|
|
7805
|
+
targetId: outcome.panelTarget?.targetId ?? null,
|
|
7806
|
+
url: panelUrl
|
|
7807
|
+
},
|
|
7808
|
+
panels: outcome.panels,
|
|
7809
|
+
devtoolsPage: devtoolsPageUrl,
|
|
7810
|
+
reloadedInspected: true === args.reload
|
|
7811
|
+
},
|
|
7812
|
+
warnings: outcome.panelTarget ? [] : [
|
|
7813
|
+
"The panel is shown but its document target had not appeared within 3s; call extension_open surface: \"devtools\" again once it loads to get its url."
|
|
7814
|
+
],
|
|
7815
|
+
hint: panelUrl ? `DevTools is open on ${inspected.url} with the "${outcome.panelTitle}" panel shown. Read the panel with extension_eval context: "page", url: "${panelUrl}" (over CDP, with chrome.devtools available); context: "devtools" reads the devtools page itself (${doc}), the hidden document that registered the panel. The panel is an iframe inside DevTools, not a tab, so extension_dom_snapshot and extension_inspect do not reach it.` : `DevTools is open on ${inspected.url} with the "${outcome.panelTitle}" panel shown. context: "devtools" on extension_eval reads the devtools page itself (${doc}).`
|
|
7816
|
+
});
|
|
6610
7817
|
}
|
|
6611
7818
|
const E_USER_GESTURE_REQUIRED = "E_USER_GESTURE_REQUIRED";
|
|
6612
7819
|
function readGestureRefusal(raw) {
|
|
@@ -6621,6 +7828,73 @@ function readGestureRefusal(raw) {
|
|
|
6621
7828
|
const message = String(parsed.error?.message ?? "");
|
|
6622
7829
|
return code === E_USER_GESTURE_REQUIRED || /user gesture/i.test(message) ? parsed : null;
|
|
6623
7830
|
}
|
|
7831
|
+
function readUnsupportedRefusal(raw) {
|
|
7832
|
+
let parsed;
|
|
7833
|
+
try {
|
|
7834
|
+
parsed = JSON.parse(raw);
|
|
7835
|
+
} catch {
|
|
7836
|
+
return null;
|
|
7837
|
+
}
|
|
7838
|
+
if (parsed?.ok !== false) return null;
|
|
7839
|
+
const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
|
|
7840
|
+
const message = String(parsed.error?.message ?? "");
|
|
7841
|
+
return "E_NOT_IMPLEMENTED" === code || /not available/i.test(message) ? parsed : null;
|
|
7842
|
+
}
|
|
7843
|
+
const GECKO_SIDEBAR_GESTURE = "Firefox opens a sidebar_action panel only from a user gesture (the toolbar button or View > Sidebar; Bugzilla 1392624), and the engine's open verb carries none, so it cannot open the panel";
|
|
7844
|
+
async function openGeckoSidebar(projectPath, browser, refusal, timeout) {
|
|
7845
|
+
const doc = surfaceDocument(projectPath, browser, "sidebar");
|
|
7846
|
+
if (!doc) return missingSurfaceError(projectPath, browser, "sidebar", "so there is no sidebar panel to open");
|
|
7847
|
+
const probe = await runActVerb([
|
|
7848
|
+
"inspect",
|
|
7849
|
+
projectPath,
|
|
7850
|
+
"--context",
|
|
7851
|
+
"sidebar",
|
|
7852
|
+
"--include",
|
|
7853
|
+
"summary",
|
|
7854
|
+
"--browser",
|
|
7855
|
+
browser,
|
|
7856
|
+
...null != timeout ? [
|
|
7857
|
+
"--timeout",
|
|
7858
|
+
String(timeout)
|
|
7859
|
+
] : []
|
|
7860
|
+
], projectPath, timeout, open_schema.name);
|
|
7861
|
+
let open = null;
|
|
7862
|
+
try {
|
|
7863
|
+
const parsed = JSON.parse(probe);
|
|
7864
|
+
if (parsed?.ok === true) open = parsed;
|
|
7865
|
+
} catch {}
|
|
7866
|
+
if (open) {
|
|
7867
|
+
const url = "string" == typeof open.value?.url ? open.value.url : "string" == typeof open.value?.meta?.url ? open.value.meta.url : void 0;
|
|
7868
|
+
return envelope_envelope({
|
|
7869
|
+
ok: true,
|
|
7870
|
+
command: open_schema.name,
|
|
7871
|
+
status: "already-open",
|
|
7872
|
+
value: {
|
|
7873
|
+
surface: "sidebar",
|
|
7874
|
+
document: doc,
|
|
7875
|
+
alreadyOpen: true,
|
|
7876
|
+
...url ? {
|
|
7877
|
+
url
|
|
7878
|
+
} : {}
|
|
7879
|
+
},
|
|
7880
|
+
hint: `The sidebar panel is open in the ${browser} window already: read it with extension_dom_snapshot context: 'sidebar' or run code in it with extension_eval context: 'sidebar'. ${GECKO_SIDEBAR_GESTURE}, and it did not need to.`
|
|
7881
|
+
});
|
|
7882
|
+
}
|
|
7883
|
+
const fallback = await openSurfaceAsTab(projectPath, browser, "sidebar");
|
|
7884
|
+
try {
|
|
7885
|
+
const parsedFallback = JSON.parse(fallback);
|
|
7886
|
+
if (parsedFallback?.ok) {
|
|
7887
|
+
addWarning(parsedFallback, `${GECKO_SIDEBAR_GESTURE}, and the panel is not open now, so the sidebar document was rendered as a tab instead. The DOM is the same document the panel would show; the panel hosting stays unverified. A person opens the real panel from the toolbar button or View > Sidebar.`);
|
|
7888
|
+
return actFrameJson(parsedFallback);
|
|
7889
|
+
}
|
|
7890
|
+
} catch {}
|
|
7891
|
+
refusal.error = {
|
|
7892
|
+
...refusal.error ?? {},
|
|
7893
|
+
message: `${GECKO_SIDEBAR_GESTURE}. Rendering the document ${doc} as a tab failed as well.`
|
|
7894
|
+
};
|
|
7895
|
+
refusal.hint = "Start the session with allowEval: true so the document can be opened by url through the bridge, or open the panel from the toolbar button in the dev browser and read it with extension_dom_snapshot context: 'sidebar'.";
|
|
7896
|
+
return actFrameJson(refusal);
|
|
7897
|
+
}
|
|
6624
7898
|
const SIDEBAR_GESTURE_WARNING = "Chrome opens the side panel only from a user gesture and the engine's open verb carries none, so the server opened the extension's own sidebar page in a tab, dispatched a synthetic click on it over CDP and called chrome.sidePanel.open from inside that click, then closed the tab. The panel that opened is the real one; the toolbar wiring (action.onClicked or sidePanel.setPanelBehavior) was not exercised.";
|
|
6625
7899
|
async function openSidebarThroughGesture(projectPath, browser, refusal) {
|
|
6626
7900
|
const doc = surfaceDocument(projectPath, browser, "sidebar");
|
|
@@ -6660,7 +7934,14 @@ async function openSidebarThroughGesture(projectPath, browser, refusal) {
|
|
|
6660
7934
|
refusal.hint = `Chrome opens the side panel only from a user gesture, which the engine's open verb cannot carry, and the server's synthetic click did not open it either (${reason}). ` + (doc && extensionId ? `To read the panel page anyway, call extension_open with url: "chrome-extension://${extensionId}/${doc}" and then extension_dom_snapshot context: 'sidebar' on it. ` : "To read the panel page anyway, open its document by url with extension_open and read it with extension_dom_snapshot context: 'sidebar'. ") + "Opening the real panel needs a toolbar click from a person in the dev browser.";
|
|
6661
7935
|
return actFrameJson(refusal);
|
|
6662
7936
|
}
|
|
6663
|
-
function resolveToken() {
|
|
7937
|
+
function resolveToken(options = {}) {
|
|
7938
|
+
const project = String(options.project ?? "").trim();
|
|
7939
|
+
if (project) {
|
|
7940
|
+
const creds = readValidCredentials(void 0, {
|
|
7941
|
+
project
|
|
7942
|
+
});
|
|
7943
|
+
return creds?.token ? String(creds.token).trim() : "";
|
|
7944
|
+
}
|
|
6664
7945
|
const fromEnv = String(process.env.EXTENSION_DEV_TOKEN || "").trim();
|
|
6665
7946
|
if (fromEnv) return fromEnv;
|
|
6666
7947
|
const creds = readValidCredentials();
|
|
@@ -7425,11 +8706,16 @@ function previewOriginOf(previewUrl) {
|
|
|
7425
8706
|
return null;
|
|
7426
8707
|
}
|
|
7427
8708
|
}
|
|
7428
|
-
async function buildShare(projectPath, distDir, manifest, browser, verifyInBrowserTerms) {
|
|
8709
|
+
async function buildShare(projectPath, distDir, manifest, browser, verifyInBrowserTerms, project) {
|
|
7429
8710
|
const result = await uploadPreview({
|
|
7430
8711
|
distDir,
|
|
7431
8712
|
manifest,
|
|
7432
|
-
browser
|
|
8713
|
+
browser,
|
|
8714
|
+
...project ? {
|
|
8715
|
+
token: resolveToken({
|
|
8716
|
+
project
|
|
8717
|
+
})
|
|
8718
|
+
} : {}
|
|
7433
8719
|
});
|
|
7434
8720
|
if (!result.ok) {
|
|
7435
8721
|
const isAuth = "PreviewAuthError" === result.error.name;
|
|
@@ -7538,6 +8824,7 @@ const preview_web_schema = {
|
|
|
7538
8824
|
type: "object",
|
|
7539
8825
|
properties: {
|
|
7540
8826
|
projectPath: PROJECT_PATH,
|
|
8827
|
+
project: PROJECT_TOKEN_INPUT,
|
|
7541
8828
|
browser: {
|
|
7542
8829
|
type: "string",
|
|
7543
8830
|
enum: REAL_BROWSERS,
|
|
@@ -7705,7 +8992,7 @@ async function preview_web_handler(args) {
|
|
|
7705
8992
|
if (true !== opened.ok) previewWarnings.push("Could not open the preview in a browser. This needs a live dev session (run extension_dev, then extension_wait for ready). The deepLink above still works if you open it yourself.");
|
|
7706
8993
|
}
|
|
7707
8994
|
if (args.share) {
|
|
7708
|
-
const share = await buildShare(args.projectPath, distDir, manifest, browser, false !== args.probe);
|
|
8995
|
+
const share = await buildShare(args.projectPath, distDir, manifest, browser, false !== args.probe, args.project);
|
|
7709
8996
|
result.share = share;
|
|
7710
8997
|
if (true === share.ok && false === share.browserLoadable) {
|
|
7711
8998
|
const check = share.browserCheck;
|
|
@@ -8102,6 +9389,7 @@ const shares_schema = {
|
|
|
8102
9389
|
inputSchema: {
|
|
8103
9390
|
type: "object",
|
|
8104
9391
|
properties: {
|
|
9392
|
+
project: PROJECT_TOKEN_INPUT,
|
|
8105
9393
|
action: {
|
|
8106
9394
|
type: "string",
|
|
8107
9395
|
enum: [
|
|
@@ -8235,6 +9523,11 @@ async function listShares(args) {
|
|
|
8235
9523
|
} : {},
|
|
8236
9524
|
...args.api ? {
|
|
8237
9525
|
api: args.api
|
|
9526
|
+
} : {},
|
|
9527
|
+
...args.project ? {
|
|
9528
|
+
token: resolveToken({
|
|
9529
|
+
project: args.project
|
|
9530
|
+
})
|
|
8238
9531
|
} : {}
|
|
8239
9532
|
});
|
|
8240
9533
|
if (!listing.ok) {
|
|
@@ -8370,7 +9663,9 @@ async function revokeShare(args) {
|
|
|
8370
9663
|
message: supplied ? `The reference is malformed and nothing was sent to the platform: no share id could be read out of ${JSON.stringify(supplied.length > 200 ? `${supplied.slice(0, 200)}...` : supplied)}. A share id is gen_ followed by exactly 64 lowercase hex characters (32 on shares minted before 2026-07-30), and a url revokes only when it contains one whole. Check the ref for a copy-paste cut, or run action:"list" and copy the exact artifactId.` : 'Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action:"list" to see both.'
|
|
8371
9664
|
}
|
|
8372
9665
|
});
|
|
8373
|
-
const token = resolveToken(
|
|
9666
|
+
const token = resolveToken({
|
|
9667
|
+
project: args.project
|
|
9668
|
+
});
|
|
8374
9669
|
if (token) {
|
|
8375
9670
|
const gate = await evaluateApproval({
|
|
8376
9671
|
command: "extension_shares",
|
|
@@ -8481,6 +9776,9 @@ async function shares_handler(args) {
|
|
|
8481
9776
|
} : {},
|
|
8482
9777
|
...args.api ? {
|
|
8483
9778
|
api: args.api
|
|
9779
|
+
} : {},
|
|
9780
|
+
...args.project ? {
|
|
9781
|
+
project: args.project
|
|
8484
9782
|
} : {}
|
|
8485
9783
|
});
|
|
8486
9784
|
return listShares({
|
|
@@ -8495,6 +9793,9 @@ async function shares_handler(args) {
|
|
|
8495
9793
|
} : {},
|
|
8496
9794
|
...args.api ? {
|
|
8497
9795
|
api: args.api
|
|
9796
|
+
} : {},
|
|
9797
|
+
...args.project ? {
|
|
9798
|
+
project: args.project
|
|
8498
9799
|
} : {}
|
|
8499
9800
|
});
|
|
8500
9801
|
}
|
|
@@ -8819,7 +10120,7 @@ function makeFilter(args) {
|
|
|
8819
10120
|
}
|
|
8820
10121
|
function readContractForDiagnosis(projectPath, browser) {
|
|
8821
10122
|
try {
|
|
8822
|
-
const raw = node_fs.readFileSync(
|
|
10123
|
+
const raw = node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8");
|
|
8823
10124
|
return JSON.parse(raw);
|
|
8824
10125
|
} catch {
|
|
8825
10126
|
return null;
|
|
@@ -8828,13 +10129,13 @@ function readContractForDiagnosis(projectPath, browser) {
|
|
|
8828
10129
|
function sightedContractBrowser(projectPath) {
|
|
8829
10130
|
let dirs;
|
|
8830
10131
|
try {
|
|
8831
|
-
dirs = node_fs.readdirSync(
|
|
10132
|
+
dirs = node_fs.readdirSync(session_paths_sessionArtifactsRootDir(projectPath));
|
|
8832
10133
|
} catch {
|
|
8833
10134
|
return null;
|
|
8834
10135
|
}
|
|
8835
10136
|
let best = null;
|
|
8836
10137
|
for (const dir of dirs)try {
|
|
8837
|
-
const stat = node_fs.statSync(
|
|
10138
|
+
const stat = node_fs.statSync(session_paths_readyContractPath(projectPath, dir));
|
|
8838
10139
|
if (!best || stat.mtimeMs > best.mtimeMs) best = {
|
|
8839
10140
|
browser: dir,
|
|
8840
10141
|
mtimeMs: stat.mtimeMs
|
|
@@ -8949,6 +10250,25 @@ function capabilityProbeChecks(parsed) {
|
|
|
8949
10250
|
return value?.checks;
|
|
8950
10251
|
}
|
|
8951
10252
|
const CONTROL_OFF_BY_CHOICE = /\bwas not started with --allow-control\b/i;
|
|
10253
|
+
function pidIsAlive(pid) {
|
|
10254
|
+
try {
|
|
10255
|
+
process.kill(pid, 0);
|
|
10256
|
+
return true;
|
|
10257
|
+
} catch {
|
|
10258
|
+
return false;
|
|
10259
|
+
}
|
|
10260
|
+
}
|
|
10261
|
+
function reconcileRelaunchedBrowser(checks, contract) {
|
|
10262
|
+
const exitedLeg = checks.find((leg)=>"browser" === leg.check && "fail" === leg.status && /\bexited\b/i.test(String(leg.detail ?? "")));
|
|
10263
|
+
if (!exitedLeg) return false;
|
|
10264
|
+
const executorAnswered = checks.some((leg)=>("executor" === leg.check || "control-channel" === leg.check) && "pass" === leg.status);
|
|
10265
|
+
const browserAlive = "number" == typeof contract?.browserPid && pidIsAlive(contract.browserPid);
|
|
10266
|
+
if (!executorAnswered && !browserAlive) return false;
|
|
10267
|
+
exitedLeg.status = "warn";
|
|
10268
|
+
exitedLeg.detail = `${exitedLeg.detail ?? "browser exited"}. ${browserAlive ? `The browser pid the launcher recorded (${contract?.browserPid}) is alive` : "The executor answered a probe after that exit"}, so the session is live and the recorded exit was an earlier process: Firefox hands a fresh profile to a relaunched process and the first one exits 0.`;
|
|
10269
|
+
exitedLeg.remediation = "Nothing to do. If a later call finds the session unreachable, extension_stop and extension_dev again.";
|
|
10270
|
+
return true;
|
|
10271
|
+
}
|
|
8952
10272
|
function controlOffByChoiceLeg(checks) {
|
|
8953
10273
|
return checks.find((leg)=>"control-channel" === leg.check && "fail" === leg.status && "string" == typeof leg.detail && CONTROL_OFF_BY_CHOICE.test(leg.detail)) ?? null;
|
|
8954
10274
|
}
|
|
@@ -8991,17 +10311,9 @@ async function doctor_handler(args) {
|
|
|
8991
10311
|
}
|
|
8992
10312
|
let healthy = 0 === code;
|
|
8993
10313
|
const contract = readContractForDiagnosis(projectPath, browser);
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
|
|
8998
|
-
checks.push({
|
|
8999
|
-
check: "runtime-errors",
|
|
9000
|
-
status: "fail",
|
|
9001
|
-
detail: toMcpSpeak(detail),
|
|
9002
|
-
remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
|
|
9003
|
-
});
|
|
9004
|
-
} else {
|
|
10314
|
+
const relaunched = reconcileRelaunchedBrowser(checks, contract);
|
|
10315
|
+
if (relaunched) healthy = !checks.some((leg)=>"fail" === leg.status);
|
|
10316
|
+
if (contract?.status !== "error" || relaunched) {
|
|
9005
10317
|
const errs = recentErrorLogs(projectPath, browser);
|
|
9006
10318
|
if (errs.length) {
|
|
9007
10319
|
healthy = false;
|
|
@@ -9012,6 +10324,16 @@ async function doctor_handler(args) {
|
|
|
9012
10324
|
remediation: "The extension is throwing at runtime. Inspect with extension_logs. A chrome.* API called without its permission is a common cause: extension_manifest_validate catches a permission MISSING FROM permissions[], but it does not model host-permission scope (e.g. webRequest with no matching host_permissions) or gesture requirements (e.g. activeTab without a user gesture), so a valid:true there does not rule those out."
|
|
9013
10325
|
});
|
|
9014
10326
|
}
|
|
10327
|
+
} else {
|
|
10328
|
+
healthy = false;
|
|
10329
|
+
const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
|
|
10330
|
+
const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
|
|
10331
|
+
checks.push({
|
|
10332
|
+
check: "runtime-errors",
|
|
10333
|
+
status: "fail",
|
|
10334
|
+
detail: toMcpSpeak(detail),
|
|
10335
|
+
remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
|
|
10336
|
+
});
|
|
9015
10337
|
}
|
|
9016
10338
|
const engineVersion = projectEngineVersion(projectPath);
|
|
9017
10339
|
if (engineVersion) {
|
|
@@ -9187,9 +10509,9 @@ function capRecent(events, limit) {
|
|
|
9187
10509
|
function emptyReason(projectPath, browser) {
|
|
9188
10510
|
let contract;
|
|
9189
10511
|
try {
|
|
9190
|
-
contract = JSON.parse(node_fs.readFileSync(
|
|
10512
|
+
contract = JSON.parse(node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8"));
|
|
9191
10513
|
} catch {
|
|
9192
|
-
return `No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev. ${sessionPathHint(
|
|
10514
|
+
return `No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev. ${sessionPathHint(session_paths_readyContractPath(projectPath, browser))}`;
|
|
9193
10515
|
}
|
|
9194
10516
|
if ("error" === contract.status) {
|
|
9195
10517
|
const errs = contract.errors;
|
|
@@ -9232,7 +10554,7 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
|
|
|
9232
10554
|
function staleFileNote(projectPath, browser, eventsRunId) {
|
|
9233
10555
|
let contract;
|
|
9234
10556
|
try {
|
|
9235
|
-
contract = JSON.parse(node_fs.readFileSync(
|
|
10557
|
+
contract = JSON.parse(node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8"));
|
|
9236
10558
|
} catch {
|
|
9237
10559
|
return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
|
|
9238
10560
|
}
|
|
@@ -9250,7 +10572,7 @@ function staleFileNote(projectPath, browser, eventsRunId) {
|
|
|
9250
10572
|
function readLogRunId(projectPath, browser) {
|
|
9251
10573
|
let text;
|
|
9252
10574
|
try {
|
|
9253
|
-
text = node_fs.readFileSync(
|
|
10575
|
+
text = node_fs.readFileSync(session_paths_logsPath(projectPath, browser), "utf8");
|
|
9254
10576
|
} catch {
|
|
9255
10577
|
return "";
|
|
9256
10578
|
}
|
|
@@ -9268,7 +10590,7 @@ function readLogRunId(projectPath, browser) {
|
|
|
9268
10590
|
return runId;
|
|
9269
10591
|
}
|
|
9270
10592
|
async function readFromFile(args, browser, limit) {
|
|
9271
|
-
const file =
|
|
10593
|
+
const file = session_paths_logsPath(args.projectPath, browser);
|
|
9272
10594
|
if (!node_fs.existsSync(file)) return envelope_envelope({
|
|
9273
10595
|
ok: false,
|
|
9274
10596
|
command: TOOL,
|
|
@@ -9334,7 +10656,7 @@ function controlRefusal(closeCode, reason, url, sentVersion) {
|
|
|
9334
10656
|
};
|
|
9335
10657
|
}
|
|
9336
10658
|
async function readFromStream(args, browser, limit) {
|
|
9337
|
-
const ready = readReadyContract(args.projectPath, browser);
|
|
10659
|
+
const ready = readReadyContract(engineProjectRoot(args.projectPath), browser);
|
|
9338
10660
|
if (!ready) {
|
|
9339
10661
|
const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
|
|
9340
10662
|
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
|
|
@@ -9455,13 +10777,13 @@ async function readFromStream(args, browser, limit) {
|
|
|
9455
10777
|
async function logs_handler(args) {
|
|
9456
10778
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
9457
10779
|
const limit = args.limit && args.limit > 0 ? args.limit : DEFAULT_LIMIT;
|
|
9458
|
-
if (WEBKIT_FAMILY.has(browser) && !node_fs.existsSync(
|
|
10780
|
+
if (WEBKIT_FAMILY.has(browser) && !node_fs.existsSync(session_paths_logsPath(args.projectPath, browser))) return envelope_envelope({
|
|
9459
10781
|
ok: false,
|
|
9460
10782
|
command: TOOL,
|
|
9461
10783
|
status: "no-log-file",
|
|
9462
10784
|
error: {
|
|
9463
10785
|
code: "E_LOGS_MISSING",
|
|
9464
|
-
message: `No logs found at ${
|
|
10786
|
+
message: `No logs found at ${session_paths_logsPath(args.projectPath, browser)}, and ${browser} has no CDP or RDP to read instead.`
|
|
9465
10787
|
},
|
|
9466
10788
|
hint: "Safari logs arrive through the extension's bridge to the dev server, with background and content contexts, once the extension is enabled and a dev session from an Extension.js that streams Safari logs (4.1.28 or newer) is running. Start extension_dev --browser=safari, enable the extension in Safari > Settings > Extensions, then read again. The page a content script changes is also readable with extension_eval (context: 'page') or extension_assert content-script-injected."
|
|
9467
10789
|
});
|
|
@@ -9736,7 +11058,7 @@ function truncate(value, max = 200) {
|
|
|
9736
11058
|
}
|
|
9737
11059
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
9738
11060
|
}
|
|
9739
|
-
const
|
|
11061
|
+
const assert_WORKER_TARGET_TYPES = new Set([
|
|
9740
11062
|
"service_worker",
|
|
9741
11063
|
"worker",
|
|
9742
11064
|
"background_page",
|
|
@@ -9750,7 +11072,14 @@ async function assertBackgroundWorker(clause, stage) {
|
|
|
9750
11072
|
if ("none" === background.kind) return failCheck(id, null, `${read.file} declares no background, so no worker can boot. Nothing about the session is implicated.`, {
|
|
9751
11073
|
manifestFile: read.file
|
|
9752
11074
|
});
|
|
9753
|
-
if (!stage.chromium)
|
|
11075
|
+
if (!stage.chromium) {
|
|
11076
|
+
const listed = await listBridgeTabs(stage.projectPath, stage.browser, stage.timeout, "extension_assert");
|
|
11077
|
+
if ("tabs" in listed) return passCheck(id, null, `The ${background.kind} answered a tabs query over the control channel; the bridge executor runs inside the background, so it has booted.`, {
|
|
11078
|
+
backgroundKind: background.kind,
|
|
11079
|
+
tabsSeen: listed.tabs.length
|
|
11080
|
+
});
|
|
11081
|
+
return inconclusiveCheck(id, null, `The control channel did not answer on ${stage.browser}, so the background could not be asked.`, "Start the session with allowControl: true (extension_dev) and extension_wait for ready, then assert again; extension_logs (context: ['background']) shows what the background wrote meanwhile.");
|
|
11082
|
+
}
|
|
9754
11083
|
const targets = await stage.targets();
|
|
9755
11084
|
if (null === targets) return stage.noSession(id, null);
|
|
9756
11085
|
const guest = await verifyGuestLoaded(stage.projectPath, stage.browser);
|
|
@@ -9758,7 +11087,7 @@ async function assertBackgroundWorker(clause, stage) {
|
|
|
9758
11087
|
if (!guest.loaded) return failCheck(id, null, `The extension is not loaded in the running browser, so its ${background.kind} never started. ${guest.reason}`, {
|
|
9759
11088
|
guestIds: guest.guestIds
|
|
9760
11089
|
});
|
|
9761
|
-
const workers = targets.filter((target)=>
|
|
11090
|
+
const workers = targets.filter((target)=>assert_WORKER_TARGET_TYPES.has(target.type) && guest.guestIds.some((guestId)=>String(target.url ?? "").startsWith(`chrome-extension://${guestId}/`)));
|
|
9762
11091
|
if (workers.length > 0) return passCheck(id, null, `The browser lists ${workers.length} live ${background.kind} target for this extension (${workers.map((worker)=>worker.url).join(", ")}).`, {
|
|
9763
11092
|
targets: workers.map((worker)=>({
|
|
9764
11093
|
type: worker.type,
|
|
@@ -9795,7 +11124,47 @@ async function assertSurfaceRendered(clause, stage) {
|
|
|
9795
11124
|
if (!document) return failCheck(id, subject, `This extension declares no ${clause.surface}: nothing sets ${SURFACE_MANIFEST_KEYS[clause.surface] ?? clause.surface} in its manifest, so there is no document to render. Surfaces it does declare: ${declared.length ? declared.join(", ") : "none"}.`, {
|
|
9796
11125
|
declaredSurfaces: declared
|
|
9797
11126
|
});
|
|
9798
|
-
if (!stage.chromium)
|
|
11127
|
+
if (!stage.chromium) {
|
|
11128
|
+
const raw = await runActVerb([
|
|
11129
|
+
"inspect",
|
|
11130
|
+
stage.projectPath,
|
|
11131
|
+
"--context",
|
|
11132
|
+
clause.surface,
|
|
11133
|
+
"--include",
|
|
11134
|
+
"summary",
|
|
11135
|
+
"--browser",
|
|
11136
|
+
stage.browser,
|
|
11137
|
+
...null != stage.timeout ? [
|
|
11138
|
+
"--timeout",
|
|
11139
|
+
String(stage.timeout)
|
|
11140
|
+
] : []
|
|
11141
|
+
], stage.projectPath, stage.timeout, "extension_assert");
|
|
11142
|
+
let parsed = null;
|
|
11143
|
+
try {
|
|
11144
|
+
parsed = JSON.parse(raw);
|
|
11145
|
+
} catch {}
|
|
11146
|
+
if (parsed?.ok === true) {
|
|
11147
|
+
const summary = parsed.value?.summary ?? {};
|
|
11148
|
+
const children = Number(summary.bodyChildCount ?? 0);
|
|
11149
|
+
if (clause.selector) return inconclusiveCheck(id, subject, `The ${clause.surface} document is open (${parsed.value?.url ?? document}), but a selector cannot be probed through the surface relay on ${stage.browser}.`, `Read it with extension_inspect (url: the document's moz-extension:// address, probe: ['${clause.selector}']) or extension_eval context: '${clause.surface}'.`, {
|
|
11150
|
+
url: parsed.value?.url,
|
|
11151
|
+
summary
|
|
11152
|
+
});
|
|
11153
|
+
if (null != clause.minNodes && children < clause.minNodes) return failCheck(id, subject, `The ${clause.surface} document is open but its body has ${children} child element${1 === children ? "" : "s"}, fewer than the ${clause.minNodes} expected.`, {
|
|
11154
|
+
url: parsed.value?.url,
|
|
11155
|
+
summary
|
|
11156
|
+
});
|
|
11157
|
+
return passCheck(id, subject, `The ${clause.surface} document (${document}) is open and rendering: the surface relay answered with ${children} body child element${1 === children ? "" : "s"}.`, {
|
|
11158
|
+
url: parsed.value?.url,
|
|
11159
|
+
summary
|
|
11160
|
+
});
|
|
11161
|
+
}
|
|
11162
|
+
const message = String(parsed?.error?.message ?? raw);
|
|
11163
|
+
if (/not open|E_TARGET_NOT_FOUND|not found/i.test(message) || parsed?.error?.code === "E_TARGET_NOT_FOUND") return failCheck(id, subject, `The ${clause.surface} is declared (${document}) but nothing is rendering it: the surface relay has no open document to answer from. Open it with extension_open (surface: '${clause.surface}') before asserting.`, {
|
|
11164
|
+
document
|
|
11165
|
+
});
|
|
11166
|
+
return inconclusiveCheck(id, subject, `The surface relay could not be asked on ${stage.browser}: ${message.slice(0, 200)}`, "Start the session with allowControl: true (extension_dev) and extension_wait for ready, then assert again.");
|
|
11167
|
+
}
|
|
9799
11168
|
const targets = await stage.targets();
|
|
9800
11169
|
if (null === targets) return stage.noSession(id, subject);
|
|
9801
11170
|
const extensionId = await stage.extensionId();
|
|
@@ -10004,7 +11373,7 @@ async function assertStorageKeyPresent(clause, stage) {
|
|
|
10004
11373
|
function assertConsoleErrorsEmpty(clause, stage) {
|
|
10005
11374
|
const id = CONSOLE;
|
|
10006
11375
|
const subject = clause.subject;
|
|
10007
|
-
const file =
|
|
11376
|
+
const file = session_paths_logsPath(stage.projectPath, stage.browser);
|
|
10008
11377
|
const all = readLogEvents(stage.projectPath, stage.browser, {});
|
|
10009
11378
|
if (0 === all.length) {
|
|
10010
11379
|
const reason = emptyReason(stage.projectPath, stage.browser);
|
|
@@ -10104,6 +11473,17 @@ async function assert_handler(args) {
|
|
|
10104
11473
|
});
|
|
10105
11474
|
}
|
|
10106
11475
|
const manifest_validate_COMMAND = "extension_manifest_validate";
|
|
11476
|
+
const CHROMIUM_ONLY_KEYS = [
|
|
11477
|
+
"version_name",
|
|
11478
|
+
"minimum_chrome_version",
|
|
11479
|
+
"key",
|
|
11480
|
+
"update_url",
|
|
11481
|
+
"offline_enabled",
|
|
11482
|
+
"side_panel",
|
|
11483
|
+
"declarative_net_request",
|
|
11484
|
+
"cross_origin_embedder_policy",
|
|
11485
|
+
"cross_origin_opener_policy"
|
|
11486
|
+
];
|
|
10107
11487
|
const CHROME_DESKTOP_ONLY_KEYS = [
|
|
10108
11488
|
"file_browser_handlers",
|
|
10109
11489
|
"file_system_provider_capabilities",
|
|
@@ -10276,6 +11656,19 @@ function collectPathRefs(m) {
|
|
|
10276
11656
|
}
|
|
10277
11657
|
return refs;
|
|
10278
11658
|
}
|
|
11659
|
+
function collectWebAccessibleRefs(m) {
|
|
11660
|
+
const refs = [];
|
|
11661
|
+
const war = m.web_accessible_resources;
|
|
11662
|
+
if (!Array.isArray(war)) return refs;
|
|
11663
|
+
for (const entry of war)if ("string" == typeof entry) refs.push(entry);
|
|
11664
|
+
else if (entry && "object" == typeof entry) {
|
|
11665
|
+
const resources = entry.resources;
|
|
11666
|
+
if (Array.isArray(resources)) {
|
|
11667
|
+
for (const r of resources)if ("string" == typeof r) refs.push(r);
|
|
11668
|
+
}
|
|
11669
|
+
}
|
|
11670
|
+
return refs;
|
|
11671
|
+
}
|
|
10279
11672
|
function fileResolvesSomewhere(ref, roots) {
|
|
10280
11673
|
if (!ref || ref.includes("*") || /^(https?:|data:)/i.test(ref)) return true;
|
|
10281
11674
|
const clean = ref.replace(/^\.?\//, "");
|
|
@@ -10447,14 +11840,18 @@ async function manifest_validate_handler(args) {
|
|
|
10447
11840
|
if (!manifest.name) result.errors.push("Missing required field: name");
|
|
10448
11841
|
if (!manifest.version) result.warnings.push("Missing field: version (required for store submission)");
|
|
10449
11842
|
const chromiumManifest = filterKeysForThisBrowser(manifest, "chrome");
|
|
11843
|
+
const projectRoot = "src" === node_path.basename(manifestDir) ? node_path.dirname(manifestDir) : manifestDir;
|
|
10450
11844
|
const roots = [
|
|
10451
11845
|
manifestDir,
|
|
10452
11846
|
node_path.join(manifestDir, "src"),
|
|
10453
|
-
...
|
|
10454
|
-
|
|
10455
|
-
] : []
|
|
11847
|
+
...projectRoot !== manifestDir ? [
|
|
11848
|
+
projectRoot
|
|
11849
|
+
] : [],
|
|
11850
|
+
node_path.join(manifestDir, "public"),
|
|
11851
|
+
node_path.join(projectRoot, "public")
|
|
10456
11852
|
];
|
|
10457
11853
|
for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.errors.push(`Referenced file "${ref}" was not found near the manifest. extension_build fails on this dangling reference.`);
|
|
11854
|
+
for (const ref of new Set(collectWebAccessibleRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.warnings.push(`web_accessible_resources names "${ref}", which was not found near the manifest. The build ships without it and the browser answers 404 when the extension or a page asks for it.`);
|
|
10458
11855
|
const defaultLocale = manifest.default_locale;
|
|
10459
11856
|
if ("string" == typeof defaultLocale && defaultLocale) {
|
|
10460
11857
|
const hasCatalog = roots.some((root)=>node_fs.existsSync(node_path.resolve(root, "_locales", defaultLocale, "messages.json")));
|
|
@@ -10495,6 +11892,17 @@ async function manifest_validate_handler(args) {
|
|
|
10495
11892
|
const isFirefox = isGeckoFamily(browser);
|
|
10496
11893
|
const effective = effectiveByBrowser.get(browser) ?? filterKeysForThisBrowser(manifest, browser);
|
|
10497
11894
|
const issues = [];
|
|
11895
|
+
if (isFirefox && 2 === effective.manifest_version) {
|
|
11896
|
+
const porting = [];
|
|
11897
|
+
const bg2 = effective.background;
|
|
11898
|
+
if (bg2 && Array.isArray(bg2.scripts)) porting.push("background.scripts to a single chromium:service_worker");
|
|
11899
|
+
if (effective.browser_action) porting.push("browser_action to chromium:action");
|
|
11900
|
+
if (Array.isArray(effective.web_accessible_resources) && effective.web_accessible_resources.some((e)=>"string" == typeof e)) porting.push("web_accessible_resources strings to [{resources, matches}] objects");
|
|
11901
|
+
const perms2 = effective.permissions ?? [];
|
|
11902
|
+
if (perms2.some((p)=>"string" == typeof p && (p.includes("://") || "<all_urls>" === p))) porting.push("host patterns out of permissions into host_permissions");
|
|
11903
|
+
if (perms2.includes("webRequestBlocking")) porting.push("webRequestBlocking to declarativeNetRequest rules");
|
|
11904
|
+
if (porting.length) result.warnings.push(`${browser} stays on Manifest V2 here, and Firefox still runs it. To also target Chromium, which only loads MV3, the keys to port are: ${porting.join("; ")}. Keep both by prefixing the Chromium variants with chromium:.`);
|
|
11905
|
+
}
|
|
10498
11906
|
if (isChromium) {
|
|
10499
11907
|
const mv = effective.manifest_version;
|
|
10500
11908
|
if (mv && mv < 3) issues.push("Manifest V2 is deprecated on Chromium. Use chromium:manifest_version: 3.");
|
|
@@ -10513,7 +11921,15 @@ async function manifest_validate_handler(args) {
|
|
|
10513
11921
|
const note = 'content_scripts.world: "MAIN" needs Firefox 128 or later (earlier Firefox runs the script in the isolated world). If you depend on it, set browser_specific_settings.gecko.strict_min_version to "128.0".';
|
|
10514
11922
|
if (!result.warnings.includes(note)) result.warnings.push(note);
|
|
10515
11923
|
}
|
|
10516
|
-
if (chromiumManifest.side_panel && !effective.sidebar_action)
|
|
11924
|
+
if (chromiumManifest.side_panel && !effective.sidebar_action) result.warnings.push("Chromium side_panel declared but no firefox:sidebar_action: the Firefox build ships without a sidebar. If you want one there, Firefox uses sidebar_action.");
|
|
11925
|
+
const bss = effective.browser_specific_settings;
|
|
11926
|
+
const geckoId = bss?.gecko?.id;
|
|
11927
|
+
if ("string" != typeof geckoId || !geckoId) result.warnings.push('Firefox: no browser_specific_settings.gecko.id. A temporary add-on without one gets a new internal id on every launch, so storage and the moz-extension:// origin do not survive a relaunch, and a store upload needs the id. Set firefox:browser_specific_settings.gecko.id (any "name@domain" string).');
|
|
11928
|
+
const dataCollection = bss?.gecko?.data_collection_permissions;
|
|
11929
|
+
if (!dataCollection || "object" != typeof dataCollection) result.warnings.push('Firefox: no browser_specific_settings.gecko.data_collection_permissions. AMO requires it for new add-ons and the Firefox build warns on every run; the minimal form is firefox:browser_specific_settings.gecko.data_collection_permissions: {"required": ["none"]}.');
|
|
11930
|
+
for (const key of CHROMIUM_ONLY_KEYS)if (void 0 !== effective[key]) result.warnings.push(`Firefox: manifest key "${key}" is Chromium-only and is ignored or refused by Firefox. Move it under "chromium:${key}".`);
|
|
11931
|
+
const warEntries = effective.web_accessible_resources;
|
|
11932
|
+
if (Array.isArray(warEntries) && warEntries.some((entry)=>entry && "object" == typeof entry && void 0 !== entry.extension_ids)) result.warnings.push('Firefox: web_accessible_resources[].extension_ids is Chromium-only; Firefox accepts only "matches" there.');
|
|
10517
11933
|
const bg = effective.background;
|
|
10518
11934
|
if (bg) {
|
|
10519
11935
|
if (bg.service_worker && !bg.scripts) issues.push('Background service_worker declared but no firefox:scripts. Firefox uses "scripts" (array) instead of "service_worker".');
|
|
@@ -10528,7 +11944,7 @@ async function manifest_validate_handler(args) {
|
|
|
10528
11944
|
if (effectivePerms.has(perm)) continue;
|
|
10529
11945
|
if (!declaredPermSet.has(perm)) continue;
|
|
10530
11946
|
const ns = isFirefox ? "browser" : "chrome";
|
|
10531
|
-
issues.push(`Code calls ${ns}.${api} but the ${browser} build's permissions do not include "${perm}" (it is declared only under another target's prefixed key, e.g. chromium:permissions).
|
|
11947
|
+
issues.push(`Code calls ${ns}.${api} but the ${browser} build's permissions do not include "${perm}" (it is declared only under another target's prefixed key, e.g. chromium:permissions). An unguarded call crashes this target at runtime. If every call sits behind a feature check (typeof ${ns}.${api} !== "undefined"), the build is sound: pass skipValidation: true to extension_build, or grant "${perm}" to this target too.`);
|
|
10532
11948
|
}
|
|
10533
11949
|
result.browserSupport[browser] = {
|
|
10534
11950
|
supported: 0 === issues.length,
|
|
@@ -10538,19 +11954,28 @@ async function manifest_validate_handler(args) {
|
|
|
10538
11954
|
}
|
|
10539
11955
|
const surfaces = [];
|
|
10540
11956
|
if (chromiumManifest.content_scripts) surfaces.push("content");
|
|
10541
|
-
if (chromiumManifest.side_panel) surfaces.push("sidebar");
|
|
11957
|
+
if (chromiumManifest.side_panel || manifest["firefox:sidebar_action"]) surfaces.push("sidebar");
|
|
10542
11958
|
if (chromiumManifest.action || manifest["firefox:browser_action"]) surfaces.push("action");
|
|
10543
11959
|
if (chromiumManifest.chrome_url_overrides?.newtab) surfaces.push("newtab");
|
|
11960
|
+
if (chromiumManifest.devtools_page) surfaces.push("devtools");
|
|
11961
|
+
if (chromiumManifest.options_ui || chromiumManifest.options_page) surfaces.push("options");
|
|
10544
11962
|
if (chromiumManifest.background) surfaces.push("background");
|
|
10545
11963
|
const distinctive = surfaces.filter((s)=>"background" !== s);
|
|
10546
11964
|
const matchOn = distinctive.length ? distinctive : surfaces;
|
|
10547
11965
|
if (matchOn.length) try {
|
|
10548
11966
|
const templates = await listTemplates();
|
|
10549
|
-
result.similarTemplates = templates.map((t)=>
|
|
11967
|
+
result.similarTemplates = templates.map((t)=>{
|
|
11968
|
+
const shared = t.surfaces.filter((s)=>matchOn.includes(s)).length;
|
|
11969
|
+
const union = new Set([
|
|
11970
|
+
...t.surfaces,
|
|
11971
|
+
...matchOn
|
|
11972
|
+
]).size;
|
|
11973
|
+
return {
|
|
10550
11974
|
slug: t.slug,
|
|
10551
11975
|
surfaces: t.surfaces,
|
|
10552
|
-
score:
|
|
10553
|
-
}
|
|
11976
|
+
score: union ? shared / union : 0
|
|
11977
|
+
};
|
|
11978
|
+
}).filter((t)=>t.score > 0).sort((a, b)=>b.score - a.score).slice(0, 5).map((t)=>({
|
|
10554
11979
|
slug: t.slug,
|
|
10555
11980
|
surfaces: t.surfaces
|
|
10556
11981
|
}));
|
|
@@ -11966,7 +13391,34 @@ const inspect_schema_schema = {
|
|
|
11966
13391
|
"projectPath"
|
|
11967
13392
|
]
|
|
11968
13393
|
}
|
|
11969
|
-
};
|
|
13394
|
+
};
|
|
13395
|
+
const EXTENSION_PAGE_CONTEXTS = [
|
|
13396
|
+
"popup",
|
|
13397
|
+
"options",
|
|
13398
|
+
"sidebar",
|
|
13399
|
+
"devtools",
|
|
13400
|
+
"newtab",
|
|
13401
|
+
"history",
|
|
13402
|
+
"bookmarks"
|
|
13403
|
+
];
|
|
13404
|
+
const EXTENSION_ORIGIN = /^(moz|chrome|safari-web)-extension:\/\//;
|
|
13405
|
+
function isExtensionUrl(url) {
|
|
13406
|
+
return "string" == typeof url && EXTENSION_ORIGIN.test(url);
|
|
13407
|
+
}
|
|
13408
|
+
function surfaceForExtensionUrl(projectPath, browser, url) {
|
|
13409
|
+
const bare = url.replace(EXTENSION_ORIGIN, "").replace(/^[^/]*\//, (m)=>EXTENSION_ORIGIN.test(url) ? "" : m).replace(/^\.?\//, "").replace(/[?#].*$/, "");
|
|
13410
|
+
if (!bare) return null;
|
|
13411
|
+
for (const context of EXTENSION_PAGE_CONTEXTS){
|
|
13412
|
+
const document = surfaceDocument(projectPath, browser, context);
|
|
13413
|
+
if (document) {
|
|
13414
|
+
if (bare === document || bare.endsWith(`/${document}`) || document.endsWith(`/${bare}`)) return {
|
|
13415
|
+
context,
|
|
13416
|
+
document
|
|
13417
|
+
};
|
|
13418
|
+
}
|
|
13419
|
+
}
|
|
13420
|
+
return null;
|
|
13421
|
+
}
|
|
11970
13422
|
const inspect_gecko_TOOL = inspect_schema_schema.name;
|
|
11971
13423
|
function buildBridgeInspectExpression(opts) {
|
|
11972
13424
|
const parts = [
|
|
@@ -11992,14 +13444,7 @@ function buildBridgeInspectExpression(opts) {
|
|
|
11992
13444
|
} catch (e) {}`);
|
|
11993
13445
|
if (opts.domSnapshot) parts.push(`try { out.domSnapshot = ${domSnapshotScript(500)}; } catch (e) {}`);
|
|
11994
13446
|
if (opts.extensionRoots) parts.push(`try { out.extensionRoots = ${EXTENSION_ROOT_META_SCRIPT}; } catch (e) {}`);
|
|
11995
|
-
if (opts.probes.length) parts.push(`out.probes = {};
|
|
11996
|
-
for (const sel of ${JSON.stringify(opts.probes)}) {
|
|
11997
|
-
try {
|
|
11998
|
-
const nodes = document.querySelectorAll(sel);
|
|
11999
|
-
const first = nodes[0];
|
|
12000
|
-
out.probes[sel] = { count: nodes.length, sample: first ? String(first.outerHTML || "").slice(0, 200) : null };
|
|
12001
|
-
} catch (e) { out.probes[sel] = { error: String((e && e.message) || e) }; }
|
|
12002
|
-
}`);
|
|
13447
|
+
if (opts.probes.length) parts.push(`out.probes = ${probeSelectorsScript(opts.probes)};`);
|
|
12003
13448
|
parts.push("return out;");
|
|
12004
13449
|
return `(() => { ${parts.join("\n")} })()`;
|
|
12005
13450
|
}
|
|
@@ -12084,7 +13529,22 @@ async function collectGeckoConsole(args, browser, urlFilter, result, notes) {
|
|
|
12084
13529
|
}
|
|
12085
13530
|
async function inspectViaBridge(args, browser, include, maxBytes) {
|
|
12086
13531
|
const notes = [];
|
|
12087
|
-
|
|
13532
|
+
const surface = args.url ? surfaceForExtensionUrl(args.projectPath, browser, args.url) : null;
|
|
13533
|
+
if (args.url && !surface && EXTENSION_ORIGIN.test(args.url)) {
|
|
13534
|
+
const declared = declaredSurfaces(args.projectPath, browser) ?? [];
|
|
13535
|
+
return envelope_envelope({
|
|
13536
|
+
ok: false,
|
|
13537
|
+
command: inspect_gecko_TOOL,
|
|
13538
|
+
status: "no-surface",
|
|
13539
|
+
error: {
|
|
13540
|
+
code: "E_NO_SURFACE_DOCUMENT",
|
|
13541
|
+
name: "NoSurfaceDocument",
|
|
13542
|
+
message: `${args.url} is a page inside the extension, and on ${browser} a page inside the extension is read through its own surface relay, but that path matches none of the surface documents the manifest declares${declared.length ? ` (${declared.join(", ")})` : ""}. Script injection cannot reach an extension page at all.`
|
|
13543
|
+
},
|
|
13544
|
+
hint: declared.length ? `Pass the declared document path (extension_open surface: "${declared[0]}" opens one), or read it with extension_dom_snapshot context: "${declared[0]}".` : "Declare the page as a surface in the manifest (action.default_popup, options_ui.page, sidebar_action.default_panel or chrome_url_overrides) and rebuild."
|
|
13545
|
+
});
|
|
13546
|
+
}
|
|
13547
|
+
if (args.url && !surface) {
|
|
12088
13548
|
const listed = await listBridgeTabs(args.projectPath, browser, args.timeout, inspect_gecko_TOOL);
|
|
12089
13549
|
if ("error" in listed) return listed.error;
|
|
12090
13550
|
const already = listed.tabs.some((t)=>t.url.includes(args.url));
|
|
@@ -12111,8 +13571,8 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
|
|
|
12111
13571
|
expression,
|
|
12112
13572
|
args.projectPath,
|
|
12113
13573
|
"--context",
|
|
12114
|
-
"page",
|
|
12115
|
-
...args.url ? [
|
|
13574
|
+
surface ? surface.context : "page",
|
|
13575
|
+
...args.url && !surface ? [
|
|
12116
13576
|
"--url",
|
|
12117
13577
|
args.url
|
|
12118
13578
|
] : [],
|
|
@@ -12166,7 +13626,11 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
|
|
|
12166
13626
|
if (null === value) return raw;
|
|
12167
13627
|
const result = {
|
|
12168
13628
|
browser,
|
|
12169
|
-
transport: "bridge"
|
|
13629
|
+
transport: "bridge",
|
|
13630
|
+
...surface ? {
|
|
13631
|
+
surface: surface.context,
|
|
13632
|
+
document: surface.document
|
|
13633
|
+
} : {}
|
|
12170
13634
|
};
|
|
12171
13635
|
if (value.meta) {
|
|
12172
13636
|
result.target = {
|
|
@@ -12408,7 +13872,7 @@ function list_extensions_unpackedExtensionId(distPath) {
|
|
|
12408
13872
|
function readOwnIdentity(projectPath, browser) {
|
|
12409
13873
|
let contract;
|
|
12410
13874
|
try {
|
|
12411
|
-
contract = JSON.parse(node_fs.readFileSync(
|
|
13875
|
+
contract = JSON.parse(node_fs.readFileSync(session_paths_readyContractPath(projectPath, browser), "utf8"));
|
|
12412
13876
|
} catch {
|
|
12413
13877
|
return null;
|
|
12414
13878
|
}
|
|
@@ -12642,6 +14106,69 @@ async function listGeckoExtensions(projectPath, browser) {
|
|
|
12642
14106
|
});
|
|
12643
14107
|
}
|
|
12644
14108
|
}
|
|
14109
|
+
const RELAY_MARK = "__extensionDevRelay";
|
|
14110
|
+
function cloneHelper() {
|
|
14111
|
+
return `var clone = function (v) {
|
|
14112
|
+
if (v === undefined) return null;
|
|
14113
|
+
try {
|
|
14114
|
+
if (typeof structuredClone === "function") { structuredClone(v); return v; }
|
|
14115
|
+
return JSON.parse(JSON.stringify(v));
|
|
14116
|
+
} catch (e) { return String(v); }
|
|
14117
|
+
};`;
|
|
14118
|
+
}
|
|
14119
|
+
function relaySafeExpression(expression, token) {
|
|
14120
|
+
const src = JSON.stringify(expression);
|
|
14121
|
+
const key = JSON.stringify(token);
|
|
14122
|
+
return `(function () {
|
|
14123
|
+
${cloneHelper()}
|
|
14124
|
+
var value = (0, eval)(${src});
|
|
14125
|
+
if (value && typeof value.then === "function") {
|
|
14126
|
+
var store = globalThis.${RELAY_MARK} = globalThis.${RELAY_MARK} || {};
|
|
14127
|
+
store[${key}] = { done: false };
|
|
14128
|
+
Promise.resolve(value).then(function (settled) {
|
|
14129
|
+
store[${key}] = { done: true, ok: true, value: clone(settled) };
|
|
14130
|
+
}, function (error) {
|
|
14131
|
+
store[${key}] = { done: true, ok: false, name: (error && error.name) || "EvalError", message: (error && error.message) || String(error) };
|
|
14132
|
+
});
|
|
14133
|
+
return { ${RELAY_MARK}: 1, done: false, token: ${key} };
|
|
14134
|
+
}
|
|
14135
|
+
return { ${RELAY_MARK}: 1, done: true, ok: true, value: clone(value) };
|
|
14136
|
+
})()`;
|
|
14137
|
+
}
|
|
14138
|
+
function relayPollExpression(token) {
|
|
14139
|
+
const key = JSON.stringify(token);
|
|
14140
|
+
return `(function () {
|
|
14141
|
+
var store = globalThis.${RELAY_MARK};
|
|
14142
|
+
var entry = store && store[${key}];
|
|
14143
|
+
if (!entry) return { ${RELAY_MARK}: 1, done: true, ok: false, name: "RelayLost", message: "the page reloaded or navigated before the expression settled, so its result is gone" };
|
|
14144
|
+
if (!entry.done) return { ${RELAY_MARK}: 1, done: false, token: ${key} };
|
|
14145
|
+
delete store[${key}];
|
|
14146
|
+
return { ${RELAY_MARK}: 1, done: true, ok: entry.ok, value: entry.value, name: entry.name, message: entry.message };
|
|
14147
|
+
})()`;
|
|
14148
|
+
}
|
|
14149
|
+
function readRelayFrame(value) {
|
|
14150
|
+
if (!value || "object" != typeof value || Array.isArray(value)) return null;
|
|
14151
|
+
const frame = value;
|
|
14152
|
+
if (1 !== frame[RELAY_MARK] || "boolean" != typeof frame.done) return null;
|
|
14153
|
+
return {
|
|
14154
|
+
done: frame.done,
|
|
14155
|
+
..."boolean" == typeof frame.ok ? {
|
|
14156
|
+
ok: frame.ok
|
|
14157
|
+
} : {},
|
|
14158
|
+
..."value" in frame ? {
|
|
14159
|
+
value: frame.value
|
|
14160
|
+
} : {},
|
|
14161
|
+
..."string" == typeof frame.name ? {
|
|
14162
|
+
name: frame.name
|
|
14163
|
+
} : {},
|
|
14164
|
+
..."string" == typeof frame.message ? {
|
|
14165
|
+
message: frame.message
|
|
14166
|
+
} : {},
|
|
14167
|
+
..."string" == typeof frame.token ? {
|
|
14168
|
+
token: frame.token
|
|
14169
|
+
} : {}
|
|
14170
|
+
};
|
|
14171
|
+
}
|
|
12645
14172
|
const eval_schema = {
|
|
12646
14173
|
name: "extension_eval",
|
|
12647
14174
|
description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages (newtab, history, bookmarks) need no tab id but must already be open: open one with extension_open first, because a closed one returns an explicit error. On a Chromium MV3 session those pages, and context:'page' with a chrome-extension:// url, evaluate over CDP, the inspector path the extension page CSP does not govern; elsewhere they evaluate over the in-bundle relay. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
|
|
@@ -12700,36 +14227,96 @@ function chromiumManifestVersion(projectPath, browser) {
|
|
|
12700
14227
|
}
|
|
12701
14228
|
return null;
|
|
12702
14229
|
}
|
|
14230
|
+
function geckoManifestVersion(projectPath, browser) {
|
|
14231
|
+
for (const file of manifestCandidates(projectPath, browser)){
|
|
14232
|
+
let manifest;
|
|
14233
|
+
try {
|
|
14234
|
+
manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
14235
|
+
} catch {
|
|
14236
|
+
continue;
|
|
14237
|
+
}
|
|
14238
|
+
const version = manifest["firefox:manifest_version"] ?? manifest["gecko:manifest_version"] ?? manifest.manifest_version;
|
|
14239
|
+
if (3 === version) return 3;
|
|
14240
|
+
if (2 === version) return 2;
|
|
14241
|
+
}
|
|
14242
|
+
return null;
|
|
14243
|
+
}
|
|
12703
14244
|
function resolveDefaultEvalContext(projectPath, browser) {
|
|
12704
14245
|
if (!isChromiumFamily(browser)) return "background";
|
|
12705
14246
|
return 3 === chromiumManifestVersion(projectPath, browser) ? "page" : "background";
|
|
12706
14247
|
}
|
|
12707
|
-
const EXTENSION_PAGE_CONTEXTS = [
|
|
12708
|
-
"popup",
|
|
12709
|
-
"options",
|
|
12710
|
-
"sidebar",
|
|
12711
|
-
"newtab",
|
|
12712
|
-
"history",
|
|
12713
|
-
"bookmarks"
|
|
12714
|
-
];
|
|
12715
14248
|
function wantsExtensionPageOverCdp(projectPath, browser, context, url) {
|
|
12716
14249
|
if (!isChromiumFamily(browser) || !context) return false;
|
|
12717
|
-
if ("
|
|
14250
|
+
if ("background" === context) return true;
|
|
14251
|
+
if ("page" === context) return "string" == typeof url && url.length > 0;
|
|
12718
14252
|
return EXTENSION_PAGE_CONTEXTS.includes(context) && 3 === chromiumManifestVersion(projectPath, browser);
|
|
12719
14253
|
}
|
|
12720
|
-
async function evaluateOnChromiumExtensionPage(args, browser, context) {
|
|
12721
|
-
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
12722
|
-
if (!resolved) return envelope_envelope({
|
|
12723
|
-
ok: false,
|
|
12724
|
-
command: eval_schema.name,
|
|
12725
|
-
status: "no-session",
|
|
12726
|
-
error: {
|
|
12727
|
-
code: "E_NO_SESSION",
|
|
12728
|
-
name: "NoSession",
|
|
12729
|
-
message: `No active dev session / CDP port for ${browser}, and an extension page evaluates over CDP. Start extension_dev with allowEval: true and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
|
|
12730
|
-
}
|
|
12731
|
-
});
|
|
14254
|
+
async function evaluateOnChromiumExtensionPage(args, browser, context, resolved) {
|
|
12732
14255
|
let wanted;
|
|
14256
|
+
if ("background" === context) {
|
|
14257
|
+
const extensionId = await resolveExtensionId(args.projectPath, browser);
|
|
14258
|
+
if (!extensionId) return envelope_envelope({
|
|
14259
|
+
ok: false,
|
|
14260
|
+
command: eval_schema.name,
|
|
14261
|
+
status: "no-extension-id",
|
|
14262
|
+
error: {
|
|
14263
|
+
code: "E_NO_EXTENSION_ID",
|
|
14264
|
+
name: "NoExtensionId",
|
|
14265
|
+
message: "Could not resolve the extension id from the live session's CDP targets."
|
|
14266
|
+
},
|
|
14267
|
+
hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
|
|
14268
|
+
});
|
|
14269
|
+
let workers = await findExtensionWorkerTargets(resolved.port, extensionId);
|
|
14270
|
+
const wakeWarnings = [];
|
|
14271
|
+
if (0 === workers.length) {
|
|
14272
|
+
const wake = await wakeExtensionWorker(resolved.port, extensionId);
|
|
14273
|
+
if (!wake.woken) return envelope_envelope({
|
|
14274
|
+
ok: false,
|
|
14275
|
+
command: eval_schema.name,
|
|
14276
|
+
status: "no-target",
|
|
14277
|
+
error: {
|
|
14278
|
+
code: "E_NO_TARGET",
|
|
14279
|
+
name: "NoTarget",
|
|
14280
|
+
message: `No running background for chrome-extension://${extensionId}/: Chrome stops an idle MV3 service worker and lists no target for it, and starting it through ServiceWorker.startWorker did not bring one back: ${wake.reason}.`
|
|
14281
|
+
},
|
|
14282
|
+
hint: "Check the extension declares a background (extension_manifest_validate), then wake it with an event it listens to (extension_reload, open a surface with extension_open, navigate a matching tab) and retry. extension_logs (context: ['background']) holds what it wrote before it idled."
|
|
14283
|
+
});
|
|
14284
|
+
workers = wake.targets;
|
|
14285
|
+
wakeWarnings.push("The background worker was idle (Chrome stops an MV3 service worker after about 30 s without events) and was started through ServiceWorker.startWorker before evaluating; state it held before idling is gone unless it was persisted.");
|
|
14286
|
+
}
|
|
14287
|
+
const target = workers[0];
|
|
14288
|
+
const outcome = await evaluateOnExtensionPage(resolved.port, target.targetId, args.expression);
|
|
14289
|
+
if (!outcome.ok) return envelope_envelope({
|
|
14290
|
+
ok: false,
|
|
14291
|
+
command: eval_schema.name,
|
|
14292
|
+
status: outcome.thrown ? "eval-failed" : "cdp-failed",
|
|
14293
|
+
error: {
|
|
14294
|
+
code: outcome.thrown ? "E_EVAL" : "E_CDP",
|
|
14295
|
+
name: outcome.thrown ? "EvalError" : "CdpError",
|
|
14296
|
+
message: outcome.message
|
|
14297
|
+
},
|
|
14298
|
+
hint: outcome.thrown ? `The expression threw inside the ${target.type} at ${target.url}; it ran over CDP with the extension APIs available and a returned promise awaited.` : "The session's debug port refused the call or the worker went away mid-call. extension_doctor names which."
|
|
14299
|
+
});
|
|
14300
|
+
return envelope_envelope({
|
|
14301
|
+
ok: true,
|
|
14302
|
+
command: eval_schema.name,
|
|
14303
|
+
status: "evaluated",
|
|
14304
|
+
value: outcome.value,
|
|
14305
|
+
warnings: [
|
|
14306
|
+
...wakeWarnings,
|
|
14307
|
+
...workers.length > 1 ? [
|
|
14308
|
+
`${workers.length} background targets match the extension; evaluated in ${target.type} ${target.targetId}.`
|
|
14309
|
+
] : []
|
|
14310
|
+
],
|
|
14311
|
+
hint: `Evaluated over CDP in the extension's ${target.type} (${target.url}), the inspector path the extension CSP does not govern; a returned promise is awaited.`
|
|
14312
|
+
});
|
|
14313
|
+
}
|
|
14314
|
+
if ("page" === context && !isExtensionUrl(args.url)) {
|
|
14315
|
+
const url = args.url;
|
|
14316
|
+
const matches = matchTargetsByUrl(await listPageTargets(resolved.port), url);
|
|
14317
|
+
if (0 === matches.length) return RELAY_INSTEAD;
|
|
14318
|
+
return evaluateOnWebTarget(args, matches, url);
|
|
14319
|
+
}
|
|
12733
14320
|
if ("page" === context) wanted = args.url;
|
|
12734
14321
|
else {
|
|
12735
14322
|
const doc = surfaceDocument(args.projectPath, browser, context);
|
|
@@ -12761,7 +14348,7 @@ async function evaluateOnChromiumExtensionPage(args, browser, context) {
|
|
|
12761
14348
|
});
|
|
12762
14349
|
wanted = `chrome-extension://${extensionId}/${doc}`;
|
|
12763
14350
|
}
|
|
12764
|
-
const targets = await findExtensionPageTargets(resolved.port, wanted);
|
|
14351
|
+
const targets = (await findExtensionPageTargets(resolved.port, wanted)).filter((t)=>!isBrowserErrorPage(t.url));
|
|
12765
14352
|
if (0 === targets.length) return envelope_envelope({
|
|
12766
14353
|
ok: false,
|
|
12767
14354
|
command: eval_schema.name,
|
|
@@ -12834,6 +14421,211 @@ async function evaluateOnWebKitPage(args, browser, info) {
|
|
|
12834
14421
|
});
|
|
12835
14422
|
}
|
|
12836
14423
|
}
|
|
14424
|
+
const RELAY_INSTEAD = "__relay_instead__";
|
|
14425
|
+
function isBrowserErrorPage(url) {
|
|
14426
|
+
return /^(chrome|edge)-error:\/\//.test(url);
|
|
14427
|
+
}
|
|
14428
|
+
async function evaluateOnWebTarget(args, matches, url) {
|
|
14429
|
+
const resolved = await resolveCdpPort(args.projectPath, args.browser ?? "chrome");
|
|
14430
|
+
if (!resolved) return RELAY_INSTEAD;
|
|
14431
|
+
const live = matches.filter((t)=>!isBrowserErrorPage(t.url));
|
|
14432
|
+
if (0 === live.length) return envelope_envelope({
|
|
14433
|
+
ok: false,
|
|
14434
|
+
command: eval_schema.name,
|
|
14435
|
+
status: "no-target",
|
|
14436
|
+
error: {
|
|
14437
|
+
code: "E_NO_TARGET",
|
|
14438
|
+
name: "NoTarget",
|
|
14439
|
+
message: `The tab matching ${url} shows the browser's own error page (${matches[0].title || matches[0].url}), so there is no document to evaluate in.`
|
|
14440
|
+
},
|
|
14441
|
+
hint: "The page was blocked or failed to load; open it again with extension_open and read the browser's reason in the tab title."
|
|
14442
|
+
});
|
|
14443
|
+
const target = live[0];
|
|
14444
|
+
const outcome = await evaluateOnExtensionPage(resolved.port, target.targetId, args.expression);
|
|
14445
|
+
if (!outcome.ok) return envelope_envelope({
|
|
14446
|
+
ok: false,
|
|
14447
|
+
command: eval_schema.name,
|
|
14448
|
+
status: outcome.thrown ? "eval-failed" : "cdp-failed",
|
|
14449
|
+
error: {
|
|
14450
|
+
code: outcome.thrown ? "E_EVAL" : "E_CDP",
|
|
14451
|
+
name: outcome.thrown ? "EvalError" : "CdpError",
|
|
14452
|
+
message: outcome.message
|
|
14453
|
+
},
|
|
14454
|
+
hint: outcome.thrown ? `The expression threw inside ${target.url}; it ran over CDP in the page's main world.` : "The session's debug port refused the call or the tab went away. extension_doctor names which."
|
|
14455
|
+
});
|
|
14456
|
+
return envelope_envelope({
|
|
14457
|
+
ok: true,
|
|
14458
|
+
command: eval_schema.name,
|
|
14459
|
+
status: "evaluated",
|
|
14460
|
+
value: outcome.value,
|
|
14461
|
+
warnings: live.length > 1 ? [
|
|
14462
|
+
`${live.length} tabs match ${url}; evaluated in ${target.url} (target ${target.targetId}). Narrow the url to pick another.`
|
|
14463
|
+
] : [],
|
|
14464
|
+
hint: `Evaluated over CDP in ${target.url} (target ${target.targetId}), the page's main world through the inspector, which the site's CSP and Trusted Types do not govern. Pass context: "content" for the extension's isolated world instead.`
|
|
14465
|
+
});
|
|
14466
|
+
}
|
|
14467
|
+
const TRUSTED_TYPES_REFUSAL = /Trusted Type/i;
|
|
14468
|
+
const RELAY_POLL_MS = 300;
|
|
14469
|
+
const RELAY_DEFAULT_BUDGET_MS = 30000;
|
|
14470
|
+
function tryParseFrame(raw) {
|
|
14471
|
+
try {
|
|
14472
|
+
const parsed = JSON.parse(raw);
|
|
14473
|
+
return parsed && "object" == typeof parsed ? parsed : null;
|
|
14474
|
+
} catch {
|
|
14475
|
+
return null;
|
|
14476
|
+
}
|
|
14477
|
+
}
|
|
14478
|
+
async function evaluateThroughRelay(args, browser, context) {
|
|
14479
|
+
const token = node_crypto.randomUUID();
|
|
14480
|
+
const budgetMs = args.timeout ?? RELAY_DEFAULT_BUDGET_MS;
|
|
14481
|
+
const deadline = Date.now() + budgetMs;
|
|
14482
|
+
const run = (expression)=>runActVerb([
|
|
14483
|
+
"eval",
|
|
14484
|
+
...commonFlags({
|
|
14485
|
+
...args,
|
|
14486
|
+
context,
|
|
14487
|
+
browser
|
|
14488
|
+
}),
|
|
14489
|
+
"--",
|
|
14490
|
+
expression,
|
|
14491
|
+
args.projectPath
|
|
14492
|
+
], args.projectPath, args.timeout, eval_schema.name);
|
|
14493
|
+
let raw = await run(relaySafeExpression(args.expression, token));
|
|
14494
|
+
let parsed = tryParseFrame(raw);
|
|
14495
|
+
if (!parsed || true !== parsed.ok) return raw;
|
|
14496
|
+
let frame = readRelayFrame(parsed.value);
|
|
14497
|
+
if (!frame) return raw;
|
|
14498
|
+
let polls = 0;
|
|
14499
|
+
while(!frame.done){
|
|
14500
|
+
if (Date.now() >= deadline) return envelope_envelope({
|
|
14501
|
+
ok: false,
|
|
14502
|
+
command: eval_schema.name,
|
|
14503
|
+
status: "eval-pending",
|
|
14504
|
+
error: {
|
|
14505
|
+
code: "E_WAIT_TIMEOUT",
|
|
14506
|
+
name: "EvalPending",
|
|
14507
|
+
message: `The expression returned a promise that had not settled after ${budgetMs} ms; it is still running in the ${context} page.`
|
|
14508
|
+
},
|
|
14509
|
+
value: {
|
|
14510
|
+
context,
|
|
14511
|
+
token,
|
|
14512
|
+
polls
|
|
14513
|
+
},
|
|
14514
|
+
hint: `Its outcome lands in globalThis.${RELAY_MARK}[${JSON.stringify(token)}] inside the ${context} page when it settles ({done, ok, value}): read it with extension_eval in the same context, or pass a larger timeout to wait here.`
|
|
14515
|
+
});
|
|
14516
|
+
await new Promise((r)=>setTimeout(r, RELAY_POLL_MS));
|
|
14517
|
+
polls += 1;
|
|
14518
|
+
raw = await run(relayPollExpression(token));
|
|
14519
|
+
parsed = tryParseFrame(raw);
|
|
14520
|
+
if (!parsed || true !== parsed.ok) return raw;
|
|
14521
|
+
const next = readRelayFrame(parsed.value);
|
|
14522
|
+
if (!next) return raw;
|
|
14523
|
+
frame = next;
|
|
14524
|
+
}
|
|
14525
|
+
if (false === frame.ok) return envelope_envelope({
|
|
14526
|
+
ok: false,
|
|
14527
|
+
command: eval_schema.name,
|
|
14528
|
+
status: "eval-failed",
|
|
14529
|
+
error: {
|
|
14530
|
+
code: "E_EVAL",
|
|
14531
|
+
name: frame.name || "EvalError",
|
|
14532
|
+
message: frame.message || "the expression rejected"
|
|
14533
|
+
},
|
|
14534
|
+
hint: `The expression threw, or the promise it returned rejected, inside the ${context} page.`
|
|
14535
|
+
});
|
|
14536
|
+
parsed.value = void 0 === frame.value ? null : frame.value;
|
|
14537
|
+
if (polls > 0) parsed.hint = `The expression returned a promise; the ${context} page settled it and this call polled ${polls} time${1 === polls ? "" : "s"} for the result. ` + ("string" == typeof parsed.hint ? parsed.hint : "");
|
|
14538
|
+
return actFrameJson(parsed);
|
|
14539
|
+
}
|
|
14540
|
+
const CSP_EVAL_REFUSAL = /blocked by CSP|call to eval|unsafe-eval|Content Security Policy/i;
|
|
14541
|
+
function explainCspRefusal(raw, context) {
|
|
14542
|
+
const parsed = tryParseFrame(raw);
|
|
14543
|
+
if (!parsed || false !== parsed.ok) return null;
|
|
14544
|
+
const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
|
|
14545
|
+
const message = String(parsed.error?.message ?? "");
|
|
14546
|
+
if ("E_EVAL" !== code || !CSP_EVAL_REFUSAL.test(message)) return null;
|
|
14547
|
+
parsed.error.name = "CspBlocksEval";
|
|
14548
|
+
parsed.hint = `The extension's content_security_policy (or the MV3 default) forbids eval in its own ${context ?? "background"} context, so the in-page executor cannot run any expression there; this is the extension's policy, not a fault in the expression. Read the page instead with extension_dom_snapshot or extension_inspect, evaluate a web page with context: \"page\" and a url, or read the extension's console with extension_logs. On a Chromium MV3 session the extension's own pages evaluate over CDP, which the page policy does not govern.`;
|
|
14549
|
+
if ("string" == typeof parsed.error.hint) delete parsed.error.hint;
|
|
14550
|
+
return actFrameJson(parsed);
|
|
14551
|
+
}
|
|
14552
|
+
const NO_SCRIPTING_API = /scripting is not available/i;
|
|
14553
|
+
function pageEvalRefusedByCsp(parsed) {
|
|
14554
|
+
const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
|
|
14555
|
+
return "E_EVAL" === code && CSP_EVAL_REFUSAL.test(String(parsed.error?.message ?? ""));
|
|
14556
|
+
}
|
|
14557
|
+
function isSingleExpression(source) {
|
|
14558
|
+
try {
|
|
14559
|
+
new Function(`return (${source}\n)`);
|
|
14560
|
+
return true;
|
|
14561
|
+
} catch {
|
|
14562
|
+
return false;
|
|
14563
|
+
}
|
|
14564
|
+
}
|
|
14565
|
+
async function evaluateThroughExecuteScript(args, browser, context, raw) {
|
|
14566
|
+
if ("page" !== context && "content" !== context) return null;
|
|
14567
|
+
if (isChromiumFamily(browser)) return null;
|
|
14568
|
+
const parsed = tryParseFrame(raw);
|
|
14569
|
+
if (!parsed || false !== parsed.ok) return null;
|
|
14570
|
+
const noScriptingApi = NO_SCRIPTING_API.test(String(parsed.error?.message ?? ""));
|
|
14571
|
+
const cspRefused = !noScriptingApi && pageEvalRefusedByCsp(parsed) && 2 === geckoManifestVersion(args.projectPath, browser);
|
|
14572
|
+
if (!noScriptingApi && !cspRefused) return null;
|
|
14573
|
+
const why = cspRefused ? "the page's content security policy refused the in-page eval" : `${browser} MV2 has no scripting API`;
|
|
14574
|
+
const code = isSingleExpression(args.expression) ? `(function () { try { return { __extensionDevExec: 1, ok: true, value: (${args.expression}\n) }; } catch (e) { return { __extensionDevExec: 1, ok: false, name: (e && e.name) || "EvalError", message: (e && e.message) || String(e) }; } })()` : args.expression;
|
|
14575
|
+
const wrapped = await runActVerb([
|
|
14576
|
+
"eval",
|
|
14577
|
+
...commonFlags({
|
|
14578
|
+
...args,
|
|
14579
|
+
context: "background",
|
|
14580
|
+
url: void 0,
|
|
14581
|
+
tab: void 0,
|
|
14582
|
+
browser
|
|
14583
|
+
}),
|
|
14584
|
+
"--",
|
|
14585
|
+
executeScriptExpression(args.url, code),
|
|
14586
|
+
args.projectPath
|
|
14587
|
+
], args.projectPath, args.timeout, eval_schema.name);
|
|
14588
|
+
const frame = tryParseFrame(wrapped);
|
|
14589
|
+
if (!frame || true !== frame.ok) return wrapped ?? raw;
|
|
14590
|
+
const value = frame.value;
|
|
14591
|
+
if (value && "object" == typeof value && "string" == typeof value.error) return envelope_envelope({
|
|
14592
|
+
ok: false,
|
|
14593
|
+
command: eval_schema.name,
|
|
14594
|
+
status: "eval-failed",
|
|
14595
|
+
error: {
|
|
14596
|
+
code: "E_EVAL",
|
|
14597
|
+
name: "EvalError",
|
|
14598
|
+
message: value.error
|
|
14599
|
+
},
|
|
14600
|
+
hint: `Because ${why}, the expression went through tabs.executeScript from the background and that call failed. The tab must be a web page the extension holds host permissions for; extension pages cannot be injected into.`
|
|
14601
|
+
});
|
|
14602
|
+
const first = Array.isArray(value?.frames) ? value.frames[0] : void 0;
|
|
14603
|
+
const result = first && "object" == typeof first && 1 === first.__extensionDevExec ? first : {
|
|
14604
|
+
ok: true,
|
|
14605
|
+
value: void 0 === first ? null : first
|
|
14606
|
+
};
|
|
14607
|
+
if (false === result.ok) return envelope_envelope({
|
|
14608
|
+
ok: false,
|
|
14609
|
+
command: eval_schema.name,
|
|
14610
|
+
status: "eval-failed",
|
|
14611
|
+
error: {
|
|
14612
|
+
code: "E_EVAL",
|
|
14613
|
+
name: result.name || "EvalError",
|
|
14614
|
+
message: result.message || "the expression threw"
|
|
14615
|
+
},
|
|
14616
|
+
hint: "The expression threw inside the tab (run through tabs.executeScript in the content world)."
|
|
14617
|
+
});
|
|
14618
|
+
return envelope_envelope({
|
|
14619
|
+
ok: true,
|
|
14620
|
+
command: eval_schema.name,
|
|
14621
|
+
status: "evaluated",
|
|
14622
|
+
value: void 0 === result.value ? null : result.value,
|
|
14623
|
+
warnings: [
|
|
14624
|
+
`Because ${why}, this ran through tabs.executeScript from the background: the content script world of the tab, not the page's MAIN world, so globals the page defines are not visible${"page" === context ? ' even though context: "page" was asked' : ""}. An empty string or null here is the content world's reading at call time; if the element renders late, wait and retry.`
|
|
14625
|
+
],
|
|
14626
|
+
hint: "Pass url to pick the tab; the extension needs host permissions for it."
|
|
14627
|
+
});
|
|
14628
|
+
}
|
|
12837
14629
|
async function eval_handler(args) {
|
|
12838
14630
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
12839
14631
|
if (WEBKIT_FAMILY.has(browser) && "page" === args.context) {
|
|
@@ -12842,7 +14634,42 @@ async function eval_handler(args) {
|
|
|
12842
14634
|
}
|
|
12843
14635
|
const defaulted = !args.context && "page" === resolveDefaultEvalContext(args.projectPath, browser);
|
|
12844
14636
|
const context = defaulted ? "page" : args.context;
|
|
12845
|
-
if (wantsExtensionPageOverCdp(args.projectPath, browser, context, args.url))
|
|
14637
|
+
if (wantsExtensionPageOverCdp(args.projectPath, browser, context, args.url)) {
|
|
14638
|
+
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
14639
|
+
if (resolved) {
|
|
14640
|
+
const overCdp = await evaluateOnChromiumExtensionPage(args, browser, context, resolved);
|
|
14641
|
+
if (overCdp !== RELAY_INSTEAD) return overCdp;
|
|
14642
|
+
}
|
|
14643
|
+
}
|
|
14644
|
+
if ("page" === context && isExtensionUrl(args.url)) {
|
|
14645
|
+
const surface = surfaceForExtensionUrl(args.projectPath, browser, args.url);
|
|
14646
|
+
if (surface) {
|
|
14647
|
+
const raw = await evaluateThroughRelay({
|
|
14648
|
+
...args,
|
|
14649
|
+
url: void 0,
|
|
14650
|
+
tab: void 0
|
|
14651
|
+
}, browser, surface.context);
|
|
14652
|
+
const parsed = tryParseFrame(raw);
|
|
14653
|
+
if (parsed) {
|
|
14654
|
+
addWarning(parsed, `${args.url} is the extension's own ${surface.context} document (${surface.document}), which script injection cannot reach on any engine, so this evaluated through the ${surface.context} surface relay. Pass context: "${surface.context}" directly next time.`);
|
|
14655
|
+
return actFrameJson(parsed);
|
|
14656
|
+
}
|
|
14657
|
+
return raw;
|
|
14658
|
+
}
|
|
14659
|
+
const declared = declaredSurfaces(args.projectPath, browser) ?? [];
|
|
14660
|
+
return envelope_envelope({
|
|
14661
|
+
ok: false,
|
|
14662
|
+
command: eval_schema.name,
|
|
14663
|
+
status: "no-surface",
|
|
14664
|
+
error: {
|
|
14665
|
+
code: "E_NO_SURFACE_DOCUMENT",
|
|
14666
|
+
name: "NoSurfaceDocument",
|
|
14667
|
+
message: `${args.url} is a page inside the extension, which script injection cannot reach, and it matches none of the surface documents the manifest declares${declared.length ? ` (${declared.join(", ")})` : ""}.`
|
|
14668
|
+
},
|
|
14669
|
+
hint: declared.length ? `Evaluate in a declared surface with context: "${declared[0]}" (open it first with extension_open), or read a web page by url.` : "Declare the page as a surface in the manifest (action.default_popup, options_ui.page, sidebar_action.default_panel or chrome_url_overrides) and rebuild."
|
|
14670
|
+
});
|
|
14671
|
+
}
|
|
14672
|
+
if (context && EXTENSION_PAGE_CONTEXTS.includes(context)) return evaluateThroughRelay(args, browser, context);
|
|
12846
14673
|
const raw = await runActVerb([
|
|
12847
14674
|
"eval",
|
|
12848
14675
|
...commonFlags({
|
|
@@ -12854,6 +14681,15 @@ async function eval_handler(args) {
|
|
|
12854
14681
|
args.expression,
|
|
12855
14682
|
args.projectPath
|
|
12856
14683
|
], args.projectPath, args.timeout, eval_schema.name);
|
|
14684
|
+
const mv2Fallback = await evaluateThroughExecuteScript(args, browser, context, raw);
|
|
14685
|
+
if (null !== mv2Fallback) return mv2Fallback;
|
|
14686
|
+
const cspRefusal = explainCspRefusal(raw, context);
|
|
14687
|
+
if (null !== cspRefusal) return cspRefusal;
|
|
14688
|
+
const trustedTypes = tryParseFrame(raw);
|
|
14689
|
+
if (trustedTypes?.ok === false && TRUSTED_TYPES_REFUSAL.test(String(trustedTypes.error?.message ?? ""))) {
|
|
14690
|
+
trustedTypes.hint = "This site enforces Trusted Types, which refuse a string evaluated inside the page. Pass url so the tab is evaluated over CDP (the inspector path the policy does not govern), or use context: \"content\" for a DOM read from the extension's isolated world.";
|
|
14691
|
+
return actFrameJson(trustedTypes);
|
|
14692
|
+
}
|
|
12857
14693
|
if ("content" === args.context) try {
|
|
12858
14694
|
const parsed = JSON.parse(raw);
|
|
12859
14695
|
if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
|
|
@@ -13342,10 +15178,11 @@ async function dom_snapshot_handler(args) {
|
|
|
13342
15178
|
}
|
|
13343
15179
|
const publish_schema = {
|
|
13344
15180
|
name: "extension_publish",
|
|
13345
|
-
description: "Publish the project your stored token is scoped to (extension_auth, or EXTENSION_DEV_TOKEN) to extension.dev, and return its shareable URL. This is what \"deploy\" or \"ship\" an extension usually means; extension_submit is the separate store-review path. The target is the token's project: there is no projectPath, and no local file is uploaded. For a public project the URL is the canonical public page and ttlHours does not apply. For a private one it is a fresh time-limited share link (?share=) whose lifetime is ttlHours.",
|
|
15181
|
+
description: "Publish the project your stored token is scoped to (extension_auth, or EXTENSION_DEV_TOKEN) to extension.dev, and return its shareable URL. This is what \"deploy\" or \"ship\" an extension usually means; extension_submit is the separate store-review path. The target is the token's project: there is no projectPath, and no local file is uploaded. With several logins stored, pass `project` ('<workspace>/<project>') to pick which one; it outranks EXTENSION_DEV_TOKEN. For a public project the URL is the canonical public page and ttlHours does not apply. For a private one it is a fresh time-limited share link (?share=) whose lifetime is ttlHours.",
|
|
13346
15182
|
inputSchema: {
|
|
13347
15183
|
type: "object",
|
|
13348
15184
|
properties: {
|
|
15185
|
+
project: PROJECT_TOKEN_INPUT,
|
|
13349
15186
|
ttlHours: {
|
|
13350
15187
|
type: "number",
|
|
13351
15188
|
description: "Private-project share-link lifetime in hours, 1-168 (default 24). Ignored for public projects."
|
|
@@ -13359,6 +15196,13 @@ const publish_schema = {
|
|
|
13359
15196
|
required: []
|
|
13360
15197
|
}
|
|
13361
15198
|
};
|
|
15199
|
+
function previewCommandsOf(value) {
|
|
15200
|
+
if (!value || "object" != typeof value) return [];
|
|
15201
|
+
return Object.entries(value).filter(([browser, command])=>"string" == typeof browser && "" !== browser.trim() && "string" == typeof command && command.startsWith("npx -y extension@latest preview ")).map(([browser, command])=>[
|
|
15202
|
+
browser,
|
|
15203
|
+
command
|
|
15204
|
+
]);
|
|
15205
|
+
}
|
|
13362
15206
|
function publish_fail(name, message, status, code) {
|
|
13363
15207
|
return envelope_envelope({
|
|
13364
15208
|
ok: false,
|
|
@@ -13372,7 +15216,10 @@ function publish_fail(name, message, status, code) {
|
|
|
13372
15216
|
});
|
|
13373
15217
|
}
|
|
13374
15218
|
async function publish_handler(args) {
|
|
13375
|
-
const token = resolveToken(
|
|
15219
|
+
const token = resolveToken({
|
|
15220
|
+
project: args.project
|
|
15221
|
+
});
|
|
15222
|
+
if (!token && args.project) return publish_fail("PublishAuthError", noStoredLoginHint(args.project), "auth-required", "E_AUTH_REQUIRED");
|
|
13376
15223
|
if (!token) return publish_fail("PublishAuthError", "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).", "auth-required", "E_AUTH_REQUIRED");
|
|
13377
15224
|
if (null != args.ttlHours) {
|
|
13378
15225
|
const t = Number(args.ttlHours);
|
|
@@ -13441,7 +15288,7 @@ async function publish_handler(args) {
|
|
|
13441
15288
|
buildNote = `buildSha ${args.buildSha} is pinned but was not found in the project's registry build index, so builtAt/version/channel are not filled in from another build. The URL still serves the pinned build the platform accepted.`;
|
|
13442
15289
|
}
|
|
13443
15290
|
} else {
|
|
13444
|
-
const newestSuccess = items.filter(
|
|
15291
|
+
const newestSuccess = items.filter(isSuccessfulBuild).sort((a, b)=>String(b.timestamp ?? "").localeCompare(String(a.timestamp ?? "")))[0];
|
|
13445
15292
|
if (newestSuccess) {
|
|
13446
15293
|
if (null == data.buildSha) data.buildSha = newestSuccess.sha;
|
|
13447
15294
|
if (null == data.builtAt && newestSuccess.timestamp) data.builtAt = newestSuccess.timestamp;
|
|
@@ -13458,6 +15305,8 @@ async function publish_handler(args) {
|
|
|
13458
15305
|
body: data,
|
|
13459
15306
|
api: args.api
|
|
13460
15307
|
});
|
|
15308
|
+
const previewCommands = previewCommandsOf(data.previewCommands);
|
|
15309
|
+
const previewHint = previewCommands.length > 0 ? `Run the extension without opening the share page: ${previewCommands.map(([browser, command])=>`${browser}: ${command}`).join(" | ")}. Each command carries the same share token as the URL and expires with it.` : null;
|
|
13461
15310
|
return envelope_envelope({
|
|
13462
15311
|
ok: true,
|
|
13463
15312
|
command: "extension_publish",
|
|
@@ -13466,15 +15315,19 @@ async function publish_handler(args) {
|
|
|
13466
15315
|
warnings: [
|
|
13467
15316
|
note,
|
|
13468
15317
|
buildNote
|
|
13469
|
-
]
|
|
15318
|
+
],
|
|
15319
|
+
...previewHint ? {
|
|
15320
|
+
hint: previewHint
|
|
15321
|
+
} : {}
|
|
13470
15322
|
});
|
|
13471
15323
|
}
|
|
13472
15324
|
const release_promote_schema = {
|
|
13473
15325
|
name: "extension_release_promote",
|
|
13474
|
-
description: "Promote a built extension to a release channel (stable, preview, beta, …) on extension.dev, headless. This WRITES: it is the only verb that changes what a channel points at. It is auth-gated by your stored login (extension_auth) or a release token in EXTENSION_DEV_TOKEN, minted and revoked under project settings, Access tokens. Tokens live at most 7 days, so CI must re-mint before expiry. The project comes from the token. Call extension_release_status to find a valid buildId. Cutting a version-bump PR is not available headlessly, because it writes to your source repo and needs an interactive login.",
|
|
15326
|
+
description: "Promote a built extension to a release channel (stable, preview, beta, …) on extension.dev, headless. This WRITES: it is the only verb that changes what a channel points at. It is auth-gated by your stored login (extension_auth) or a release token in EXTENSION_DEV_TOKEN, minted and revoked under project settings, Access tokens. Tokens live at most 7 days, so CI must re-mint before expiry. The project comes from the token; with several logins stored, `project` picks which one. Call extension_release_status to find a valid buildId. Cutting a version-bump PR is not available headlessly, because it writes to your source repo and needs an interactive login.",
|
|
13475
15327
|
inputSchema: {
|
|
13476
15328
|
type: "object",
|
|
13477
15329
|
properties: {
|
|
15330
|
+
project: PROJECT_TOKEN_INPUT,
|
|
13478
15331
|
buildId: {
|
|
13479
15332
|
type: "string",
|
|
13480
15333
|
description: "Build commit SHA to promote (a 7-char short SHA is fine)"
|
|
@@ -13527,7 +15380,10 @@ function release_promote_fail(name, message, status, code) {
|
|
|
13527
15380
|
});
|
|
13528
15381
|
}
|
|
13529
15382
|
async function release_promote_handler(args) {
|
|
13530
|
-
const token = resolveToken(
|
|
15383
|
+
const token = resolveToken({
|
|
15384
|
+
project: args.project
|
|
15385
|
+
});
|
|
15386
|
+
if (!token && args.project) return release_promote_fail("PromoteAuthError", noStoredLoginHint(args.project), "auth-required", "E_AUTH_REQUIRED");
|
|
13531
15387
|
if (!token) return release_promote_fail("ReleaseAuthError", "No token. Set EXTENSION_DEV_TOKEN to a release token (create one in the extension.dev dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry), or run extension_auth (action: login).", "auth-required", "E_AUTH_REQUIRED");
|
|
13532
15388
|
const buildId = String(args.buildId || "").trim();
|
|
13533
15389
|
const channel = String(args.channel || "").trim();
|
|
@@ -13761,7 +15617,7 @@ async function readReleases(args) {
|
|
|
13761
15617
|
publicProjectUrl
|
|
13762
15618
|
} : {}
|
|
13763
15619
|
},
|
|
13764
|
-
hint: promotable.length > 0 || recentBuilds.length > 0 ? `Promotable shas: channels currently pin ${promotable.length > 0 ? promotable.join(", ") : "none"}; recent builds add ${recentBuilds.filter(
|
|
15620
|
+
hint: promotable.length > 0 || recentBuilds.length > 0 ? `Promotable shas: channels currently pin ${promotable.length > 0 ? promotable.join(", ") : "none"}; recent builds add ${recentBuilds.filter(isSuccessfulBuild).map((b)=>b.sha).join(", ") || "none"}. Use one of these as buildId/buildSha for promote/deploy/publish.` : `No channels or builds are recorded on the registry yet for ${ref.workspace}/${ref.project}. Push a commit to produce a build, then check ${buildsPageUrl}.`,
|
|
13765
15621
|
warnings: [
|
|
13766
15622
|
publicUrlNote,
|
|
13767
15623
|
channelsUnavailable,
|
|
@@ -14149,10 +16005,11 @@ function storeMdWarnings(browsers, cwd) {
|
|
|
14149
16005
|
}
|
|
14150
16006
|
const submit_schema = {
|
|
14151
16007
|
name: "extension_submit",
|
|
14152
|
-
description: "Submit a built extension for store REVIEW through extension.dev, which holds your store credentials and dispatches from your project's mirror CI: the Chrome Web Store, Firefox AMO, Edge Add-ons and the App Store (Safari). This is store review only. It does not push a build to the extension.dev platform, and it does not make a shareable link: that is extension_publish, which is what \"deploy\" or \"ship\" an extension almost always means. Reach for this only when the ask is explicitly a store submission. It defaults to a dry run that dispatches nothing: the platform verifies auth, project, build and store workflow, and this tool adds each store's credential-health verdict. Trust those per-store rows over the platform's bare preflight line, which does not check store health. Pass dryRun:false to actually submit, which is irreversible and enters store review. The project comes from your token (extension_auth or EXTENSION_DEV_TOKEN; tokens live at most 7 days, so CI must re-mint from the console's Access tokens page). Store credentials are never arguments, and no local file is uploaded. Call extension_release_status for valid shas, and, after a real submission, for the recorded outcome and review state.",
|
|
16008
|
+
description: "Submit a built extension for store REVIEW through extension.dev, which holds your store credentials and dispatches from your project's mirror CI: the Chrome Web Store, Firefox AMO, Edge Add-ons and the App Store (Safari). This is store review only. It does not push a build to the extension.dev platform, and it does not make a shareable link: that is extension_publish, which is what \"deploy\" or \"ship\" an extension almost always means. Reach for this only when the ask is explicitly a store submission. It defaults to a dry run that dispatches nothing: the platform verifies auth, project, build and store workflow, and this tool adds each store's credential-health verdict. Trust those per-store rows over the platform's bare preflight line, which does not check store health. Pass dryRun:false to actually submit, which is irreversible and enters store review. The project comes from your token (extension_auth or EXTENSION_DEV_TOKEN; tokens live at most 7 days, so CI must re-mint from the console's Access tokens page); with several logins stored, `project` picks which one. Store credentials are never arguments, and no local file is uploaded. Call extension_release_status for valid shas, and, after a real submission, for the recorded outcome and review state.",
|
|
14153
16009
|
inputSchema: {
|
|
14154
16010
|
type: "object",
|
|
14155
16011
|
properties: {
|
|
16012
|
+
project: PROJECT_TOKEN_INPUT,
|
|
14156
16013
|
browsers: {
|
|
14157
16014
|
type: "array",
|
|
14158
16015
|
items: {
|
|
@@ -14212,7 +16069,10 @@ function submit_fail(name, message, status, code) {
|
|
|
14212
16069
|
});
|
|
14213
16070
|
}
|
|
14214
16071
|
async function submit_handler(args) {
|
|
14215
|
-
const token = resolveToken(
|
|
16072
|
+
const token = resolveToken({
|
|
16073
|
+
project: args.project
|
|
16074
|
+
});
|
|
16075
|
+
if (!token && args.project) return submit_fail("SubmitAuthError", noStoredLoginHint(args.project), "auth-required", "E_AUTH_REQUIRED");
|
|
14216
16076
|
if (!token) return submit_fail("SubmitAuthError", "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry).", "auth-required", "E_AUTH_REQUIRED");
|
|
14217
16077
|
const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
|
|
14218
16078
|
if (0 === browsers.length) return submit_fail("SubmitInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).', "bad-request", "E_BAD_REQUEST");
|
|
@@ -14443,7 +16303,7 @@ async function wait_handler(args) {
|
|
|
14443
16303
|
const requested = args.timeoutMs ?? args.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
14444
16304
|
const budgetMs = Math.min(Math.max(requested, MIN_TIMEOUT_MS), SAFE_CEILING_MS);
|
|
14445
16305
|
const clamped = requested > SAFE_CEILING_MS;
|
|
14446
|
-
const readyPath =
|
|
16306
|
+
const readyPath = session_paths_readyContractPath(args.projectPath, browser);
|
|
14447
16307
|
const buildOnly = findSessionInfo(args.projectPath, browser)?.noBrowser === true;
|
|
14448
16308
|
const since = sessionSinceMs(args.projectPath, browser);
|
|
14449
16309
|
const start = Date.now();
|
|
@@ -14451,6 +16311,7 @@ async function wait_handler(args) {
|
|
|
14451
16311
|
let sawCompiledButUnattached = false;
|
|
14452
16312
|
let lastContractStatus = null;
|
|
14453
16313
|
let staleContractNote = null;
|
|
16314
|
+
const clampNote = clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : null;
|
|
14454
16315
|
while(Date.now() - start < budgetMs){
|
|
14455
16316
|
try {
|
|
14456
16317
|
const stat = node_fs.statSync(readyPath);
|
|
@@ -14502,6 +16363,26 @@ async function wait_handler(args) {
|
|
|
14502
16363
|
},
|
|
14503
16364
|
hint: "Build-only session (noBrowser): the extension compiled and the dev server is live, but no browser was launched, so browserAttached will never become true. Do not call extension_wait again to wait for a browser. The control verbs (storage/reload/open/dom_snapshot/eval) need a live browser and will not work against this session."
|
|
14504
16365
|
});
|
|
16366
|
+
if (!attached && "start" === contract.command) return envelope_envelope({
|
|
16367
|
+
ok: true,
|
|
16368
|
+
command: wait_schema.name,
|
|
16369
|
+
status: "launched",
|
|
16370
|
+
value: {
|
|
16371
|
+
compiled: true,
|
|
16372
|
+
browserAttached: false,
|
|
16373
|
+
sessionCommand: "start",
|
|
16374
|
+
browser: contract.browser,
|
|
16375
|
+
pid: contract.pid,
|
|
16376
|
+
readyPath,
|
|
16377
|
+
budgetMs,
|
|
16378
|
+
elapsedMs: Date.now() - start
|
|
16379
|
+
},
|
|
16380
|
+
warnings: [
|
|
16381
|
+
clampNote,
|
|
16382
|
+
staleContractNote
|
|
16383
|
+
],
|
|
16384
|
+
hint: "This is an extension_start session: the production build is loaded in the browser, and a production build carries no dev bridge, so browserAttached stays false for good and extension_eval, extension_storage, extension_reload, extension_open and extension_dom_snapshot cannot drive it. Do not call extension_wait again. To drive or read the extension, run it with extension_dev; to check the production artifact, extension_build reports its summary and extension_preview_web renders the built dist."
|
|
16385
|
+
});
|
|
14505
16386
|
if (!attached) {
|
|
14506
16387
|
await new Promise((r)=>setTimeout(r, pollInterval));
|
|
14507
16388
|
sawCompiledButUnattached = true;
|
|
@@ -14531,6 +16412,12 @@ async function wait_handler(args) {
|
|
|
14531
16412
|
pid: contract.pid,
|
|
14532
16413
|
distPath: contract.distPath,
|
|
14533
16414
|
manifestPath: contract.manifestPath,
|
|
16415
|
+
..."number" == typeof contract.browserPid ? {
|
|
16416
|
+
browserPid: contract.browserPid
|
|
16417
|
+
} : {},
|
|
16418
|
+
..."string" == typeof contract.profilePath && contract.profilePath ? {
|
|
16419
|
+
profilePath: contract.profilePath
|
|
16420
|
+
} : {},
|
|
14534
16421
|
compiledAt: contract.compiledAt,
|
|
14535
16422
|
startedAt: contract.startedAt,
|
|
14536
16423
|
budgetMs,
|
|
@@ -14576,6 +16463,10 @@ async function wait_handler(args) {
|
|
|
14576
16463
|
budgetMs,
|
|
14577
16464
|
elapsedMs: Date.now() - start
|
|
14578
16465
|
},
|
|
16466
|
+
warnings: [
|
|
16467
|
+
clampNote,
|
|
16468
|
+
staleContractNote
|
|
16469
|
+
],
|
|
14579
16470
|
hint: "This is usually transient: call extension_wait again. If it persists, stop and restart the session with extension_dev (a restart reliably reattaches); extension_doctor reports the executor leg."
|
|
14580
16471
|
});
|
|
14581
16472
|
return envelope_envelope({
|
|
@@ -14594,7 +16485,7 @@ async function wait_handler(args) {
|
|
|
14594
16485
|
elapsedMs: Date.now() - start
|
|
14595
16486
|
},
|
|
14596
16487
|
warnings: [
|
|
14597
|
-
|
|
16488
|
+
clampNote,
|
|
14598
16489
|
staleContractNote
|
|
14599
16490
|
],
|
|
14600
16491
|
hint: "Still building, call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
|
|
@@ -14663,11 +16554,11 @@ const FEATURE_TEMPLATE_MAP = {
|
|
|
14663
16554
|
preact: "action"
|
|
14664
16555
|
},
|
|
14665
16556
|
newtab: {
|
|
14666
|
-
react: "
|
|
14667
|
-
vue: "
|
|
14668
|
-
svelte: "
|
|
14669
|
-
preact: "
|
|
14670
|
-
vanilla: "
|
|
16557
|
+
react: "newtab-react",
|
|
16558
|
+
vue: "newtab-vue",
|
|
16559
|
+
svelte: "newtab-svelte",
|
|
16560
|
+
preact: "newtab-preact",
|
|
16561
|
+
vanilla: "newtab"
|
|
14671
16562
|
},
|
|
14672
16563
|
background: {
|
|
14673
16564
|
react: "javascript",
|
|
@@ -15120,12 +17011,20 @@ async function readIdentity(deps) {
|
|
|
15120
17011
|
const identityNote = expired ? "The stored token has expired. Run extension_auth (action: login) to refresh it." : `Logged in as ${creds.workspaceSlug}/${creds.projectSlug}, per the token extension_auth stored on this machine. That token is what scopes the identity: it does not follow the current working directory or project folder.`;
|
|
15121
17012
|
const apiDivergesNote = apiDiverges ? `This login was minted via ${recordedApi}: access grants for private registry reads use that recorded base when no api argument is given, while other authenticated tools target ${effectiveDefaultApi} unless given one.` : null;
|
|
15122
17013
|
const envTokenNote = envTokenSet ? "EXTENSION_DEV_TOKEN is set and takes precedence over this stored login for authenticated tools; this report describes only the stored login." : null;
|
|
17014
|
+
const logins = listCredentials().map((entry)=>({
|
|
17015
|
+
project: `${entry.workspaceSlug}/${entry.projectSlug}`,
|
|
17016
|
+
active: entry.active,
|
|
17017
|
+
expiresAt: entry.expiresAt ? new Date(1000 * entry.expiresAt).toISOString() : null,
|
|
17018
|
+
expired: Boolean(entry.expiresAt && entry.expiresAt <= now)
|
|
17019
|
+
}));
|
|
17020
|
+
const loginsNote = logins.length > 1 ? `${logins.length} logins are stored on this machine (${logins.map((l)=>l.project).join(", ")}); the active one above is the default for token-scoped tools, and each of them takes \`project\` to use another.` : null;
|
|
15123
17021
|
const message = [
|
|
15124
17022
|
identityNote,
|
|
15125
17023
|
server.note,
|
|
15126
17024
|
server.warning,
|
|
15127
17025
|
apiDivergesNote,
|
|
15128
|
-
envTokenNote
|
|
17026
|
+
envTokenNote,
|
|
17027
|
+
loginsNote
|
|
15129
17028
|
].filter(Boolean).join(" ");
|
|
15130
17029
|
return envelope_envelope({
|
|
15131
17030
|
ok: true,
|
|
@@ -15142,7 +17041,8 @@ async function readIdentity(deps) {
|
|
|
15142
17041
|
expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
|
|
15143
17042
|
expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
|
|
15144
17043
|
expired,
|
|
15145
|
-
server: server.value
|
|
17044
|
+
server: server.value,
|
|
17045
|
+
logins
|
|
15146
17046
|
},
|
|
15147
17047
|
hint: message,
|
|
15148
17048
|
warnings: [
|
|
@@ -15153,27 +17053,35 @@ async function readIdentity(deps) {
|
|
|
15153
17053
|
]
|
|
15154
17054
|
});
|
|
15155
17055
|
}
|
|
15156
|
-
async function clearLocalCredentials() {
|
|
15157
|
-
const
|
|
17056
|
+
async function clearLocalCredentials(project) {
|
|
17057
|
+
const wanted = String(project ?? "").trim();
|
|
17058
|
+
const creds = readCredentials(wanted ? {
|
|
17059
|
+
project: wanted
|
|
17060
|
+
} : void 0);
|
|
15158
17061
|
const revokeUrl = creds?.workspaceSlug && creds?.projectSlug ? consoleProjectUrl({
|
|
15159
17062
|
workspace: creds.workspaceSlug,
|
|
15160
17063
|
project: creds.projectSlug
|
|
15161
17064
|
}, "settings/access-tokens") : null;
|
|
15162
|
-
const result = clearCredentials(
|
|
17065
|
+
const result = clearCredentials(wanted ? {
|
|
17066
|
+
project: wanted
|
|
17067
|
+
} : void 0);
|
|
17068
|
+
const scope = wanted ? `the login for ${creds ? `${creds.workspaceSlug}/${creds.projectSlug}` : wanted}` : result.removed.length > 1 ? `all ${result.removed.length} stored logins` : "the stored login";
|
|
15163
17069
|
return envelope_envelope({
|
|
15164
17070
|
ok: true,
|
|
15165
17071
|
command: "extension_auth",
|
|
15166
17072
|
status: result.cleared ? "logged-out" : "nothing-to-clear",
|
|
15167
17073
|
value: {
|
|
15168
17074
|
cleared: result.cleared,
|
|
17075
|
+
removed: result.removed,
|
|
17076
|
+
remaining: result.remaining,
|
|
15169
17077
|
revokeUrl: result.cleared && revokeUrl ? revokeUrl : null
|
|
15170
17078
|
},
|
|
15171
|
-
hint: result.cleared ?
|
|
17079
|
+
hint: result.cleared ? `Removed ${scope} from this machine${result.remaining.length ? `; ${result.remaining.length} other login${1 === result.remaining.length ? "" : "s"} stay (${result.remaining.join(", ")})` : ""}. The token stays valid server-side until it expires; ${revokeUrl ? `revoke it now at ${revokeUrl} (takes about a minute to propagate)` : "revoke it from the project's access-tokens page if needed"}.` : wanted ? `No stored login matches ${wanted}. extension_auth (action: status) lists the logins on this machine.` : "No stored credentials to remove."
|
|
15172
17080
|
});
|
|
15173
17081
|
}
|
|
15174
17082
|
const auth_schema = {
|
|
15175
17083
|
name: "extension_auth",
|
|
15176
|
-
description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only; the token stays valid server-side until it is revoked at the URL the response returns.",
|
|
17084
|
+
description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only (with `project`, just that project's login); the token stays valid server-side until it is revoked at the URL the response returns. Several logins live side by side on one machine, one per workspace/project, and status lists them all under `logins`.",
|
|
15177
17085
|
inputSchema: {
|
|
15178
17086
|
type: "object",
|
|
15179
17087
|
properties: {
|
|
@@ -15188,7 +17096,7 @@ const auth_schema = {
|
|
|
15188
17096
|
},
|
|
15189
17097
|
project: {
|
|
15190
17098
|
type: "string",
|
|
15191
|
-
description: "login: target project as '<workspace>/<project>'; the token is scoped to it. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. Create one at extension.dev/new if none exists yet."
|
|
17099
|
+
description: "login: target project as '<workspace>/<project>'; the token is scoped to it. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. Create one at extension.dev/new if none exists yet. Logins to several projects are all kept, the latest is the default; token-scoped tools take `project` to pick another. logout: remove only this project's login (omitted, every stored login goes)."
|
|
15192
17100
|
},
|
|
15193
17101
|
deviceCode: {
|
|
15194
17102
|
type: "string",
|
|
@@ -15201,7 +17109,7 @@ const auth_schema = {
|
|
|
15201
17109
|
};
|
|
15202
17110
|
async function auth_handler(args) {
|
|
15203
17111
|
const action = args.action ?? "status";
|
|
15204
|
-
if ("logout" === action) return clearLocalCredentials();
|
|
17112
|
+
if ("logout" === action) return clearLocalCredentials(args.project);
|
|
15205
17113
|
if ("login" === action) return loginToProject({
|
|
15206
17114
|
project: String(args.project ?? ""),
|
|
15207
17115
|
deviceCode: args.deviceCode,
|
|
@@ -15221,6 +17129,8 @@ const ALL_BROWSERS = [
|
|
|
15221
17129
|
"firefox",
|
|
15222
17130
|
"waterfox",
|
|
15223
17131
|
"librewolf",
|
|
17132
|
+
"zen",
|
|
17133
|
+
"floorp",
|
|
15224
17134
|
"safari"
|
|
15225
17135
|
];
|
|
15226
17136
|
const MANAGED_INSTALLABLE = new Set([
|
|
@@ -15271,6 +17181,14 @@ const SYSTEM_PATHS = {
|
|
|
15271
17181
|
"/Applications/LibreWolf.app/Contents/MacOS/librewolf",
|
|
15272
17182
|
`${process.env.HOME}/Applications/LibreWolf.app/Contents/MacOS/librewolf`
|
|
15273
17183
|
],
|
|
17184
|
+
zen: [
|
|
17185
|
+
"/Applications/Zen.app/Contents/MacOS/zen",
|
|
17186
|
+
`${process.env.HOME}/Applications/Zen.app/Contents/MacOS/zen`
|
|
17187
|
+
],
|
|
17188
|
+
floorp: [
|
|
17189
|
+
"/Applications/Floorp.app/Contents/MacOS/floorp",
|
|
17190
|
+
`${process.env.HOME}/Applications/Floorp.app/Contents/MacOS/floorp`
|
|
17191
|
+
],
|
|
15274
17192
|
safari: [
|
|
15275
17193
|
"/Applications/Safari.app/Contents/MacOS/Safari"
|
|
15276
17194
|
]
|
|
@@ -15320,6 +17238,16 @@ const SYSTEM_PATHS = {
|
|
|
15320
17238
|
librewolf: [
|
|
15321
17239
|
"/usr/bin/librewolf",
|
|
15322
17240
|
"/opt/librewolf/librewolf"
|
|
17241
|
+
],
|
|
17242
|
+
zen: [
|
|
17243
|
+
"/usr/bin/zen",
|
|
17244
|
+
"/usr/bin/zen-browser",
|
|
17245
|
+
"/opt/zen/zen",
|
|
17246
|
+
"/opt/zen-browser/zen"
|
|
17247
|
+
],
|
|
17248
|
+
floorp: [
|
|
17249
|
+
"/usr/bin/floorp",
|
|
17250
|
+
"/opt/floorp/floorp"
|
|
15323
17251
|
]
|
|
15324
17252
|
},
|
|
15325
17253
|
win32: {
|
|
@@ -15361,6 +17289,14 @@ const SYSTEM_PATHS = {
|
|
|
15361
17289
|
librewolf: [
|
|
15362
17290
|
"C:\\Program Files\\LibreWolf\\librewolf.exe",
|
|
15363
17291
|
"C:\\Program Files (x86)\\LibreWolf\\librewolf.exe"
|
|
17292
|
+
],
|
|
17293
|
+
zen: [
|
|
17294
|
+
"C:\\Program Files\\Zen Browser\\zen.exe",
|
|
17295
|
+
`${process.env.LOCALAPPDATA || ""}\\Zen Browser\\zen.exe`
|
|
17296
|
+
],
|
|
17297
|
+
floorp: [
|
|
17298
|
+
"C:\\Program Files\\Floorp\\floorp.exe",
|
|
17299
|
+
"C:\\Program Files (x86)\\Floorp\\floorp.exe"
|
|
15364
17300
|
]
|
|
15365
17301
|
}
|
|
15366
17302
|
};
|
|
@@ -15387,44 +17323,71 @@ function resolveCacheRoot() {
|
|
|
15387
17323
|
if (home) return node_path.join(home, ".cache", "extension.js", "browsers");
|
|
15388
17324
|
return node_path.resolve(process.cwd(), ".cache", "extension.js", "browsers");
|
|
15389
17325
|
}
|
|
15390
|
-
|
|
15391
|
-
|
|
17326
|
+
const MANAGED_SEARCH_DEPTH = 8;
|
|
17327
|
+
const MANAGED_EXEC_NAMES = {
|
|
17328
|
+
chrome: [
|
|
17329
|
+
"chrome",
|
|
17330
|
+
"chrome.exe",
|
|
17331
|
+
"Google Chrome for Testing"
|
|
17332
|
+
],
|
|
17333
|
+
chromium: [
|
|
17334
|
+
"chrome",
|
|
17335
|
+
"chromium",
|
|
17336
|
+
"chrome.exe",
|
|
17337
|
+
"chromium.exe",
|
|
17338
|
+
"Chromium"
|
|
17339
|
+
],
|
|
17340
|
+
edge: [
|
|
17341
|
+
"msedge",
|
|
17342
|
+
"msedge.exe",
|
|
17343
|
+
"microsoft-edge",
|
|
17344
|
+
"Microsoft Edge"
|
|
17345
|
+
],
|
|
17346
|
+
firefox: [
|
|
17347
|
+
"firefox",
|
|
17348
|
+
"firefox.exe",
|
|
17349
|
+
"firefox-bin"
|
|
17350
|
+
]
|
|
17351
|
+
};
|
|
17352
|
+
function executableInsideBundle(bundle, names) {
|
|
17353
|
+
const macos = node_path.join(bundle, "Contents", "MacOS");
|
|
17354
|
+
try {
|
|
17355
|
+
for (const entry of node_fs.readdirSync(macos, {
|
|
17356
|
+
withFileTypes: true
|
|
17357
|
+
}))if (entry.isFile() && names.includes(entry.name)) return node_path.join(macos, entry.name);
|
|
17358
|
+
} catch {}
|
|
17359
|
+
return null;
|
|
17360
|
+
}
|
|
17361
|
+
function compareVersionNames(a, b) {
|
|
17362
|
+
const nums = (name)=>(name.match(/\d+/g) ?? []).map((n)=>Number(n));
|
|
17363
|
+
const left = nums(a);
|
|
17364
|
+
const right = nums(b);
|
|
17365
|
+
for(let i = 0; i < Math.max(left.length, right.length); i += 1){
|
|
17366
|
+
const diff = (left[i] ?? -1) - (right[i] ?? -1);
|
|
17367
|
+
if (0 !== diff) return diff;
|
|
17368
|
+
}
|
|
17369
|
+
return a.localeCompare(b);
|
|
17370
|
+
}
|
|
17371
|
+
function findManagedBinary(browser, cacheRoot = resolveCacheRoot()) {
|
|
17372
|
+
return findManagedBinaryIn(node_path.join(cacheRoot, browser), browser);
|
|
17373
|
+
}
|
|
17374
|
+
function findManagedBinaryIn(browserDir, browser) {
|
|
15392
17375
|
if (!node_fs.existsSync(browserDir)) return null;
|
|
15393
|
-
const
|
|
15394
|
-
chrome: [
|
|
15395
|
-
"chrome",
|
|
15396
|
-
"chrome.exe",
|
|
15397
|
-
"Google Chrome for Testing"
|
|
15398
|
-
],
|
|
15399
|
-
chromium: [
|
|
15400
|
-
"chrome",
|
|
15401
|
-
"chromium",
|
|
15402
|
-
"chrome.exe",
|
|
15403
|
-
"chromium.exe",
|
|
15404
|
-
"Chromium.app"
|
|
15405
|
-
],
|
|
15406
|
-
edge: [
|
|
15407
|
-
"msedge",
|
|
15408
|
-
"msedge.exe",
|
|
15409
|
-
"microsoft-edge",
|
|
15410
|
-
"Microsoft Edge"
|
|
15411
|
-
],
|
|
15412
|
-
firefox: [
|
|
15413
|
-
"firefox",
|
|
15414
|
-
"firefox.exe",
|
|
15415
|
-
"Firefox.app"
|
|
15416
|
-
]
|
|
15417
|
-
};
|
|
15418
|
-
const names = execNames[browser] ?? [];
|
|
17376
|
+
const names = MANAGED_EXEC_NAMES[browser] ?? [];
|
|
15419
17377
|
function search(dir, depth) {
|
|
15420
|
-
if (depth >
|
|
17378
|
+
if (depth > MANAGED_SEARCH_DEPTH) return null;
|
|
15421
17379
|
try {
|
|
15422
|
-
|
|
17380
|
+
const entries = node_fs.readdirSync(dir, {
|
|
15423
17381
|
withFileTypes: true
|
|
15424
|
-
}))
|
|
17382
|
+
}).sort((a, b)=>compareVersionNames(b.name, a.name));
|
|
17383
|
+
for (const entry of entries){
|
|
15425
17384
|
const full = node_path.join(dir, entry.name);
|
|
15426
17385
|
if (entry.isFile() && names.includes(entry.name)) return full;
|
|
15427
|
-
if (entry.isDirectory() &&
|
|
17386
|
+
if (entry.isDirectory() && entry.name.endsWith(".app")) {
|
|
17387
|
+
const inside = executableInsideBundle(full, names);
|
|
17388
|
+
if (inside) return inside;
|
|
17389
|
+
}
|
|
17390
|
+
if (entry.isDirectory() && depth < MANAGED_SEARCH_DEPTH) {
|
|
15428
17391
|
const found = search(full, depth + 1);
|
|
15429
17392
|
if (found) return found;
|
|
15430
17393
|
}
|
|
@@ -15475,14 +17438,16 @@ async function detectBrowsers(browsers) {
|
|
|
15475
17438
|
const isWebkit = WEBKIT_FAMILY.has(browser);
|
|
15476
17439
|
let binaryPath = findManagedBinary(browser);
|
|
15477
17440
|
let source = "managed";
|
|
17441
|
+
const systemBinaryPath = findSystemBinary(browser);
|
|
15478
17442
|
if (!binaryPath) {
|
|
15479
|
-
binaryPath =
|
|
17443
|
+
binaryPath = systemBinaryPath;
|
|
15480
17444
|
source = binaryPath ? "system" : "not_found";
|
|
15481
17445
|
}
|
|
15482
17446
|
let version = null;
|
|
15483
17447
|
if (binaryPath && !isWebkit) version = await getVersion(binaryPath, browser);
|
|
15484
17448
|
else if (binaryPath && isWebkit) version = await readSafariVersion(binaryPath);
|
|
15485
17449
|
const automation = isWebkit && binaryPath ? await detectSafariAutomation(binaryPath) : null;
|
|
17450
|
+
const shadowedSystem = "managed" === source && systemBinaryPath && systemBinaryPath !== binaryPath ? systemBinaryPath : null;
|
|
15486
17451
|
detected.push({
|
|
15487
17452
|
browser,
|
|
15488
17453
|
binaryPath,
|
|
@@ -15493,6 +17458,10 @@ async function detectBrowsers(browsers) {
|
|
|
15493
17458
|
rdpSupport: isGecko,
|
|
15494
17459
|
...automation ? {
|
|
15495
17460
|
automation
|
|
17461
|
+
} : {},
|
|
17462
|
+
...shadowedSystem ? {
|
|
17463
|
+
systemBinaryPath: shadowedSystem,
|
|
17464
|
+
devLaunches: `extension_dev launches the managed binary above, not the system install at ${shadowedSystem}; pass ${isGecko ? "geckoBinary" : "chromiumBinary"} to extension_dev to use the system one.`
|
|
15496
17465
|
} : {}
|
|
15497
17466
|
});
|
|
15498
17467
|
}
|
|
@@ -15574,7 +17543,8 @@ async function installManagedBrowser(browser) {
|
|
|
15574
17543
|
const start = Date.now();
|
|
15575
17544
|
try {
|
|
15576
17545
|
await extensionInstall({
|
|
15577
|
-
browser
|
|
17546
|
+
browser,
|
|
17547
|
+
locateInstalledBinary: (destination, target)=>findManagedBinaryIn(destination, target)
|
|
15578
17548
|
});
|
|
15579
17549
|
return envelope_envelope({
|
|
15580
17550
|
ok: true,
|
|
@@ -15902,6 +17872,7 @@ const tools = [
|
|
|
15902
17872
|
add_feature_namespaceObject,
|
|
15903
17873
|
auth_namespaceObject,
|
|
15904
17874
|
project_create_namespaceObject,
|
|
17875
|
+
workspace_create_namespaceObject,
|
|
15905
17876
|
browsers_namespaceObject,
|
|
15906
17877
|
doctor_namespaceObject
|
|
15907
17878
|
];
|