@deeeed/metamask-harness 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +41 -0
- package/adapters/extension/build-lavamoat.sh +2 -1
- package/adapters/extension/ensure-browser.sh +82 -9
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +83 -1
- package/adapters/extension/lib/chrome-args.cjs +325 -1
- package/adapters/extension/lib/playwright-cdp.cjs +34 -0
- package/adapters/extension/lib/slot-title.cjs +2 -4
- package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
- package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
- package/adapters/extension/reattach.sh +2 -1
- package/adapters/extension/sidepanel-toggle.sh +14 -96
- package/adapters/extension/wallet-fixture-state.cjs +8 -31
- package/adapters/manifest.json +16 -0
- package/adapters/shared/private-atomic-write.cjs +47 -0
- package/adapters/shared/setup-base.sh +864 -0
- package/dist/adapters/extension/runtime.js +367 -24
- package/dist/adapters/extension/validation-process-ownership.js +10 -0
- package/dist/cli-commands.js +1 -0
- package/dist/command-contract.js +12 -0
- package/dist/commands/launch/extension.js +130 -19
- package/dist/commands/setup-base.js +24 -0
- package/dist/mm-harness-cli.js +28 -2
- package/library/actions/extension/analytics/consent.mjs +203 -0
- package/library/actions/extension/analytics/set_consent.mjs +19 -143
- package/library/actions/extension/perps/perps.mjs +2 -16
- package/library/actions/extension/perps/state.mjs +20 -0
- package/library/actions/extension/wallet/list_accounts.mjs +3 -25
- package/library/actions/extension/wallet/read_state.mjs +3 -23
- package/library/actions/extension/wallet/select_account.mjs +6 -33
- package/library/actions/extension/wallet/setup.mjs +2 -20
- package/library/actions/extension/wallet/state.mjs +111 -0
- package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
- package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
- package/package.json +7 -4
- package/scripts/site-contrast.mjs +538 -0
- package/site/architecture.html +415 -0
- package/site/assets/progress.mjs +272 -0
- package/site/assets/style.css +808 -0
- package/site/cheatsheet.html +305 -0
- package/site/index.html +643 -0
- package/site/recipes.html +396 -0
- package/site/reviewers.html +374 -0
- package/site/tutorials/index.html +180 -0
- package/site/tutorials/v1.html +211 -0
- package/site/tutorials/v2.html +207 -0
- package/site/tutorials/v3.html +214 -0
- package/site/tutorials/v4.html +195 -0
- package/site/tutorials/v5.html +163 -0
- package/site/tutorials/v6.html +165 -0
- package/site/tutorials/v7.html +184 -0
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import {
|
|
6
7
|
depsCheck,
|
|
7
8
|
recordDepsBaseline
|
|
8
9
|
} from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
9
10
|
import { colorHumanMessage } from "../../cli-color.js";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
importRecipeHarnessRuntimeCdp,
|
|
13
|
+
recipeHarnessPath,
|
|
14
|
+
recipeRuntimeDir,
|
|
15
|
+
runnerDir
|
|
16
|
+
} from "../../paths.js";
|
|
11
17
|
import { extensionIdFromKey } from "../../adapters/extension/extension-id.js";
|
|
12
18
|
import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
|
|
13
19
|
import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
|
|
@@ -15,6 +21,10 @@ import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js
|
|
|
15
21
|
import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
|
|
16
22
|
import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
|
|
17
23
|
import { EXIT, spawnScriptStreaming } from "../shared.js";
|
|
24
|
+
const { CdpSession } = await importRecipeHarnessRuntimeCdp();
|
|
25
|
+
const { RUNTIME_IDENTITY_FILENAME, RUNTIME_NONCE_PREFIX } = createRequire(import.meta.url)(
|
|
26
|
+
path.join(runnerDir, "adapters/extension/lib/chrome-args.cjs")
|
|
27
|
+
);
|
|
18
28
|
function extensionDepsBlock(target) {
|
|
19
29
|
if (!fs.existsSync(path.join(target, "package.json"))) return null;
|
|
20
30
|
const deps = depsCheck(target);
|
|
@@ -73,7 +83,8 @@ async function extensionRuntimeReusable(target) {
|
|
|
73
83
|
if (isExtensionDistStale(target)) return false;
|
|
74
84
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
75
85
|
const reachable = await cdpVersionReachable(cdpPort);
|
|
76
|
-
const
|
|
86
|
+
const nonceOwned = reachable && await cdpOwnedByRuntimeNonce(cdpPort, target);
|
|
87
|
+
const owned = nonceOwned || reachable && cdpOwnedByExpectedRuntime(cdpPort, target);
|
|
77
88
|
const targetPresent = owned && await cdpHasExpectedExtensionTarget(cdpPort, target);
|
|
78
89
|
const healthy = targetPresent && await cdpRuntimeHealthy(cdpPort, target);
|
|
79
90
|
if (healthy) return true;
|
|
@@ -88,17 +99,20 @@ function expectedChromeProfile(target) {
|
|
|
88
99
|
function expectedRuntimeDist(target) {
|
|
89
100
|
return path.resolve(path.join(target, recipeRuntimeDir(), process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"));
|
|
90
101
|
}
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return false;
|
|
102
|
+
function commandHasExactFlagValue(command, flag, value) {
|
|
103
|
+
const expected = `${flag}=${value}`;
|
|
104
|
+
let offset = command.indexOf(expected);
|
|
105
|
+
while (offset !== -1) {
|
|
106
|
+
const before = offset === 0 ? "" : command[offset - 1];
|
|
107
|
+
const afterOffset = offset + expected.length;
|
|
108
|
+
const after = afterOffset === command.length ? "" : command[afterOffset];
|
|
109
|
+
if ((!before || /\s/u.test(before)) && (!after || /\s/u.test(after))) return true;
|
|
110
|
+
offset = command.indexOf(expected, offset + 1);
|
|
101
111
|
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
function cdpOwnedByExpectedRuntime(port, target) {
|
|
115
|
+
const pids = cdpListenerPids(port);
|
|
102
116
|
const profile = expectedChromeProfile(target);
|
|
103
117
|
const runtimeDist = expectedRuntimeDist(target);
|
|
104
118
|
for (const pid of pids) {
|
|
@@ -107,7 +121,7 @@ function cdpOwnedByExpectedRuntime(port, target) {
|
|
|
107
121
|
encoding: "utf8",
|
|
108
122
|
stdio: ["ignore", "pipe", "ignore"]
|
|
109
123
|
});
|
|
110
|
-
if (command
|
|
124
|
+
if (commandHasExactFlagValue(command, "--user-data-dir", profile) && (commandHasExactFlagValue(command, "--load-extension", runtimeDist) || commandHasExactFlagValue(command, "--disable-extensions-except", runtimeDist))) {
|
|
111
125
|
return true;
|
|
112
126
|
}
|
|
113
127
|
} catch {
|
|
@@ -115,21 +129,118 @@ function cdpOwnedByExpectedRuntime(port, target) {
|
|
|
115
129
|
}
|
|
116
130
|
return false;
|
|
117
131
|
}
|
|
118
|
-
function
|
|
132
|
+
async function cdpOwnedByRuntimeNonce(port, target) {
|
|
133
|
+
const identity = readRuntimeIdentity(port, target);
|
|
134
|
+
if (!identity) return false;
|
|
135
|
+
const version = await cdpVersion(port);
|
|
136
|
+
const webSocketDebuggerUrl = version?.webSocketDebuggerUrl;
|
|
137
|
+
if (typeof webSocketDebuggerUrl !== "string" || !webSocketDebuggerUrl) return false;
|
|
138
|
+
let session;
|
|
139
|
+
try {
|
|
140
|
+
session = await CdpSession.connect(webSocketDebuggerUrl, { timeoutMs: 2e3 });
|
|
141
|
+
const result = await boundedBrowserCdpCall(
|
|
142
|
+
session,
|
|
143
|
+
"Browser.getBrowserCommandLine",
|
|
144
|
+
{},
|
|
145
|
+
2e3
|
|
146
|
+
);
|
|
147
|
+
if (!Array.isArray(result.arguments)) return false;
|
|
148
|
+
const nonceArgs = result.arguments.filter(
|
|
149
|
+
(argument) => typeof argument === "string" && argument.startsWith(RUNTIME_NONCE_PREFIX)
|
|
150
|
+
);
|
|
151
|
+
return nonceArgs.length === 1 && nonceArgs[0] === `${RUNTIME_NONCE_PREFIX}${identity.nonce}`;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
} finally {
|
|
155
|
+
session?.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function boundedBrowserCdpCall(session, method, params, timeoutMs) {
|
|
159
|
+
let timer;
|
|
160
|
+
try {
|
|
161
|
+
return await Promise.race([
|
|
162
|
+
session.call(method, params),
|
|
163
|
+
new Promise((_resolve, reject) => {
|
|
164
|
+
timer = setTimeout(
|
|
165
|
+
() => reject(new Error(`${method} timed out after ${timeoutMs}ms.`)),
|
|
166
|
+
timeoutMs
|
|
167
|
+
);
|
|
168
|
+
})
|
|
169
|
+
]);
|
|
170
|
+
} finally {
|
|
171
|
+
if (timer) clearTimeout(timer);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function readRuntimeIdentity(port, target) {
|
|
175
|
+
const expectedPort = Number(port);
|
|
176
|
+
if (!Number.isInteger(expectedPort) || expectedPort <= 0 || expectedPort > 65535) return null;
|
|
177
|
+
const identityPath = path.join(target, recipeRuntimeDir(), RUNTIME_IDENTITY_FILENAME);
|
|
178
|
+
let descriptor;
|
|
179
|
+
try {
|
|
180
|
+
descriptor = fs.openSync(
|
|
181
|
+
identityPath,
|
|
182
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW
|
|
183
|
+
);
|
|
184
|
+
const stat = fs.fstatSync(descriptor);
|
|
185
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > 4096 || (stat.mode & 63) !== 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const parsed = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
189
|
+
if (parsed.port !== expectedPort || !Number.isInteger(parsed.pid) || Number(parsed.pid) <= 0 || !Number.isInteger(parsed.startedAt) || Number(parsed.startedAt) <= 0 || Number(parsed.startedAt) > Date.now() + 6e4 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{64}$/u.test(parsed.nonce)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (!cdpListenerPids(port).includes(String(parsed.pid))) return null;
|
|
193
|
+
return parsed;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
} finally {
|
|
197
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function cdpListenerPids(port) {
|
|
201
|
+
try {
|
|
202
|
+
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
205
|
+
});
|
|
206
|
+
return out.split(/\s+/u).filter((value) => /^\d+$/u.test(value));
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function cdpVersionReachable(port) {
|
|
212
|
+
return Boolean(await cdpVersion(port));
|
|
213
|
+
}
|
|
214
|
+
function cdpVersion(port) {
|
|
119
215
|
return new Promise((resolve) => {
|
|
120
216
|
const request = http.get(
|
|
121
217
|
{ host: "127.0.0.1", port: Number(port), path: "/json/version", timeout: 2e3 },
|
|
122
218
|
(response) => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
219
|
+
let body = "";
|
|
220
|
+
response.setEncoding("utf8");
|
|
221
|
+
response.on("data", (chunk) => {
|
|
222
|
+
body += chunk;
|
|
223
|
+
});
|
|
224
|
+
response.on("end", () => {
|
|
225
|
+
const code = response.statusCode ?? 0;
|
|
226
|
+
if (code < 200 || code >= 300) {
|
|
227
|
+
resolve(null);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(body);
|
|
232
|
+
resolve(parsed && typeof parsed === "object" ? parsed : null);
|
|
233
|
+
} catch {
|
|
234
|
+
resolve(null);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
126
237
|
}
|
|
127
238
|
);
|
|
128
239
|
request.on("timeout", () => {
|
|
129
240
|
request.destroy();
|
|
130
|
-
resolve(
|
|
241
|
+
resolve(null);
|
|
131
242
|
});
|
|
132
|
-
request.on("error", () => resolve(
|
|
243
|
+
request.on("error", () => resolve(null));
|
|
133
244
|
});
|
|
134
245
|
}
|
|
135
246
|
function expectedExtensionId(target) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runnerDir } from "../paths.js";
|
|
4
|
+
function handleSetupBase(argv) {
|
|
5
|
+
const script = path.join(runnerDir, "adapters", "shared", "setup-base.sh");
|
|
6
|
+
const result = spawnSync("bash", [script, ...argv], {
|
|
7
|
+
cwd: process.cwd(),
|
|
8
|
+
env: { ...process.env, MM_HARNESS_NODE: process.execPath },
|
|
9
|
+
stdio: "inherit"
|
|
10
|
+
});
|
|
11
|
+
if (result.error) {
|
|
12
|
+
const code = result.error.code ?? "ESPAWN";
|
|
13
|
+
process.stderr.write(
|
|
14
|
+
`setup-base could not start (${code}).
|
|
15
|
+
Next: npm i -g @deeeed/metamask-harness@latest
|
|
16
|
+
`
|
|
17
|
+
);
|
|
18
|
+
return 3;
|
|
19
|
+
}
|
|
20
|
+
return result.status ?? 1;
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
handleSetupBase
|
|
24
|
+
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { withCommandJournal } from "./command-journal.js";
|
|
|
8
8
|
import { JsonStreamWriter } from "./json-stream.js";
|
|
9
9
|
import { handleUpdate, maybeNudge } from "./commands/update.js";
|
|
10
10
|
import { handleCallHelp } from "./commands/call.js";
|
|
11
|
+
import { handleSetupBase } from "./commands/setup-base.js";
|
|
11
12
|
import { getAdapterSurface } from "./adapters/surface.js";
|
|
12
13
|
import { detectAdapter } from "./harness.js";
|
|
13
14
|
import {
|
|
@@ -20,6 +21,28 @@ globalThis.__MM_HARNESS_WRAPPER__ = true;
|
|
|
20
21
|
const { main: recipeMain } = await import("./cli.js");
|
|
21
22
|
const rawArgv = process.argv.slice(2);
|
|
22
23
|
const REAL = [
|
|
24
|
+
{
|
|
25
|
+
name: "setup-base",
|
|
26
|
+
summary: "Bootstrap numbered MetaMask product checkouts and install their dependencies.",
|
|
27
|
+
example: "mm-harness setup-base --only core --counts core=1",
|
|
28
|
+
helpText: `mm-harness setup-base [flags]
|
|
29
|
+
|
|
30
|
+
Bootstrap numbered MetaMask Extension, Mobile, and Core checkouts under one
|
|
31
|
+
base directory, then run each checkout's pinned dependency install.
|
|
32
|
+
|
|
33
|
+
--dir <base> Destination root
|
|
34
|
+
--counts <repo=n,...> Copies per selected repository
|
|
35
|
+
--only <extension,mobile,core> Repository subset
|
|
36
|
+
--dry-run Print the plan without changing anything
|
|
37
|
+
--force Override the free-space refusal
|
|
38
|
+
--json Machine-readable terminal summary
|
|
39
|
+
--show-config Print saved preferences and exit
|
|
40
|
+
--reset-config Remove saved preferences and exit
|
|
41
|
+
--skip-harness-update Skip the installed-version notice
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
mm-harness setup-base --only core --counts core=1`
|
|
45
|
+
},
|
|
23
46
|
{
|
|
24
47
|
name: "status",
|
|
25
48
|
aliases: ["health", "home"],
|
|
@@ -603,7 +626,7 @@ const HELP_GROUPS = [
|
|
|
603
626
|
{
|
|
604
627
|
title: "MAINTAIN",
|
|
605
628
|
blurb: "keep the installed harness current with the npm registry",
|
|
606
|
-
commands: ["update"]
|
|
629
|
+
commands: ["setup-base", "update"]
|
|
607
630
|
}
|
|
608
631
|
];
|
|
609
632
|
function commandMeta(name) {
|
|
@@ -732,7 +755,7 @@ program.command("completions").description("Install/print bundled shell tab-comp
|
|
|
732
755
|
const result = spawnSync("bash", [script, ...rawArgv.slice(1)], { stdio: "inherit" });
|
|
733
756
|
process.exit(result.status ?? 1);
|
|
734
757
|
});
|
|
735
|
-
const NUDGE_SKIP = ["update", "completions", "completion-candidates"];
|
|
758
|
+
const NUDGE_SKIP = ["setup-base", "update", "completions", "completion-candidates"];
|
|
736
759
|
if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
|
|
737
760
|
maybeNudge();
|
|
738
761
|
}
|
|
@@ -794,6 +817,9 @@ if (rawArgv.length === 0) {
|
|
|
794
817
|
process.stdout.write(groupedHelp());
|
|
795
818
|
process.exit(0);
|
|
796
819
|
}
|
|
820
|
+
if (rawArgv[0] === "setup-base") {
|
|
821
|
+
process.exit(handleSetupBase(rawArgv.slice(1)));
|
|
822
|
+
}
|
|
797
823
|
const preflightBypass = /* @__PURE__ */ new Set([
|
|
798
824
|
...HIDDEN,
|
|
799
825
|
"completions"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export function consentControllerExpression({
|
|
2
|
+
bridgeSymbol,
|
|
3
|
+
participate,
|
|
4
|
+
marketing,
|
|
5
|
+
timeoutMs,
|
|
6
|
+
}) {
|
|
7
|
+
return `(async () => {
|
|
8
|
+
const submit = globalThis.stateHooks?.submitRequestToBackground;
|
|
9
|
+
const bridgeKey = Symbol.for(${JSON.stringify(bridgeSymbol)});
|
|
10
|
+
const capturedChrome = globalThis[bridgeKey];
|
|
11
|
+
let port;
|
|
12
|
+
let nextId = Date.now();
|
|
13
|
+
// Popup connections are ref-counted, so temporary ports cannot erase the
|
|
14
|
+
// live fullscreen or side-panel registration for the same tab.
|
|
15
|
+
const connectionName = 'popup';
|
|
16
|
+
|
|
17
|
+
const rawSubmit = (method, params) => new Promise((resolve, reject) => {
|
|
18
|
+
if (!port) {
|
|
19
|
+
if (typeof capturedChrome?.runtime?.connect !== 'function') {
|
|
20
|
+
reject(new Error('Extension consent setup could not reach the background controller.'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
port = capturedChrome.runtime.connect({ name: connectionName });
|
|
24
|
+
}
|
|
25
|
+
const id = nextId++;
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
port.onMessage.removeListener(listener);
|
|
28
|
+
reject(new Error(method + ' timed out after ${Number(timeoutMs)}ms'));
|
|
29
|
+
}, ${Number(timeoutMs)});
|
|
30
|
+
const listener = (message) => {
|
|
31
|
+
const data = message?.name === 'controller' ? message.data : null;
|
|
32
|
+
if (data?.id !== id) return;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
port.onMessage.removeListener(listener);
|
|
35
|
+
if (data.error) reject(new Error(JSON.stringify(data.error)));
|
|
36
|
+
else resolve(data.result);
|
|
37
|
+
};
|
|
38
|
+
port.onMessage.addListener(listener);
|
|
39
|
+
try {
|
|
40
|
+
port.postMessage({
|
|
41
|
+
name: 'controller',
|
|
42
|
+
data: { jsonrpc: '2.0', id, method, params }
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
port.onMessage.removeListener(listener);
|
|
47
|
+
reject(error);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const callController = typeof submit === 'function' ? submit : rawSubmit;
|
|
52
|
+
const readBackgroundState = () => new Promise((resolve, reject) => {
|
|
53
|
+
if (typeof capturedChrome?.runtime?.connect !== 'function') {
|
|
54
|
+
reject(new Error('Extension consent state is unavailable.'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const statePort = capturedChrome.runtime.connect({ name: connectionName });
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
statePort.onMessage.removeListener(listener);
|
|
60
|
+
statePort.disconnect();
|
|
61
|
+
reject(new Error('Extension consent state timed out after ${Number(timeoutMs)}ms.'));
|
|
62
|
+
}, ${Number(timeoutMs)});
|
|
63
|
+
const listener = (message) => {
|
|
64
|
+
const data = message?.name === 'controller' ? message.data : null;
|
|
65
|
+
if (data?.method !== 'START_UI_SYNC') return;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
statePort.onMessage.removeListener(listener);
|
|
68
|
+
statePort.disconnect();
|
|
69
|
+
resolve(data.params?.[0]);
|
|
70
|
+
};
|
|
71
|
+
statePort.onMessage.addListener(listener);
|
|
72
|
+
});
|
|
73
|
+
const readConsentState = async () => {
|
|
74
|
+
const hooks = globalThis.stateHooks || {};
|
|
75
|
+
const storeState = hooks.store?.getState?.();
|
|
76
|
+
const cleanState = typeof hooks.getCleanAppState === 'function'
|
|
77
|
+
? await hooks.getCleanAppState()
|
|
78
|
+
: undefined;
|
|
79
|
+
const root = capturedChrome
|
|
80
|
+
? await readBackgroundState()
|
|
81
|
+
: storeState ?? cleanState;
|
|
82
|
+
if (!root) {
|
|
83
|
+
throw new Error('Extension consent state is unavailable.');
|
|
84
|
+
}
|
|
85
|
+
const metamask = root?.metamask ?? root ?? {};
|
|
86
|
+
if (
|
|
87
|
+
typeof metamask.optedIn !== 'boolean' ||
|
|
88
|
+
typeof metamask.dataCollectionForMarketing !== 'boolean'
|
|
89
|
+
) {
|
|
90
|
+
throw new Error('Extension consent state did not contain boolean consent fields.');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
state: {
|
|
94
|
+
optedIn: metamask.optedIn,
|
|
95
|
+
dataCollectionForMarketing: metamask.dataCollectionForMarketing,
|
|
96
|
+
analyticsId: metamask.analyticsId ? 'set' : null
|
|
97
|
+
},
|
|
98
|
+
source: capturedChrome
|
|
99
|
+
? 'background-controller-flat-state'
|
|
100
|
+
: storeState
|
|
101
|
+
? 'debug-page-store'
|
|
102
|
+
: 'debug-clean-app-state'
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
const waitForConsentState = async () => {
|
|
106
|
+
const expected = {
|
|
107
|
+
optedIn: ${JSON.stringify(participate)},
|
|
108
|
+
dataCollectionForMarketing: ${JSON.stringify(marketing)}
|
|
109
|
+
};
|
|
110
|
+
const deadline = Date.now() + ${Number(timeoutMs)};
|
|
111
|
+
let result;
|
|
112
|
+
do {
|
|
113
|
+
result = await readConsentState();
|
|
114
|
+
if (Object.entries(expected).every(([key, value]) => result.state[key] === value)) {
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
118
|
+
} while (Date.now() < deadline);
|
|
119
|
+
throw new Error(
|
|
120
|
+
'Consent state did not settle to ' + JSON.stringify(expected) +
|
|
121
|
+
'; got ' + JSON.stringify(result?.state) + '.',
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
const setMarketing = async (value) => {
|
|
125
|
+
await callController('setDataCollectionForMarketing', [value]);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
if (!${JSON.stringify(participate)}) await setMarketing(false);
|
|
130
|
+
const analyticsId = await callController(
|
|
131
|
+
'setParticipateInMetaMetrics',
|
|
132
|
+
[${JSON.stringify(participate)}],
|
|
133
|
+
);
|
|
134
|
+
if (${JSON.stringify(participate)}) {
|
|
135
|
+
await setMarketing(${JSON.stringify(marketing)});
|
|
136
|
+
}
|
|
137
|
+
const settled = await waitForConsentState();
|
|
138
|
+
return {
|
|
139
|
+
analyticsId: analyticsId ? 'set' : null,
|
|
140
|
+
state: settled.state,
|
|
141
|
+
stateSource: settled.source
|
|
142
|
+
};
|
|
143
|
+
} finally {
|
|
144
|
+
port?.disconnect();
|
|
145
|
+
if (capturedChrome) delete globalThis[bridgeKey];
|
|
146
|
+
}
|
|
147
|
+
})()`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function withConsentChromeBridge(page, {
|
|
151
|
+
bridgeSymbol,
|
|
152
|
+
timeoutMs,
|
|
153
|
+
}, operation) {
|
|
154
|
+
const preload = await page.session.call('Page.addScriptToEvaluateOnNewDocument', {
|
|
155
|
+
source: `Object.defineProperty(
|
|
156
|
+
globalThis,
|
|
157
|
+
Symbol.for(${JSON.stringify(bridgeSymbol)}),
|
|
158
|
+
{ value: globalThis.chrome, configurable: true }
|
|
159
|
+
);`,
|
|
160
|
+
});
|
|
161
|
+
if (!preload?.identifier) {
|
|
162
|
+
throw new Error('Extension consent setup could not register its temporary Chrome bridge.');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
let notifyLoaded;
|
|
167
|
+
const loaded = new Promise((resolve) => {
|
|
168
|
+
notifyLoaded = resolve;
|
|
169
|
+
});
|
|
170
|
+
const unsubscribe = page.session.on('Page.loadEventFired', () => notifyLoaded?.());
|
|
171
|
+
let reloadTimeout;
|
|
172
|
+
try {
|
|
173
|
+
await page.session.call('Page.reload', { ignoreCache: false });
|
|
174
|
+
await Promise.race([
|
|
175
|
+
loaded,
|
|
176
|
+
new Promise((_, reject) => {
|
|
177
|
+
reloadTimeout = setTimeout(
|
|
178
|
+
() => reject(new Error(`Extension consent reload timed out after ${timeoutMs}ms.`)),
|
|
179
|
+
timeoutMs,
|
|
180
|
+
);
|
|
181
|
+
}),
|
|
182
|
+
]);
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(reloadTimeout);
|
|
185
|
+
unsubscribe();
|
|
186
|
+
}
|
|
187
|
+
await page.waitForExpression(
|
|
188
|
+
`typeof globalThis[Symbol.for(${JSON.stringify(bridgeSymbol)})]?.runtime?.connect === 'function'`,
|
|
189
|
+
{ timeoutMs },
|
|
190
|
+
);
|
|
191
|
+
return await operation();
|
|
192
|
+
} finally {
|
|
193
|
+
try {
|
|
194
|
+
await page.session.call('Page.removeScriptToEvaluateOnNewDocument', {
|
|
195
|
+
identifier: preload.identifier,
|
|
196
|
+
});
|
|
197
|
+
} finally {
|
|
198
|
+
await page.evaluate(
|
|
199
|
+
`delete globalThis[Symbol.for(${JSON.stringify(bridgeSymbol)})]`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -1,155 +1,31 @@
|
|
|
1
1
|
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
2
|
import { consentParams } from '../../shared/analytics/consent.mjs';
|
|
3
|
+
import {
|
|
4
|
+
consentControllerExpression,
|
|
5
|
+
withConsentChromeBridge,
|
|
6
|
+
} from './consent.mjs';
|
|
3
7
|
|
|
4
8
|
runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
5
9
|
const { participate, marketing, timeoutMs } = consentParams(input.node);
|
|
6
10
|
const bridgeSymbol = 'metamask-harness-consent-bridge';
|
|
7
11
|
|
|
8
|
-
async function readConsentState() {
|
|
9
|
-
return page.evaluate(`(async () => {
|
|
10
|
-
const hooks = globalThis.stateHooks || {};
|
|
11
|
-
const storeState = hooks.store?.getState?.();
|
|
12
|
-
const cleanState = typeof hooks.getCleanAppState === 'function'
|
|
13
|
-
? await hooks.getCleanAppState()
|
|
14
|
-
: undefined;
|
|
15
|
-
if (!storeState && !cleanState) {
|
|
16
|
-
throw new Error('Extension consent state is unavailable.');
|
|
17
|
-
}
|
|
18
|
-
const metamask = storeState?.metamask ?? cleanState?.metamask ?? {};
|
|
19
|
-
return {
|
|
20
|
-
optedIn: Boolean(metamask.optedIn),
|
|
21
|
-
dataCollectionForMarketing: Boolean(metamask.dataCollectionForMarketing),
|
|
22
|
-
analyticsId: metamask.analyticsId ? 'set' : null
|
|
23
|
-
};
|
|
24
|
-
})()`, { awaitPromise: true });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function waitForConsentState(expected) {
|
|
28
|
-
const deadline = Date.now() + timeoutMs;
|
|
29
|
-
let state;
|
|
30
|
-
do {
|
|
31
|
-
state = await readConsentState();
|
|
32
|
-
if (Object.entries(expected).every(([key, value]) => state[key] === value)) {
|
|
33
|
-
return state;
|
|
34
|
-
}
|
|
35
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
36
|
-
} while (Date.now() < deadline);
|
|
37
|
-
throw new Error(`Consent state did not settle to ${JSON.stringify(expected)}; got ${JSON.stringify(state)}.`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
12
|
const hasDebugBridge = await page.evaluate(
|
|
41
13
|
`typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
|
|
42
14
|
);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
});
|
|
58
|
-
const unsubscribe = page.session.on('Page.loadEventFired', () => notifyLoaded?.());
|
|
59
|
-
let reloadTimeout;
|
|
60
|
-
try {
|
|
61
|
-
await page.session.call('Page.reload', { ignoreCache: false });
|
|
62
|
-
await Promise.race([
|
|
63
|
-
loaded,
|
|
64
|
-
new Promise((_, reject) => {
|
|
65
|
-
reloadTimeout = setTimeout(
|
|
66
|
-
() => reject(new Error(`Extension consent reload timed out after ${timeoutMs}ms.`)),
|
|
67
|
-
timeoutMs,
|
|
68
|
-
);
|
|
69
|
-
}),
|
|
70
|
-
]);
|
|
71
|
-
} finally {
|
|
72
|
-
clearTimeout(reloadTimeout);
|
|
73
|
-
unsubscribe();
|
|
74
|
-
}
|
|
75
|
-
await page.waitForExpression(
|
|
76
|
-
`typeof globalThis[Symbol.for(${JSON.stringify(bridgeSymbol)})]?.runtime?.connect === 'function'`,
|
|
77
|
-
{ timeoutMs },
|
|
78
|
-
);
|
|
79
|
-
} finally {
|
|
80
|
-
if (preloadIdentifier) {
|
|
81
|
-
await page.session.call('Page.removeScriptToEvaluateOnNewDocument', {
|
|
82
|
-
identifier: preloadIdentifier,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const controllerResult = await page.evaluate(`(async () => {
|
|
89
|
-
const submit = globalThis.stateHooks?.submitRequestToBackground;
|
|
90
|
-
const bridgeKey = Symbol.for(${JSON.stringify(bridgeSymbol)});
|
|
91
|
-
const capturedChrome = globalThis[bridgeKey];
|
|
92
|
-
let port;
|
|
93
|
-
let nextId = Date.now();
|
|
94
|
-
|
|
95
|
-
const rawSubmit = (method, params) => new Promise((resolve, reject) => {
|
|
96
|
-
if (!port) {
|
|
97
|
-
const connectionName = location.pathname.includes('sidepanel')
|
|
98
|
-
? 'sidepanel'
|
|
99
|
-
: location.pathname.includes('popup')
|
|
100
|
-
? 'popup'
|
|
101
|
-
: 'fullscreen';
|
|
102
|
-
port = capturedChrome.runtime.connect({ name: connectionName });
|
|
103
|
-
}
|
|
104
|
-
const id = nextId++;
|
|
105
|
-
const timer = setTimeout(
|
|
106
|
-
() => reject(new Error(method + ' timed out after ${timeoutMs}ms')),
|
|
107
|
-
${timeoutMs},
|
|
108
|
-
);
|
|
109
|
-
const listener = (message) => {
|
|
110
|
-
const data = message?.name === 'controller' ? message.data : null;
|
|
111
|
-
if (data?.id !== id) return;
|
|
112
|
-
clearTimeout(timer);
|
|
113
|
-
port.onMessage.removeListener(listener);
|
|
114
|
-
if (data.error) reject(new Error(JSON.stringify(data.error)));
|
|
115
|
-
else resolve(data.result);
|
|
116
|
-
};
|
|
117
|
-
port.onMessage.addListener(listener);
|
|
118
|
-
port.postMessage({
|
|
119
|
-
name: 'controller',
|
|
120
|
-
data: { jsonrpc: '2.0', id, method, params }
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const callController = typeof submit === 'function' ? submit : rawSubmit;
|
|
125
|
-
if (typeof callController !== 'function') {
|
|
126
|
-
throw new Error('Extension consent setup could not reach the background controller.');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const setMarketing = async (value) => {
|
|
130
|
-
await callController('setDataCollectionForMarketing', [value]);
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
if (!${JSON.stringify(participate)}) await setMarketing(false);
|
|
135
|
-
const analyticsId = await callController(
|
|
136
|
-
'setParticipateInMetaMetrics',
|
|
137
|
-
[${JSON.stringify(participate)}],
|
|
138
|
-
);
|
|
139
|
-
if (${JSON.stringify(participate)}) {
|
|
140
|
-
await setMarketing(${JSON.stringify(marketing)});
|
|
141
|
-
}
|
|
142
|
-
return { analyticsId: analyticsId ? 'set' : null };
|
|
143
|
-
} finally {
|
|
144
|
-
port?.disconnect();
|
|
145
|
-
if (capturedChrome) delete globalThis[bridgeKey];
|
|
146
|
-
}
|
|
147
|
-
})()`, { awaitPromise: true });
|
|
148
|
-
|
|
149
|
-
const state = await waitForConsentState({
|
|
150
|
-
optedIn: participate,
|
|
151
|
-
dataCollectionForMarketing: marketing,
|
|
152
|
-
});
|
|
15
|
+
const runController = () => page.evaluate(consentControllerExpression({
|
|
16
|
+
bridgeSymbol,
|
|
17
|
+
participate,
|
|
18
|
+
marketing,
|
|
19
|
+
timeoutMs,
|
|
20
|
+
}), { awaitPromise: true });
|
|
21
|
+
const controllerResult = hasDebugBridge
|
|
22
|
+
? await runController()
|
|
23
|
+
: await withConsentChromeBridge(
|
|
24
|
+
page,
|
|
25
|
+
{ bridgeSymbol, timeoutMs },
|
|
26
|
+
runController,
|
|
27
|
+
);
|
|
28
|
+
const state = controllerResult.state;
|
|
153
29
|
|
|
154
30
|
if (state.optedIn !== participate) {
|
|
155
31
|
throw new Error(`Expected optedIn=${participate}, got ${state.optedIn}.`);
|
|
@@ -166,6 +42,6 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
|
166
42
|
...state,
|
|
167
43
|
analyticsId: controllerResult.analyticsId,
|
|
168
44
|
},
|
|
169
|
-
proofPath:
|
|
45
|
+
proofPath: `extension-${controllerResult.stateSource}`,
|
|
170
46
|
};
|
|
171
47
|
}));
|