@deeeed/metamask-harness 0.41.0 → 0.42.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 +24 -0
- package/README.md +7 -0
- package/adapters/manifest.json +25 -1
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +70 -10
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +115 -15
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +752 -0
- package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
- package/adapters/mobile/coalesce-metro-log.cjs +24 -0
- package/adapters/mobile/launch-metro.cjs +9 -8
- package/adapters/mobile/metro-log-generation.cjs +106 -0
- package/adapters/mobile/reload-app.mjs +67 -0
- package/adapters/mobile/start-console-forwarder.sh +17 -2
- package/adapters/mobile/start-metro.sh +23 -18
- package/adapters/mobile/stop-metro.sh +15 -7
- package/adapters/shared/open-debug.mjs +172 -2
- package/adapters/shared/reap-checkout-metros.sh +17 -0
- package/dist/adapters/extension/network-observer.js +300 -0
- package/dist/adapters/mobile/metro-env.js +0 -5
- package/dist/adapters/mobile/prepare.js +1 -3
- package/dist/adapters/mobile/runtime-decision.js +6 -30
- package/dist/adapters.js +14 -1
- package/dist/cli-commands.js +6 -3
- package/dist/cli.js +4 -0
- package/dist/command-contract.js +3 -0
- package/dist/commands/call.js +45 -20
- package/dist/commands/launch/index.js +25 -5
- package/dist/commands/reload.js +80 -0
- package/dist/commands/run.js +49 -22
- package/dist/mm-harness-cli.js +17 -1
- package/dist/network-observation.js +271 -0
- package/docs/NETWORK-CAPTURE.md +98 -0
- package/docs/QA.md +2 -0
- package/docs/RECIPES.md +10 -0
- package/library/actions/mobile/app/network_assert.mjs +14 -0
- package/library/actions/mobile/app/network_capture.mjs +72 -0
- package/library/actions/mobile/platform/bridge.mjs +7 -2
- package/library/actions/shared/app/network-artifact.mjs +10 -0
- package/library/actions/shared/app/network-assert.mjs +154 -0
- package/library/manifests/extension.action-manifest.json +88 -0
- package/library/manifests/mobile.action-manifest.json +107 -0
- package/library/recipes/mobile/perps/performance.recipe.json +11 -11
- package/package.json +1 -1
- package/scripts/completions.sh +2 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { WebSocket, WebSocketServer } = require('ws');
|
|
5
|
+
|
|
6
|
+
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const SESSION_TIMEOUT_MS = 10_000;
|
|
8
|
+
const EVENT_GATED_DOMAINS = new Set([
|
|
9
|
+
'Debugger',
|
|
10
|
+
'Log',
|
|
11
|
+
'Network',
|
|
12
|
+
'Page',
|
|
13
|
+
'ReactNativeApplication',
|
|
14
|
+
'Runtime',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function createDevtoolsProxy({
|
|
18
|
+
descriptorPath,
|
|
19
|
+
sessions,
|
|
20
|
+
sendCommand,
|
|
21
|
+
requestDiscovery,
|
|
22
|
+
allowedOrigins,
|
|
23
|
+
}) {
|
|
24
|
+
if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) {
|
|
25
|
+
throw new Error('DevTools proxy requires at least one allowed Origin');
|
|
26
|
+
}
|
|
27
|
+
let listeningPort = null;
|
|
28
|
+
const clients = new Set();
|
|
29
|
+
const server = new WebSocketServer({
|
|
30
|
+
host: '127.0.0.1',
|
|
31
|
+
port: 0,
|
|
32
|
+
maxPayload: MAX_FRAME_BYTES,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function waitForSession(deviceId) {
|
|
36
|
+
const ready = sessions.get(deviceId);
|
|
37
|
+
if (ready?.brokerReady) return ready;
|
|
38
|
+
requestDiscovery();
|
|
39
|
+
const deadline = Date.now() + SESSION_TIMEOUT_MS;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
42
|
+
const session = sessions.get(deviceId);
|
|
43
|
+
if (session?.brokerReady) return session;
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Hermes runtime is unavailable or reloading; retry the command');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeDescriptor() {
|
|
49
|
+
if (!listeningPort) return;
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
descriptorPath,
|
|
52
|
+
`${JSON.stringify({ schemaVersion: 1, pid: process.pid, port: listeningPort })}\n`,
|
|
53
|
+
{ mode: 0o600 },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function forward(client, message) {
|
|
58
|
+
if (
|
|
59
|
+
!message ||
|
|
60
|
+
typeof message !== 'object' ||
|
|
61
|
+
!Number.isInteger(message.id) ||
|
|
62
|
+
typeof message.method !== 'string'
|
|
63
|
+
) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (message.method.endsWith('.disable')) {
|
|
67
|
+
client.enabled.delete(message.method.replace(/\.disable$/u, '.enable'));
|
|
68
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
69
|
+
client.socket.send(JSON.stringify({ id: message.id, result: {} }));
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const isEnable = message.method.endsWith('.enable');
|
|
74
|
+
if (isEnable) {
|
|
75
|
+
client.enabled.set(message.method, message.params || {});
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const session = await waitForSession(client.deviceId);
|
|
79
|
+
const result = await sendCommand(
|
|
80
|
+
session,
|
|
81
|
+
message.method,
|
|
82
|
+
message.params || {},
|
|
83
|
+
SESSION_TIMEOUT_MS,
|
|
84
|
+
);
|
|
85
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
86
|
+
client.socket.send(JSON.stringify({ id: message.id, result }));
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (isEnable) client.enabled.delete(message.method);
|
|
90
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
91
|
+
client.socket.send(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
id: message.id,
|
|
94
|
+
error: {
|
|
95
|
+
code: -32000,
|
|
96
|
+
message: String(error?.message || error).slice(0, 256),
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
server.on('connection', (socket, request) => {
|
|
105
|
+
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
|
106
|
+
const deviceId = url.searchParams.get('device');
|
|
107
|
+
if (
|
|
108
|
+
!deviceId ||
|
|
109
|
+
!allowedOrigins.includes(request.headers.origin)
|
|
110
|
+
) {
|
|
111
|
+
socket.close(1008, 'invalid DevTools proxy Origin or device');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const client of clients) {
|
|
115
|
+
if (client.deviceId === deviceId) {
|
|
116
|
+
client.socket.close(1000, 'replaced by a newer DevTools window');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const client = { socket, deviceId, enabled: new Map() };
|
|
120
|
+
clients.add(client);
|
|
121
|
+
socket.on('message', (data) => {
|
|
122
|
+
let message;
|
|
123
|
+
try {
|
|
124
|
+
message = JSON.parse(String(data));
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
void forward(client, message);
|
|
129
|
+
});
|
|
130
|
+
const drop = () => clients.delete(client);
|
|
131
|
+
socket.on('close', drop);
|
|
132
|
+
socket.on('error', drop);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
server.on('listening', () => {
|
|
136
|
+
const address = server.address();
|
|
137
|
+
if (!address || typeof address === 'string') return;
|
|
138
|
+
listeningPort = address.port;
|
|
139
|
+
writeDescriptor();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
onSessionOpen(deviceId, session) {
|
|
144
|
+
for (const client of clients) {
|
|
145
|
+
if (client.deviceId !== deviceId) continue;
|
|
146
|
+
for (const [method, params] of client.enabled) {
|
|
147
|
+
void sendCommand(session, method, params, SESSION_TIMEOUT_MS).catch(
|
|
148
|
+
() => undefined,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
onCdpEvent(deviceId, method, params) {
|
|
154
|
+
const payload = JSON.stringify({ method, params });
|
|
155
|
+
const domain = method.split('.', 1)[0];
|
|
156
|
+
for (const client of clients) {
|
|
157
|
+
if (
|
|
158
|
+
client.deviceId === deviceId &&
|
|
159
|
+
client.socket.readyState === WebSocket.OPEN &&
|
|
160
|
+
(!EVENT_GATED_DOMAINS.has(domain) ||
|
|
161
|
+
client.enabled.has(`${domain}.enable`))
|
|
162
|
+
) {
|
|
163
|
+
client.socket.send(payload);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
close() {
|
|
168
|
+
for (const client of clients) client.socket.close();
|
|
169
|
+
server.close();
|
|
170
|
+
try {
|
|
171
|
+
fs.unlinkSync(descriptorPath);
|
|
172
|
+
} catch {}
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { createDevtoolsProxy };
|
|
@@ -138,7 +138,7 @@ function targetDeviceIdentity(target) {
|
|
|
138
138
|
* page 1 = native C++ runtime, page 2+ = JS runtime (where __AGENTIC__ lives)
|
|
139
139
|
* - We probe candidates to find the one with __AGENTIC__ installed
|
|
140
140
|
*/
|
|
141
|
-
async function discoverTarget(port) {
|
|
141
|
+
async function discoverTarget(port, { probe = true } = {}) {
|
|
142
142
|
const listUrl = `http://localhost:${port}/json/list`;
|
|
143
143
|
const androidTargetName = loadAndroidTargetDeviceName();
|
|
144
144
|
const androidDevice = loadAndroidDevice();
|
|
@@ -162,7 +162,7 @@ async function discoverTarget(port) {
|
|
|
162
162
|
if (androidPinned) {
|
|
163
163
|
const pinnedCandidates = runtimeCandidates.filter(matchesAndroidPin);
|
|
164
164
|
for (const candidate of pinnedCandidates) {
|
|
165
|
-
if (await probeTarget(candidate.webSocketDebuggerUrl)) {
|
|
165
|
+
if (!probe || (await probeTarget(candidate.webSocketDebuggerUrl))) {
|
|
166
166
|
acceptedPinnedCandidate = candidate;
|
|
167
167
|
return true;
|
|
168
168
|
}
|
|
@@ -174,7 +174,7 @@ async function discoverTarget(port) {
|
|
|
174
174
|
(candidate) => candidate.deviceName === simName,
|
|
175
175
|
);
|
|
176
176
|
for (const candidate of pinnedCandidates) {
|
|
177
|
-
if (await probeTarget(candidate.webSocketDebuggerUrl)) {
|
|
177
|
+
if (!probe || (await probeTarget(candidate.webSocketDebuggerUrl))) {
|
|
178
178
|
acceptedPinnedCandidate = candidate;
|
|
179
179
|
return true;
|
|
180
180
|
}
|
|
@@ -307,6 +307,16 @@ async function discoverTarget(port) {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
// The persistent console forwarder already owns and validates brokered CDP
|
|
311
|
+
// sessions. Re-probing here would evict that debugger; preserve the normal
|
|
312
|
+
// platform/device filtering and select the highest-ranked runtime instead.
|
|
313
|
+
if (!probe) {
|
|
314
|
+
return {
|
|
315
|
+
wsUrl: candidates[0].webSocketDebuggerUrl,
|
|
316
|
+
deviceName: candidates[0].deviceName || '',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
310
320
|
// Sort by page number descending (JS runtime has higher page number than C++
|
|
311
321
|
// native). rankRuntimeCandidates output arrives pre-sorted; this re-sort is
|
|
312
322
|
// load-bearing only when the raw targets.filter() fallback above repopulated
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const readline = require('node:readline');
|
|
5
|
+
|
|
6
|
+
const progressPattern = /^\s*(?:iOS|Android).*?(\d{1,3}(?:\.\d+)?)%/u;
|
|
7
|
+
let lastPercent = null;
|
|
8
|
+
let lastProgressAt = 0;
|
|
9
|
+
|
|
10
|
+
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
11
|
+
lines.on('line', (line) => {
|
|
12
|
+
const progress = progressPattern.exec(line);
|
|
13
|
+
if (!progress) {
|
|
14
|
+
process.stdout.write(`${line}\n`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const percent = Math.floor(Number(progress[1]));
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
if (percent !== lastPercent || now - lastProgressAt >= 15_000) {
|
|
20
|
+
process.stdout.write(`${line} [metro-progress ${new Date(now).toISOString()}]\n`);
|
|
21
|
+
lastPercent = percent;
|
|
22
|
+
lastProgressAt = now;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -26,33 +26,34 @@ function parseArgs(argv) {
|
|
|
26
26
|
async function main() {
|
|
27
27
|
const args = parseArgs(process.argv.slice(2));
|
|
28
28
|
if (args.help) {
|
|
29
|
-
console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> [--workers <n>] [--clear]');
|
|
29
|
+
console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> --runner <path> [--workers <n>] [--clear]');
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
-
for (const required of ['target', 'port', 'log', 'pid-file', 'build-env']) {
|
|
32
|
+
for (const required of ['target', 'port', 'log', 'pid-file', 'build-env', 'runner']) {
|
|
33
33
|
if (!args[required]) throw new Error(`--${required} is required`);
|
|
34
34
|
}
|
|
35
35
|
const target = path.resolve(args.target);
|
|
36
36
|
const log = path.resolve(args.log);
|
|
37
37
|
const pidFile = path.resolve(args['pid-file']);
|
|
38
|
+
const runner = path.resolve(args.runner);
|
|
38
39
|
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
39
40
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
40
|
-
const
|
|
41
|
-
|
|
41
|
+
const runnerStat = fs.lstatSync(runner);
|
|
42
|
+
if (!runnerStat.isFile()) throw new Error('--runner must be a regular file');
|
|
43
|
+
fs.accessSync(runner, fs.constants.X_OK);
|
|
42
44
|
const env = { ...process.env };
|
|
43
45
|
env.BASH_ENV = args['build-env'];
|
|
44
46
|
if (args.workers) env.METRO_MAX_WORKERS = String(args.workers);
|
|
45
|
-
const child = spawn(
|
|
47
|
+
const child = spawn(runner, [], {
|
|
46
48
|
cwd: target,
|
|
47
49
|
detached: true,
|
|
48
50
|
env,
|
|
49
|
-
stdio:
|
|
51
|
+
stdio: 'ignore',
|
|
50
52
|
});
|
|
51
53
|
await new Promise((resolve, reject) => {
|
|
52
54
|
child.once('spawn', resolve);
|
|
53
55
|
child.once('error', reject);
|
|
54
56
|
});
|
|
55
|
-
fs.closeSync(logFd);
|
|
56
57
|
fs.writeFileSync(pidFile, `${String(child.pid)}\n`);
|
|
57
58
|
fs.writeFileSync(
|
|
58
59
|
path.join(path.dirname(pidFile), 'metro-launch.json'),
|
|
@@ -64,7 +65,7 @@ async function main() {
|
|
|
64
65
|
target,
|
|
65
66
|
port: Number(args.port),
|
|
66
67
|
clear: Boolean(args.clear),
|
|
67
|
-
command:
|
|
68
|
+
command: runner,
|
|
68
69
|
}, null, 2)}\n`,
|
|
69
70
|
);
|
|
70
71
|
child.unref();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const generationIdPattern = /^[A-Za-z0-9-]+$/u;
|
|
9
|
+
|
|
10
|
+
function archiveGenerationId(name) {
|
|
11
|
+
if (!name.startsWith('metro.') || !name.endsWith('.log')) return null;
|
|
12
|
+
const generationId = name.slice('metro.'.length, -'.log'.length);
|
|
13
|
+
return generationIdPattern.test(generationId) ? generationId : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function positiveInteger(name, fallback) {
|
|
17
|
+
const raw = process.env[name];
|
|
18
|
+
if (raw === undefined || raw === '') return fallback;
|
|
19
|
+
if (!/^[1-9][0-9]*$/u.test(raw)) throw new Error(`${name} must be a positive integer`);
|
|
20
|
+
return Number(raw);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function main() {
|
|
24
|
+
const runtimeArg = process.argv[2];
|
|
25
|
+
const port = process.argv[3];
|
|
26
|
+
const reason = process.argv[4];
|
|
27
|
+
if (!runtimeArg || !/^[1-9][0-9]*$/u.test(port ?? '') || !reason) {
|
|
28
|
+
throw new Error('usage: metro-log-generation.cjs <runtime-dir> <port> <reason>');
|
|
29
|
+
}
|
|
30
|
+
const runtimeDir = fs.realpathSync(runtimeArg);
|
|
31
|
+
const activeLog = path.join(runtimeDir, 'metro.log');
|
|
32
|
+
const evidenceFile = path.join(runtimeDir, 'metro-generation.json');
|
|
33
|
+
const maxArchives = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_COUNT', 4);
|
|
34
|
+
const maxArchiveBytes = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_BYTES', 8 * 1024 * 1024);
|
|
35
|
+
const generationId = `${new Date().toISOString().replace(/[-:.]/gu, '')}-${port}-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
36
|
+
if (!generationIdPattern.test(generationId)) throw new Error('generated Metro log identity is invalid');
|
|
37
|
+
let rotatedLog = null;
|
|
38
|
+
|
|
39
|
+
rotatedLog = path.join(runtimeDir, `metro.${generationId}.log`);
|
|
40
|
+
try {
|
|
41
|
+
fs.renameSync(activeLog, rotatedLog);
|
|
42
|
+
const stat = fs.lstatSync(rotatedLog);
|
|
43
|
+
if (!stat.isFile()) {
|
|
44
|
+
fs.renameSync(rotatedLog, activeLog);
|
|
45
|
+
throw new Error('metro.log must be a regular file');
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error && error.code === 'ENOENT') rotatedLog = null;
|
|
49
|
+
else throw error;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
fs.closeSync(fs.openSync(activeLog, 'wx', 0o600));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error && error.code === 'EEXIST') throw new Error('another Metro generation recreated metro.log; refusing to truncate it');
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const archives = fs.readdirSync(runtimeDir)
|
|
59
|
+
.filter((name) => archiveGenerationId(name) !== null)
|
|
60
|
+
.map((name) => {
|
|
61
|
+
const file = path.join(runtimeDir, name);
|
|
62
|
+
const stat = fs.lstatSync(file);
|
|
63
|
+
if (!stat.isFile()) return null;
|
|
64
|
+
return { file, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs || right.file.localeCompare(left.file));
|
|
68
|
+
|
|
69
|
+
let retainedBytes = 0;
|
|
70
|
+
let retainedCount = 0;
|
|
71
|
+
const retained = [];
|
|
72
|
+
const removed = [];
|
|
73
|
+
for (const archive of archives) {
|
|
74
|
+
if (retainedCount < maxArchives && retainedBytes + archive.size <= maxArchiveBytes) {
|
|
75
|
+
retained.push(archive.file);
|
|
76
|
+
retainedCount += 1;
|
|
77
|
+
retainedBytes += archive.size;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
fs.unlinkSync(archive.file);
|
|
81
|
+
removed.push({ path: archive.file, size: archive.size, reason: retainedCount >= maxArchives ? 'count-limit' : 'size-limit' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const evidence = {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
generationId,
|
|
87
|
+
port: Number(port),
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
currentLog: activeLog,
|
|
90
|
+
rotatedLog: rotatedLog && fs.existsSync(rotatedLog) ? rotatedLog : null,
|
|
91
|
+
archivedLogs: retained,
|
|
92
|
+
rotationReason: reason,
|
|
93
|
+
retention: { maxArchives, maxArchiveBytes, retainedBytes, removed },
|
|
94
|
+
};
|
|
95
|
+
const temporary = `${evidenceFile}.${process.pid}.tmp`;
|
|
96
|
+
fs.writeFileSync(temporary, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
97
|
+
fs.renameSync(temporary, evidenceFile);
|
|
98
|
+
process.stdout.write(`${JSON.stringify(evidence)}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
main();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`metro-log-generation: ${error instanceof Error ? error.message : String(error)}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const WebSocket = require('ws');
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const args = { port: process.env.WATCHER_PORT || process.env.METRO_PORT || '8081', json: false };
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
if (argv[index] === '--port') args.port = argv[++index];
|
|
12
|
+
else if (argv[index] === '--json') args.json = true;
|
|
13
|
+
else if (argv[index] === '--help' || argv[index] === '-h') {
|
|
14
|
+
console.log('Usage: reload-app.mjs [--port <Metro port>] [--json]');
|
|
15
|
+
process.exit(0);
|
|
16
|
+
} else {
|
|
17
|
+
throw new Error(`Unknown argument: ${argv[index]}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function reload(port) {
|
|
24
|
+
const numericPort = Number.parseInt(String(port), 10);
|
|
25
|
+
if (!Number.isInteger(numericPort) || numericPort <= 0) {
|
|
26
|
+
throw new Error(`Metro port is invalid: ${port}`);
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolve, reject) => {
|
|
29
|
+
const socket = new WebSocket(`ws://127.0.0.1:${numericPort}/message`);
|
|
30
|
+
const timeout = setTimeout(() => {
|
|
31
|
+
socket.close();
|
|
32
|
+
reject(new Error(`Metro reload timed out on port ${numericPort}`));
|
|
33
|
+
}, 5000);
|
|
34
|
+
socket.on('open', () => {
|
|
35
|
+
socket.send(JSON.stringify({ method: 'reload', version: 2 }));
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
socket.close();
|
|
39
|
+
resolve();
|
|
40
|
+
}, 50);
|
|
41
|
+
});
|
|
42
|
+
socket.on('error', () => {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
reject(new Error(`Metro is not reachable on port ${numericPort}`));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
adapter: 'mobile',
|
|
50
|
+
method: 'metro-message',
|
|
51
|
+
command: 'reload',
|
|
52
|
+
port: numericPort,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = parseArgs(process.argv.slice(2));
|
|
57
|
+
reload(args.port)
|
|
58
|
+
.then((result) => {
|
|
59
|
+
if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
60
|
+
else console.log(`Reload requested through Metro :${result.port}`);
|
|
61
|
+
})
|
|
62
|
+
.catch((error) => {
|
|
63
|
+
const result = { ok: false, adapter: 'mobile', error: String(error?.message || error) };
|
|
64
|
+
if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
65
|
+
else console.error(result.error);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
});
|
|
@@ -32,12 +32,27 @@ APP_LOG_FILE="$LOG_DIR/app-console.log"
|
|
|
32
32
|
{ [ -f "$FORWARDER" ] && command -v node >/dev/null 2>&1; } || exit 0
|
|
33
33
|
mkdir -p "$LOG_DIR"
|
|
34
34
|
|
|
35
|
+
stopped_pids=()
|
|
36
|
+
stop_forwarder() {
|
|
37
|
+
local pid="$1"
|
|
38
|
+
case " ${stopped_pids[*]-} " in
|
|
39
|
+
*" $pid "*) return ;;
|
|
40
|
+
esac
|
|
41
|
+
stopped_pids+=("$pid")
|
|
42
|
+
kill "$pid" 2>/dev/null || true
|
|
43
|
+
for _ in {1..20}; do
|
|
44
|
+
kill -0 "$pid" 2>/dev/null || return
|
|
45
|
+
sleep 0.05
|
|
46
|
+
done
|
|
47
|
+
kill -KILL "$pid" 2>/dev/null || true
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
# One debugger owns a React Native page. Reap any collector for this Metro port,
|
|
36
51
|
# including one left by an older globally installed harness.
|
|
37
52
|
while read -r pid cmd; do
|
|
38
53
|
case "$cmd" in
|
|
39
54
|
*console-forwarder.cjs*" --port $PORT "*|*console-forwarder.cjs*" --port=$PORT "*)
|
|
40
|
-
|
|
55
|
+
stop_forwarder "$pid"
|
|
41
56
|
;;
|
|
42
57
|
esac
|
|
43
58
|
done < <(ps -axo pid=,command= 2>/dev/null || true)
|
|
@@ -46,7 +61,7 @@ if [ -f "$PID_FILE" ]; then
|
|
|
46
61
|
old_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
|
47
62
|
if [ -n "$old_pid" ]; then
|
|
48
63
|
case "$(ps -p "$old_pid" -o command= 2>/dev/null)" in
|
|
49
|
-
*console-forwarder.cjs*)
|
|
64
|
+
*console-forwarder.cjs*) stop_forwarder "$old_pid" ;;
|
|
50
65
|
esac
|
|
51
66
|
fi
|
|
52
67
|
rm -f "$PID_FILE"
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# Checks if Metro is already running and owned by this checkout with required env vars.
|
|
5
5
|
# Clears stale or foreign listeners before starting. Uses the checkout's canonical
|
|
6
6
|
# yarn watch script and sets EXPO_NO_TYPESCRIPT_SETUP=1. Cache clearing is
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# passed to Expo only for the explicit --clear option. Metro defaults to four
|
|
8
|
+
# workers; METRO_MAX_WORKERS remains an explicit override.
|
|
9
9
|
#
|
|
10
10
|
# Inputs:
|
|
11
11
|
# --target <metamask-mobile dir> (default $PWD)
|
|
@@ -68,6 +68,8 @@ mkdir -p "$LOG_DIR"
|
|
|
68
68
|
LOG_FILE="$LOG_DIR/metro.log"
|
|
69
69
|
PID_FILE="$LOG_DIR/metro.pid"
|
|
70
70
|
METRO_BUILD_ENV_FILE="$LOG_DIR/metro-build-env.sh"
|
|
71
|
+
METRO_LOG_GENERATION="$SCRIPT_DIR/metro-log-generation.cjs"
|
|
72
|
+
METRO_LOG_COALESCER="$SCRIPT_DIR/coalesce-metro-log.cjs"
|
|
71
73
|
|
|
72
74
|
# --- helpers ------------------------------------------------------------------
|
|
73
75
|
|
|
@@ -172,23 +174,21 @@ write_metro_runner() {
|
|
|
172
174
|
#!/usr/bin/env bash
|
|
173
175
|
set -uo pipefail
|
|
174
176
|
cd "$(printf '%q' "$TARGET")"
|
|
175
|
-
: > "$(printf '%q' "$LOG_FILE")"
|
|
176
177
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
177
178
|
export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
|
|
178
179
|
$worker_env_line
|
|
179
|
-
BASH_ENV=$(printf '%q' "$METRO_BUILD_ENV_FILE") command yarn $(printf '%q' "$watcher_script") 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
|
|
180
|
+
BASH_ENV=$(printf '%q' "$METRO_BUILD_ENV_FILE") command yarn $(printf '%q' "$watcher_script") 2>&1 | node $(printf '%q' "$METRO_LOG_COALESCER") | tee -a "$(printf '%q' "$LOG_FILE")"
|
|
180
181
|
EOF
|
|
181
182
|
chmod +x "$runner"
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
start_metro_tmux() {
|
|
186
|
+
local runner="$1"
|
|
185
187
|
command -v tmux >/dev/null 2>&1 || return 1
|
|
186
|
-
local session window
|
|
188
|
+
local session window
|
|
187
189
|
session="$(resolve_run_tmux_session "$LOG_DIR")"
|
|
188
190
|
{ [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 1
|
|
189
191
|
window="metro-${PORT}"
|
|
190
|
-
runner="$LOG_DIR/run-metro-${PORT}.sh"
|
|
191
|
-
write_metro_runner "$runner"
|
|
192
192
|
tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
|
|
193
193
|
tmux new-window -d -t "$session" -n "$window" "exec $(printf '%q' "$runner")" || return 1
|
|
194
194
|
printf '%s:%s\n' "$session" "$window" > "$LOG_DIR/metro.tmux"
|
|
@@ -227,8 +227,8 @@ fi
|
|
|
227
227
|
|
|
228
228
|
stop_metro_listener || exit 1
|
|
229
229
|
|
|
230
|
-
METRO_WORKERS="${METRO_MAX_WORKERS:-}"
|
|
231
|
-
if
|
|
230
|
+
METRO_WORKERS="${METRO_MAX_WORKERS:-4}"
|
|
231
|
+
if ! printf '%s' "$METRO_WORKERS" | grep -Eq '^[1-9][0-9]*$'; then
|
|
232
232
|
printf 'start-metro: METRO_MAX_WORKERS must be a positive integer, got %s\n' "$METRO_WORKERS" >&2
|
|
233
233
|
exit 2
|
|
234
234
|
fi
|
|
@@ -240,15 +240,18 @@ fi
|
|
|
240
240
|
CLEAR_LABEL=""
|
|
241
241
|
[ "$CLEAR" = true ] && CLEAR_LABEL=", clear"
|
|
242
242
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
"$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
|
|
246
|
-
else
|
|
247
|
-
printf 'Starting Metro on port %s (workers=Metro default%s)\n' \
|
|
248
|
-
"$PORT" "$CLEAR_LABEL" >&2
|
|
249
|
-
fi
|
|
243
|
+
printf 'Starting Metro on port %s (workers=%s%s)\n' \
|
|
244
|
+
"$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
|
|
250
245
|
printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
|
|
251
246
|
|
|
247
|
+
[ -f "$METRO_LOG_GENERATION" ] && [ -f "$METRO_LOG_COALESCER" ] || {
|
|
248
|
+
printf 'start-metro: Metro log generation helpers are missing; reinstall the runner\n' >&2
|
|
249
|
+
exit 1
|
|
250
|
+
}
|
|
251
|
+
ROTATION_REASON="metro-start"
|
|
252
|
+
[ "$CLEAR" = true ] && ROTATION_REASON="clear-cache-restart"
|
|
253
|
+
node "$METRO_LOG_GENERATION" "$LOG_DIR" "$PORT" "$ROTATION_REASON" >/dev/null || exit 1
|
|
254
|
+
|
|
252
255
|
write_metro_build_env || {
|
|
253
256
|
printf 'start-metro: could not stage the scoped Metro environment\n' >&2
|
|
254
257
|
exit 1
|
|
@@ -256,13 +259,14 @@ write_metro_build_env || {
|
|
|
256
259
|
|
|
257
260
|
STARTED_METRO_PID=""
|
|
258
261
|
STARTED_METRO_TMUX=""
|
|
259
|
-
|
|
262
|
+
METRO_RUNNER="$LOG_DIR/run-metro-${PORT}.sh"
|
|
263
|
+
write_metro_runner "$METRO_RUNNER"
|
|
264
|
+
if start_metro_tmux "$METRO_RUNNER"; then
|
|
260
265
|
STARTED_METRO_TMUX="$(cat "$LOG_DIR/metro.tmux" 2>/dev/null || true)"
|
|
261
266
|
else
|
|
262
267
|
# Metro runs detached, writing to the log. The tmux window is a read-only tail.
|
|
263
268
|
(
|
|
264
269
|
cd "$TARGET"
|
|
265
|
-
: > "$LOG_FILE"
|
|
266
270
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
267
271
|
export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
|
|
268
272
|
if [ -n "$METRO_WORKERS" ]; then
|
|
@@ -276,6 +280,7 @@ else
|
|
|
276
280
|
--log "$LOG_FILE"
|
|
277
281
|
--pid-file "$PID_FILE"
|
|
278
282
|
--build-env "$METRO_BUILD_ENV_FILE"
|
|
283
|
+
--runner "$METRO_RUNNER"
|
|
279
284
|
)
|
|
280
285
|
[ -z "$METRO_WORKERS" ] || launcher_args+=(--workers "$METRO_WORKERS")
|
|
281
286
|
[ "$CLEAR" = true ] && launcher_args+=(--clear)
|
|
@@ -21,7 +21,7 @@ while [ $# -gt 0 ]; do
|
|
|
21
21
|
esac
|
|
22
22
|
done
|
|
23
23
|
|
|
24
|
-
TARGET="$(cd "$TARGET" && pwd)"
|
|
24
|
+
TARGET="$(cd "$TARGET" && pwd -P)"
|
|
25
25
|
|
|
26
26
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
27
|
# shellcheck disable=SC1091
|
|
@@ -83,15 +83,14 @@ if [ -n "$fwd_pid" ]; then
|
|
|
83
83
|
esac
|
|
84
84
|
fi
|
|
85
85
|
rm -f "$FWD_PID_FILE"
|
|
86
|
-
pkill -f "console-forwarder.cjs --port
|
|
86
|
+
pkill -f "console-forwarder.cjs --port $PORT --out $TARGET/" 2>/dev/null
|
|
87
87
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# Guarded: the reap lib may be absent in a minimal install; skip rather than abort.
|
|
88
|
+
# Reap a detached bundler for this exact port when its listener disappeared
|
|
89
|
+
# before the normal stop path. Other ports in the same checkout are separate
|
|
90
|
+
# runtime generations and must remain untouched.
|
|
92
91
|
if [ -f "$SCRIPT_DIR/../shared/reap-checkout-metros.sh" ]; then
|
|
93
92
|
. "$SCRIPT_DIR/../shared/reap-checkout-metros.sh"
|
|
94
|
-
|
|
93
|
+
reap_checkout_metros_on_port "$TARGET" "$PORT" || true
|
|
95
94
|
fi
|
|
96
95
|
|
|
97
96
|
# Close the read-only log-tail window start-metro opened, if it is still there.
|
|
@@ -103,6 +102,15 @@ if [ -f "$TMUX_FILE" ] && command -v tmux >/dev/null 2>&1; then
|
|
|
103
102
|
fi
|
|
104
103
|
rm -f "$TMUX_FILE"
|
|
105
104
|
|
|
105
|
+
# A stopped checkout must not remain in Watchman's global root set. Metro will
|
|
106
|
+
# add the root again on the next launch; removing only this exact target keeps
|
|
107
|
+
# every other active slot untouched.
|
|
108
|
+
if command -v watchman >/dev/null 2>&1; then
|
|
109
|
+
if watchman watch-del "$TARGET" >/dev/null 2>&1; then
|
|
110
|
+
printf 'Removed Watchman root %s\n' "$TARGET" >&2
|
|
111
|
+
fi
|
|
112
|
+
fi
|
|
113
|
+
|
|
106
114
|
# Reaching here = cleanup done (nothing needed stopping, or it stopped and
|
|
107
115
|
# windows/pids were cleared). The idempotent-stop contract is success; do not
|
|
108
116
|
# leak an incidental non-zero from the last command on any platform.
|