@deeeed/metamask-harness 0.36.0 → 0.38.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 +51 -0
- package/adapters/extension/artifact-runtime-state.cjs +138 -0
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +24 -0
- package/adapters/extension/live.sh +82 -12
- package/adapters/extension/readiness.mjs +77 -36
- package/adapters/extension/snapshot-dist.sh +88 -3
- package/adapters/extension/verify.sh +6 -2
- package/adapters/manifest.json +17 -1
- package/adapters/shared/log-tui.mjs +1 -1
- package/dist/adapters/extension/artifact-integrity.js +38 -0
- package/dist/adapters/extension/extension-id.js +23 -4
- package/dist/adapters/extension/release-artifact.js +386 -0
- package/dist/adapters/extension/runtime-decision.js +164 -20
- package/dist/adapters/extension/runtime.js +169 -3
- package/dist/adapters/mobile/provision.js +38 -3
- package/dist/adapters/mobile/release-artifact-state.js +124 -0
- package/dist/adapters/mobile/release-artifact.js +295 -0
- package/dist/adapters.js +78 -6
- package/dist/app-lifecycle.js +4 -3
- package/dist/command-contract.js +16 -0
- package/dist/commands/call.js +6 -2
- package/dist/commands/launch/extension.js +74 -9
- package/dist/commands/launch/index.js +3 -0
- package/dist/commands/launch/mobile.js +2 -0
- package/dist/commands/provision.js +2 -0
- package/dist/commands/run-engine.js +109 -14
- package/dist/commands/run.js +4 -2
- package/dist/commands/runtime-launch.js +178 -10
- package/dist/heal-bounds.js +1 -1
- package/dist/live-adapter-contract.js +3 -1
- package/dist/metamask-action-validation.js +47 -1
- package/dist/mm-harness-cli.js +31 -1
- package/dist/recipe-security.js +40 -0
- package/dist/run-diagnostics.js +1 -1
- package/dist/runner.js +3 -0
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
- package/library/actions/extension/assets/finish_send.mjs +128 -0
- package/library/actions/extension/assets/import_custom_token.mjs +227 -0
- package/library/actions/extension/assets/prepare_send.mjs +53 -0
- package/library/actions/extension/assets/read_details.mjs +57 -0
- package/library/actions/extension/assets/read_visible_state.mjs +50 -0
- package/library/actions/extension/assets/review_send.mjs +23 -0
- package/library/actions/extension/assets/select_network_scope.mjs +30 -0
- package/library/actions/extension/assets/send.mjs +104 -0
- package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
- package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
- package/library/actions/extension/assets/verify_sorting.mjs +89 -0
- package/library/actions/extension/networks/add_chainlist.mjs +94 -0
- package/library/actions/extension/networks/add_custom.mjs +123 -0
- package/library/actions/extension/networks/custom_network.mjs +103 -0
- package/library/actions/extension/networks/read_visible_state.mjs +50 -0
- package/library/actions/extension/networks/remove_custom.mjs +68 -0
- package/library/actions/extension/perps/close_orders.mjs +1 -0
- package/library/actions/extension/perps/close_visible_position.mjs +133 -0
- package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
- package/library/actions/extension/perps/edit_margin.mjs +82 -0
- package/library/actions/extension/perps/open_balance_action.mjs +58 -0
- package/library/actions/extension/perps/open_position_action.mjs +80 -0
- package/library/actions/extension/perps/perps.mjs +196 -2
- package/library/actions/extension/perps/place_order.mjs +5 -2
- package/library/actions/extension/perps/read_eligibility.mjs +34 -0
- package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
- package/library/actions/extension/perps/read_snapshot.mjs +470 -0
- package/library/actions/extension/perps/read_visible_state.mjs +98 -0
- package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
- package/library/actions/extension/perps/select_market_filter.mjs +71 -0
- package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
- package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
- package/library/actions/extension/platform/cdp.mjs +109 -3
- package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
- package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
- package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
- package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
- package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
- package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
- package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
- package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
- package/library/actions/extension/ui/navigate.mjs +264 -8
- package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
- package/library/actions/extension/wallet/import.mjs +13 -46
- package/library/actions/extension/wallet/lock.mjs +35 -0
- package/library/actions/extension/wallet/secret-input.mjs +98 -0
- package/library/actions/extension/wallet/select_account.mjs +71 -7
- package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
- package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
- package/library/actions/mobile/platform/native-session-name.mjs +5 -0
- package/library/actions/mobile/platform/observe-ui.mjs +86 -2
- package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
- package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
- package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
- package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
- package/library/actions/mobile/ui/native-navigation.mjs +265 -0
- package/library/actions/mobile/ui/navigate.mjs +10 -1
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
- package/library/actions/mobile/wallet/import.mjs +71 -2
- package/library/actions/mobile/wallet/native-ui.mjs +512 -0
- package/library/actions/mobile/wallet/read_state.mjs +16 -0
- package/library/actions/mobile/wallet/reset.mjs +17 -4
- package/library/actions/shared/assets/visible-state.mjs +176 -0
- package/library/actions/shared/networks/visible-state.mjs +70 -0
- package/library/actions/shared/perps/visible-state.mjs +354 -0
- package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
- package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
- package/library/actions/shared/ui/locators.mjs +7 -0
- package/library/manifests/extension.action-manifest.json +1366 -184
- package/library/manifests/mobile.action-manifest.json +235 -1
- package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
- package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
- package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
- package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
- package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
- package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
- package/library/recipes/assets/release-token-details.recipe.json +48 -0
- package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
- package/library/recipes/assets/release-token-list.recipe.json +75 -0
- package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
- package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
- package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
- package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
- package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
- package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
- package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
- package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
- package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
- package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
- package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
- package/library/recipes/perps/release-market-details.recipe.json +83 -0
- package/library/recipes/perps/release-market-search.recipe.json +79 -0
- package/library/recipes/perps/release-watchlist.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
- package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
- package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
- package/library/recipes/wallet/import.recipe.json +20 -1
- package/library/recipes/wallet/reset-import.recipe.json +20 -1
- package/package.json +6 -5
- package/scripts/validate-release-report.mjs +319 -0
|
@@ -83,11 +83,15 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
|
|
|
83
83
|
(target2) => target2.type === "page" && String(target2.url ?? "").startsWith("chrome-extension://") && String(target2.url ?? "").includes("/sidepanel.html") && Boolean(target2.webSocketDebuggerUrl)
|
|
84
84
|
);
|
|
85
85
|
const extensionTargets = pageMode === "home-or-sidepanel" && homeTargets.length === 0 ? sidepanelTargets : homeTargets;
|
|
86
|
-
if (extensionTargets.length
|
|
87
|
-
const expected = pageMode === "home-or-sidepanel" ? "
|
|
86
|
+
if (extensionTargets.length === 0) {
|
|
87
|
+
const expected = pageMode === "home-or-sidepanel" ? "a MetaMask extension home page target, or one sidepanel target when home is intentionally closed" : "a MetaMask extension home page target";
|
|
88
88
|
findings.push(`Expected ${expected}, found ${extensionTargets.length}.`);
|
|
89
89
|
}
|
|
90
|
-
const
|
|
90
|
+
const targetSelection = await selectResponsiveExtensionTarget(extensionTargets, cdpCallTimeoutMs);
|
|
91
|
+
const target = targetSelection.target;
|
|
92
|
+
if (extensionTargets.length > 1) {
|
|
93
|
+
warnings.push(`Found ${extensionTargets.length} MetaMask Extension page targets; health selected ${target?.url ?? "none"} after probing each candidate.`);
|
|
94
|
+
}
|
|
91
95
|
if (!target?.webSocketDebuggerUrl) {
|
|
92
96
|
return failReport(cdpPort, findings, {
|
|
93
97
|
targetUrls: targets.map((entry) => entry.url).filter(Boolean)
|
|
@@ -147,6 +151,12 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
|
|
|
147
151
|
};
|
|
148
152
|
}
|
|
149
153
|
const runtime = await evaluateHealth(session, cdpCallTimeoutMs);
|
|
154
|
+
if (runtime.hasSubmitRequest !== true) {
|
|
155
|
+
const providerProbe = await probeUiEthereumProvider(session, cdpCallTimeoutMs);
|
|
156
|
+
runtime.evmRpcProbeOk = providerProbe.ok;
|
|
157
|
+
runtime.evmRpcProbeError = providerProbe.error;
|
|
158
|
+
runtime.evmRpcProbeSource = providerProbe.source;
|
|
159
|
+
}
|
|
150
160
|
if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
|
|
151
161
|
findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
|
|
152
162
|
}
|
|
@@ -199,6 +209,41 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
|
|
|
199
209
|
session?.close();
|
|
200
210
|
}
|
|
201
211
|
}
|
|
212
|
+
async function selectResponsiveExtensionTarget(targets, timeoutMs) {
|
|
213
|
+
if (targets.length <= 1) return { target: targets[0] };
|
|
214
|
+
const probeTimeoutMs = Math.min(1e3, timeoutMs);
|
|
215
|
+
const ranked = [];
|
|
216
|
+
for (const [index, target] of targets.entries()) {
|
|
217
|
+
let session;
|
|
218
|
+
try {
|
|
219
|
+
session = await CdpSession.connect(target.webSocketDebuggerUrl, { timeoutMs: probeTimeoutMs });
|
|
220
|
+
await boundedCdpCall(session, "Runtime.enable", {}, probeTimeoutMs);
|
|
221
|
+
await boundedCdpCall(session, "Page.enable", {}, probeTimeoutMs);
|
|
222
|
+
const compositor = await probeCdpCompositorInteractivity(session, probeTimeoutMs);
|
|
223
|
+
const result = await boundedCdpCall(session, "Runtime.evaluate", {
|
|
224
|
+
expression: `(() => ({
|
|
225
|
+
readyState: String(document.readyState || ''),
|
|
226
|
+
href: String(location.href || ''),
|
|
227
|
+
text: String(document.body?.innerText || '').slice(0, 5000),
|
|
228
|
+
}))()`,
|
|
229
|
+
returnByValue: true
|
|
230
|
+
}, probeTimeoutMs);
|
|
231
|
+
const value = result.result?.value ?? {};
|
|
232
|
+
const text = String(value.text ?? "");
|
|
233
|
+
const href = String(value.href ?? target.url ?? "");
|
|
234
|
+
const blocked = text.includes("Background connection unresponsive") || text.includes("MetaMask had trouble starting") || text.includes("Unable to connect to Ethereum");
|
|
235
|
+
const onboarding = href.includes("/onboarding/");
|
|
236
|
+
const score = (compositor.status === "ready" ? 100 : compositor.status === "not-interactive" ? 10 : 0) + (value.readyState === "complete" ? 10 : 0) + (blocked ? 0 : 5) + (onboarding ? 0 : 2);
|
|
237
|
+
ranked.push({ target, score, index });
|
|
238
|
+
} catch {
|
|
239
|
+
ranked.push({ target, score: -1, index });
|
|
240
|
+
} finally {
|
|
241
|
+
session?.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
ranked.sort((left, right) => right.score - left.score || left.index - right.index);
|
|
245
|
+
return { target: ranked[0]?.target };
|
|
246
|
+
}
|
|
202
247
|
function failReport(cdpPort, findings, details, extensionPageTargets = 0) {
|
|
203
248
|
return {
|
|
204
249
|
status: "FAIL",
|
|
@@ -316,6 +361,127 @@ async function evaluateHealth(session, timeoutMs) {
|
|
|
316
361
|
}
|
|
317
362
|
return result.result?.value ?? {};
|
|
318
363
|
}
|
|
364
|
+
async function probeUiEthereumProvider(session, timeoutMs) {
|
|
365
|
+
const bridgeKey = "mm-harness.runtime.evm-provider-probe";
|
|
366
|
+
const preload = await boundedCdpCall(
|
|
367
|
+
session,
|
|
368
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
369
|
+
{
|
|
370
|
+
source: `(() => {
|
|
371
|
+
const bridgeKey = Symbol.for(${JSON.stringify(bridgeKey)});
|
|
372
|
+
let provider;
|
|
373
|
+
Object.defineProperty(globalThis, 'ethereumProvider', {
|
|
374
|
+
configurable: true,
|
|
375
|
+
get() { return provider; },
|
|
376
|
+
set(value) { provider = value; },
|
|
377
|
+
});
|
|
378
|
+
Object.defineProperty(globalThis, bridgeKey, {
|
|
379
|
+
configurable: true,
|
|
380
|
+
value: {
|
|
381
|
+
async probe() {
|
|
382
|
+
if (!provider || typeof provider.request !== 'function') {
|
|
383
|
+
return { ok: false, error: 'Ethereum provider is unavailable' };
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const code = await provider.request({
|
|
387
|
+
method: 'eth_getCode',
|
|
388
|
+
params: ['0x0000000000000000000000000000000000000000', 'latest'],
|
|
389
|
+
});
|
|
390
|
+
const ok = typeof code === 'string' && /^0x[0-9a-f]*$/iu.test(code);
|
|
391
|
+
return {
|
|
392
|
+
ok,
|
|
393
|
+
error: ok ? null : 'Ethereum provider returned invalid contract code',
|
|
394
|
+
};
|
|
395
|
+
} catch (error) {
|
|
396
|
+
return { ok: false, error: String(error?.message || error) };
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
cleanup() {
|
|
400
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'ethereumProvider');
|
|
401
|
+
if (descriptor?.configurable === true) {
|
|
402
|
+
Object.defineProperty(globalThis, 'ethereumProvider', {
|
|
403
|
+
configurable: true,
|
|
404
|
+
writable: true,
|
|
405
|
+
value: provider,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return delete globalThis[bridgeKey];
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
})();`
|
|
413
|
+
},
|
|
414
|
+
timeoutMs
|
|
415
|
+
);
|
|
416
|
+
if (typeof preload?.identifier !== "string" || preload.identifier.length === 0) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
error: "Ethereum provider preload was not installed",
|
|
420
|
+
source: "ui-ethereum-provider"
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
let outcome = {
|
|
424
|
+
ok: false,
|
|
425
|
+
error: "Ethereum provider is unavailable",
|
|
426
|
+
source: "ui-ethereum-provider"
|
|
427
|
+
};
|
|
428
|
+
const cleanupErrors = [];
|
|
429
|
+
try {
|
|
430
|
+
await boundedCdpCall(session, "Page.reload", { ignoreCache: false }, timeoutMs);
|
|
431
|
+
const deadline = Date.now() + timeoutMs;
|
|
432
|
+
while (Date.now() <= deadline) {
|
|
433
|
+
try {
|
|
434
|
+
const result = await boundedCdpCall(session, "Runtime.evaluate", {
|
|
435
|
+
expression: `globalThis[Symbol.for(${JSON.stringify(bridgeKey)})]?.probe()`,
|
|
436
|
+
awaitPromise: true,
|
|
437
|
+
returnByValue: true
|
|
438
|
+
}, Math.max(1, deadline - Date.now()));
|
|
439
|
+
const value = result.result?.value;
|
|
440
|
+
if (value?.ok === true) {
|
|
441
|
+
outcome = { ok: true, error: null, source: "ui-ethereum-provider" };
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
if (typeof value?.error === "string") outcome.error = value.error;
|
|
445
|
+
} catch (error) {
|
|
446
|
+
outcome.error = messageOf(error);
|
|
447
|
+
}
|
|
448
|
+
if (outcome.ok) break;
|
|
449
|
+
await sleep(100);
|
|
450
|
+
}
|
|
451
|
+
} catch (error) {
|
|
452
|
+
outcome = { ok: false, error: messageOf(error), source: "ui-ethereum-provider" };
|
|
453
|
+
} finally {
|
|
454
|
+
try {
|
|
455
|
+
const cleanup = await boundedCdpCall(session, "Runtime.evaluate", {
|
|
456
|
+
expression: `(() => {
|
|
457
|
+
const key = Symbol.for(${JSON.stringify(bridgeKey)});
|
|
458
|
+
const bridge = globalThis[key];
|
|
459
|
+
if (!bridge) return true;
|
|
460
|
+
return bridge.cleanup() === true && !(key in globalThis);
|
|
461
|
+
})()`,
|
|
462
|
+
returnByValue: true
|
|
463
|
+
}, timeoutMs);
|
|
464
|
+
if (cleanup.result?.value !== true) cleanupErrors.push("page bridge removal was not confirmed");
|
|
465
|
+
} catch (error) {
|
|
466
|
+
cleanupErrors.push(messageOf(error));
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
await boundedCdpCall(
|
|
470
|
+
session,
|
|
471
|
+
"Page.removeScriptToEvaluateOnNewDocument",
|
|
472
|
+
{ identifier: preload.identifier },
|
|
473
|
+
timeoutMs
|
|
474
|
+
);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
cleanupErrors.push(messageOf(error));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return cleanupErrors.length > 0 ? {
|
|
480
|
+
ok: false,
|
|
481
|
+
error: `Ethereum provider probe cleanup failed: ${cleanupErrors.join("; ")}`,
|
|
482
|
+
source: "ui-ethereum-provider"
|
|
483
|
+
} : outcome;
|
|
484
|
+
}
|
|
319
485
|
function extensionBackgroundProbeTimeoutMs(cdpCallTimeoutMs) {
|
|
320
486
|
return Math.max(1, Math.floor(cdpCallTimeoutMs * 0.8));
|
|
321
487
|
}
|
|
@@ -14,6 +14,8 @@ const RUNWAY_IOS_METADATA = {
|
|
|
14
14
|
};
|
|
15
15
|
class SimulatorTargetError extends Error {
|
|
16
16
|
}
|
|
17
|
+
class SimulatorUnresponsiveError extends Error {
|
|
18
|
+
}
|
|
17
19
|
function resolvePoolIdentity(target) {
|
|
18
20
|
const out = resolveSlotPortsByRepo(target);
|
|
19
21
|
const id = {};
|
|
@@ -75,7 +77,7 @@ async function provisionRunwayMobile(target, options) {
|
|
|
75
77
|
const sim = ensureSimulator(simulator, runtime, deviceType);
|
|
76
78
|
let resolved;
|
|
77
79
|
let reason = options.force ? "forced" : "fresh-install";
|
|
78
|
-
if (!options.force &&
|
|
80
|
+
if (!options.force && appInstalledWithRecovery(sim.udid ?? sim.name, options)) {
|
|
79
81
|
const installed = readInstalledBaselineArtifact(resolvedTarget, sim, options.runtimeDir);
|
|
80
82
|
let requestedRunId = options.run;
|
|
81
83
|
if (installed && !requestedRunId) {
|
|
@@ -445,6 +447,24 @@ function bootSimulator(device, options) {
|
|
|
445
447
|
log(options, `runway: bootstatus wait for ${device} did not confirm; continuing to install`);
|
|
446
448
|
}
|
|
447
449
|
}
|
|
450
|
+
function restartSimulator(device, options) {
|
|
451
|
+
log(options, `runway: simulator ${device} did not answer the app probe; restarting it once`);
|
|
452
|
+
execFileSync("xcrun", ["simctl", "shutdown", device], {
|
|
453
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
454
|
+
timeout: 3e4
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
execFileSync("xcrun", ["simctl", "boot", device], {
|
|
458
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
459
|
+
timeout: 3e4
|
|
460
|
+
});
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
execFileSync("xcrun", ["simctl", "bootstatus", device, "-b"], {
|
|
464
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
465
|
+
timeout: 12e4
|
|
466
|
+
});
|
|
467
|
+
}
|
|
448
468
|
function preapproveDeepLinkScheme(device, bundleId, options) {
|
|
449
469
|
const scheme = process.env.IOS_DEV_CLIENT_SCHEME ?? "expo-metamask";
|
|
450
470
|
try {
|
|
@@ -572,12 +592,27 @@ function readInstalledBaselineArtifact(target, sim, runtimeDir) {
|
|
|
572
592
|
}
|
|
573
593
|
function appInstalled(device) {
|
|
574
594
|
try {
|
|
575
|
-
execFileSync("xcrun", ["simctl", "get_app_container", device, RUNWAY_IOS_METADATA.bundleId, "app"], {
|
|
595
|
+
execFileSync("xcrun", ["simctl", "get_app_container", device, RUNWAY_IOS_METADATA.bundleId, "app"], {
|
|
596
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
597
|
+
timeout: 15e3
|
|
598
|
+
});
|
|
576
599
|
return true;
|
|
577
|
-
} catch {
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (error instanceof Error && "code" in error && error.code === "ETIMEDOUT") {
|
|
602
|
+
throw new SimulatorUnresponsiveError(`simulator ${device} timed out while checking the installed app`);
|
|
603
|
+
}
|
|
578
604
|
return false;
|
|
579
605
|
}
|
|
580
606
|
}
|
|
607
|
+
function appInstalledWithRecovery(device, options) {
|
|
608
|
+
try {
|
|
609
|
+
return appInstalled(device);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (!(error instanceof SimulatorUnresponsiveError)) throw error;
|
|
612
|
+
restartSimulator(device, options);
|
|
613
|
+
return appInstalled(device);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
581
616
|
function writeRunwayBaseline(target, slot, platform, resolved, artifact, simulator, alreadyInstalled, runtimeDir, preservedArtifact) {
|
|
582
617
|
const file = runwayBaselinePath(target, runtimeDir);
|
|
583
618
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { recipeRuntimePath } from "../../paths.js";
|
|
5
|
+
import {
|
|
6
|
+
assertAndroidReleaseArtifactRuntime
|
|
7
|
+
} from "./release-artifact.js";
|
|
8
|
+
function mobileReleaseArtifactStatePath(target) {
|
|
9
|
+
return recipeRuntimePath(target, "mobile-release-artifact.json");
|
|
10
|
+
}
|
|
11
|
+
function writeMobileReleaseArtifactState(target, report) {
|
|
12
|
+
const state = {
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
adapter: "mobile",
|
|
15
|
+
platform: "android",
|
|
16
|
+
packageId: report.packageId,
|
|
17
|
+
versionName: report.versionName,
|
|
18
|
+
versionCode: report.versionCode,
|
|
19
|
+
sha256: report.sha256,
|
|
20
|
+
deviceSerial: report.deviceSerial,
|
|
21
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
22
|
+
};
|
|
23
|
+
const file = mobileReleaseArtifactStatePath(target);
|
|
24
|
+
const directory = ensureSafeRuntimeDirectory(target, file);
|
|
25
|
+
const existing = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
26
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw invalidState(file);
|
|
27
|
+
const temporary = path.join(
|
|
28
|
+
directory,
|
|
29
|
+
`.mobile-release-artifact.${process.pid}.${randomBytes(8).toString("hex")}.tmp`
|
|
30
|
+
);
|
|
31
|
+
let descriptor;
|
|
32
|
+
try {
|
|
33
|
+
descriptor = fs.openSync(temporary, "wx", 384);
|
|
34
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}
|
|
35
|
+
`, "utf8");
|
|
36
|
+
fs.fsyncSync(descriptor);
|
|
37
|
+
fs.closeSync(descriptor);
|
|
38
|
+
descriptor = void 0;
|
|
39
|
+
fs.renameSync(temporary, file);
|
|
40
|
+
} finally {
|
|
41
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
42
|
+
const temporaryStat = fs.lstatSync(temporary, { throwIfNoEntry: false });
|
|
43
|
+
if (temporaryStat) fs.unlinkSync(temporary);
|
|
44
|
+
}
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
function requireCurrentMobileReleaseArtifactState(target, verify = assertAndroidReleaseArtifactRuntime) {
|
|
48
|
+
const state = readMobileReleaseArtifactState(target);
|
|
49
|
+
if (state) verify(state);
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
function clearMobileReleaseArtifactState(target) {
|
|
53
|
+
const file = mobileReleaseArtifactStatePath(target);
|
|
54
|
+
const existing = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
55
|
+
if (!existing) return;
|
|
56
|
+
ensureSafeRuntimeDirectory(target, file);
|
|
57
|
+
if (!existing.isFile() || existing.isSymbolicLink()) throw invalidState(file);
|
|
58
|
+
fs.unlinkSync(file);
|
|
59
|
+
}
|
|
60
|
+
function readMobileReleaseArtifactState(target) {
|
|
61
|
+
const file = mobileReleaseArtifactStatePath(target);
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = fs.lstatSync(file);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (isMissing(error)) return null;
|
|
67
|
+
throw invalidState(file);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16 * 1024) {
|
|
71
|
+
throw invalidState(file);
|
|
72
|
+
}
|
|
73
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
74
|
+
if (value.schemaVersion !== 1 || value.adapter !== "mobile" || value.platform !== "android" || !text(value.packageId) || !text(value.versionName) || !text(value.versionCode) || !/^[a-f0-9]{64}$/u.test(value.sha256 ?? "") || !text(value.deviceSerial) || !text(value.installedAt)) throw invalidState(file);
|
|
75
|
+
return value;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof Error && error.message.startsWith("The Mobile release artifact state is invalid")) {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
throw invalidState(file);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function text(value) {
|
|
84
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
85
|
+
}
|
|
86
|
+
function isMissing(error) {
|
|
87
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
88
|
+
}
|
|
89
|
+
function invalidState(file) {
|
|
90
|
+
return new Error(`The Mobile release artifact state is invalid: ${file}. Next: rerun runtime-launch with the same artifact source.`);
|
|
91
|
+
}
|
|
92
|
+
function ensureSafeRuntimeDirectory(target, file) {
|
|
93
|
+
const root = path.resolve(target);
|
|
94
|
+
const directory = path.dirname(path.resolve(file));
|
|
95
|
+
const relative = path.relative(root, directory);
|
|
96
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
97
|
+
throw invalidState(file);
|
|
98
|
+
}
|
|
99
|
+
let current = root;
|
|
100
|
+
const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
|
|
101
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) throw invalidState(file);
|
|
102
|
+
for (const segment of relative.split(path.sep)) {
|
|
103
|
+
current = path.join(current, segment);
|
|
104
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false });
|
|
105
|
+
if (stat) {
|
|
106
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw invalidState(file);
|
|
107
|
+
} else {
|
|
108
|
+
fs.mkdirSync(current, { mode: 448 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const realRoot = fs.realpathSync(root);
|
|
112
|
+
const realDirectory = fs.realpathSync(directory);
|
|
113
|
+
if (realDirectory !== realRoot && !realDirectory.startsWith(`${realRoot}${path.sep}`)) {
|
|
114
|
+
throw invalidState(file);
|
|
115
|
+
}
|
|
116
|
+
return directory;
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
clearMobileReleaseArtifactState,
|
|
120
|
+
mobileReleaseArtifactStatePath,
|
|
121
|
+
readMobileReleaseArtifactState,
|
|
122
|
+
requireCurrentMobileReleaseArtifactState,
|
|
123
|
+
writeMobileReleaseArtifactState
|
|
124
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
async function installAndroidReleaseArtifact(options, deps = defaultDeps) {
|
|
8
|
+
validateInputs(options);
|
|
9
|
+
const sourcePath = path.resolve(options.file);
|
|
10
|
+
const stagingDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), "mm-harness-android-artifact-"));
|
|
11
|
+
try {
|
|
12
|
+
const stagedPath = path.join(stagingDirectory, "release.apk");
|
|
13
|
+
const { sha256, bytes } = await stageRegularFile(sourcePath, stagedPath);
|
|
14
|
+
if (sha256 !== options.expectedSha256) {
|
|
15
|
+
throw new Error(`Android artifact SHA-256 ${sha256} does not match expected ${options.expectedSha256}.`);
|
|
16
|
+
}
|
|
17
|
+
const { packageId, versionName, versionCode } = readAndroidPackageIdentity(deps, stagedPath);
|
|
18
|
+
if (packageId !== options.packageId) {
|
|
19
|
+
throw new Error(`Android artifact package ${packageId || "missing"} does not match expected ${options.packageId}.`);
|
|
20
|
+
}
|
|
21
|
+
if (versionName !== options.expectedVersion) {
|
|
22
|
+
throw new Error(`Android artifact version ${versionName || "missing"} does not match expected ${options.expectedVersion}.`);
|
|
23
|
+
}
|
|
24
|
+
if (versionCode !== options.expectedBuildId) {
|
|
25
|
+
throw new Error(`Android artifact build ${versionCode || "missing"} does not match expected ${options.expectedBuildId}.`);
|
|
26
|
+
}
|
|
27
|
+
const adb = resolveAdb(deps);
|
|
28
|
+
const state = runChecked(deps, adb, ["-s", options.deviceSerial, "get-state"]).trim();
|
|
29
|
+
if (state !== "device") {
|
|
30
|
+
throw new Error(`Android device ${options.deviceSerial} is not ready.`);
|
|
31
|
+
}
|
|
32
|
+
runChecked(deps, adb, ["-s", options.deviceSerial, "install", "-r", "-d", stagedPath]);
|
|
33
|
+
assertInstalledAndroidReleaseArtifact({
|
|
34
|
+
packageId,
|
|
35
|
+
versionName,
|
|
36
|
+
versionCode,
|
|
37
|
+
sha256,
|
|
38
|
+
deviceSerial: options.deviceSerial
|
|
39
|
+
}, deps, adb);
|
|
40
|
+
runChecked(deps, adb, [
|
|
41
|
+
"-s",
|
|
42
|
+
options.deviceSerial,
|
|
43
|
+
"shell",
|
|
44
|
+
"monkey",
|
|
45
|
+
"-p",
|
|
46
|
+
packageId,
|
|
47
|
+
"-c",
|
|
48
|
+
"android.intent.category.LAUNCHER",
|
|
49
|
+
"1"
|
|
50
|
+
]);
|
|
51
|
+
let pid = "";
|
|
52
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
53
|
+
const result = deps.run(adb, ["-s", options.deviceSerial, "shell", "pidof", packageId]);
|
|
54
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
55
|
+
pid = result.stdout.trim();
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
await deps.sleep(250);
|
|
59
|
+
}
|
|
60
|
+
if (!pid) throw new Error(`Android package ${packageId} did not start on ${options.deviceSerial}.`);
|
|
61
|
+
return {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
status: "pass",
|
|
64
|
+
adapter: "mobile",
|
|
65
|
+
platform: "android",
|
|
66
|
+
source: { kind: "local", path: sourcePath },
|
|
67
|
+
sha256,
|
|
68
|
+
bytes,
|
|
69
|
+
packageId,
|
|
70
|
+
versionName,
|
|
71
|
+
versionCode,
|
|
72
|
+
deviceSerial: options.deviceSerial,
|
|
73
|
+
installed: true,
|
|
74
|
+
launched: true
|
|
75
|
+
};
|
|
76
|
+
} finally {
|
|
77
|
+
await fsp.rm(stagingDirectory, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function assertAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
|
|
81
|
+
validateInputs({
|
|
82
|
+
file: "runtime.apk",
|
|
83
|
+
expectedVersion: identity.versionName,
|
|
84
|
+
expectedBuildId: identity.versionCode,
|
|
85
|
+
expectedSha256: identity.sha256,
|
|
86
|
+
packageId: identity.packageId,
|
|
87
|
+
deviceSerial: identity.deviceSerial
|
|
88
|
+
});
|
|
89
|
+
const adb = resolveAdb(deps);
|
|
90
|
+
assertInstalledAndroidReleaseArtifact(identity, deps, adb);
|
|
91
|
+
}
|
|
92
|
+
async function relaunchAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
|
|
93
|
+
assertAndroidReleaseArtifactRuntime(identity, deps);
|
|
94
|
+
const adb = resolveAdb(deps);
|
|
95
|
+
runChecked(deps, adb, [
|
|
96
|
+
"-s",
|
|
97
|
+
identity.deviceSerial,
|
|
98
|
+
"shell",
|
|
99
|
+
"monkey",
|
|
100
|
+
"-p",
|
|
101
|
+
identity.packageId,
|
|
102
|
+
"-c",
|
|
103
|
+
"android.intent.category.LAUNCHER",
|
|
104
|
+
"1"
|
|
105
|
+
]);
|
|
106
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
107
|
+
const result = deps.run(adb, ["-s", identity.deviceSerial, "shell", "pidof", identity.packageId]);
|
|
108
|
+
if (result.status === 0 && result.stdout.trim()) return;
|
|
109
|
+
await deps.sleep(250);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Android package ${identity.packageId} did not start on ${identity.deviceSerial}.`);
|
|
112
|
+
}
|
|
113
|
+
function validateInputs(options) {
|
|
114
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9._-]+)?$/u.test(options.expectedVersion)) {
|
|
115
|
+
throw new Error("--artifact-version must be an Android version name such as X.Y.Z.");
|
|
116
|
+
}
|
|
117
|
+
if (!/^\d+$/u.test(options.expectedBuildId)) {
|
|
118
|
+
throw new Error("--artifact-build-id must be the numeric Android version code.");
|
|
119
|
+
}
|
|
120
|
+
if (!/^[a-f0-9]{64}$/u.test(options.expectedSha256)) {
|
|
121
|
+
throw new Error("--artifact-sha256 must be 64 lowercase hexadecimal characters.");
|
|
122
|
+
}
|
|
123
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u.test(options.packageId)) {
|
|
124
|
+
throw new Error("--artifact-package-id must be an Android application ID.");
|
|
125
|
+
}
|
|
126
|
+
if (!/^[A-Za-z0-9._:-]+$/u.test(options.deviceSerial)) {
|
|
127
|
+
throw new Error("Android artifact launch requires an explicit device serial.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function assertInstalledAndroidReleaseArtifact(identity, deps, adb) {
|
|
131
|
+
const state = runChecked(deps, adb, ["-s", identity.deviceSerial, "get-state"]).trim();
|
|
132
|
+
if (state !== "device") throw new Error(`Android device ${identity.deviceSerial} is not ready.`);
|
|
133
|
+
const paths = runChecked(deps, adb, [
|
|
134
|
+
"-s",
|
|
135
|
+
identity.deviceSerial,
|
|
136
|
+
"shell",
|
|
137
|
+
"pm",
|
|
138
|
+
"path",
|
|
139
|
+
identity.packageId
|
|
140
|
+
]).split(/\r?\n/u).filter(Boolean);
|
|
141
|
+
if (paths.length !== 1 || !paths[0].startsWith("package:/") || !paths[0].endsWith("/base.apk")) {
|
|
142
|
+
throw new Error(`Android package ${identity.packageId} is not a single installed APK on ${identity.deviceSerial}.`);
|
|
143
|
+
}
|
|
144
|
+
const installedPath = paths[0].slice("package:".length);
|
|
145
|
+
if (!/^\/data\/app\/[A-Za-z0-9._~+=/-]+\/base\.apk$/u.test(installedPath)) {
|
|
146
|
+
throw new Error(`Android package ${identity.packageId} returned an unsafe installed path.`);
|
|
147
|
+
}
|
|
148
|
+
const installedSha256 = runChecked(deps, adb, [
|
|
149
|
+
"-s",
|
|
150
|
+
identity.deviceSerial,
|
|
151
|
+
"shell",
|
|
152
|
+
"sha256sum",
|
|
153
|
+
installedPath
|
|
154
|
+
]).trim().split(/\s+/u)[0];
|
|
155
|
+
if (installedSha256 !== identity.sha256) {
|
|
156
|
+
throw new Error(`Android package ${identity.packageId} no longer matches the trusted release artifact SHA-256.`);
|
|
157
|
+
}
|
|
158
|
+
const installed = runChecked(deps, adb, [
|
|
159
|
+
"-s",
|
|
160
|
+
identity.deviceSerial,
|
|
161
|
+
"shell",
|
|
162
|
+
"dumpsys",
|
|
163
|
+
"package",
|
|
164
|
+
identity.packageId
|
|
165
|
+
]);
|
|
166
|
+
if (!new RegExp(`^\\s*versionName=${escapeRegExp(identity.versionName)}\\s*$`, "mu").test(installed) || !new RegExp(`^\\s*versionCode=${escapeRegExp(identity.versionCode)}(?:\\s|$)`, "mu").test(installed)) {
|
|
167
|
+
throw new Error(`Android package ${identity.packageId} installed with an unexpected version on ${identity.deviceSerial}.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function escapeRegExp(value) {
|
|
171
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
172
|
+
}
|
|
173
|
+
async function stageRegularFile(file, destination) {
|
|
174
|
+
const descriptor = await fsp.open(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
175
|
+
const output = await fsp.open(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 384);
|
|
176
|
+
try {
|
|
177
|
+
const stat = await descriptor.stat();
|
|
178
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > 2 * 1024 * 1024 * 1024) {
|
|
179
|
+
throw new Error(`Android artifact must be a non-empty regular file smaller than 2 GiB: ${file}`);
|
|
180
|
+
}
|
|
181
|
+
const digest = createHash("sha256");
|
|
182
|
+
const stream = descriptor.createReadStream({ autoClose: false });
|
|
183
|
+
for await (const chunk of stream) {
|
|
184
|
+
digest.update(chunk);
|
|
185
|
+
await output.writeFile(chunk);
|
|
186
|
+
}
|
|
187
|
+
await output.sync();
|
|
188
|
+
return { sha256: digest.digest("hex"), bytes: stat.size };
|
|
189
|
+
} finally {
|
|
190
|
+
await output.close();
|
|
191
|
+
await descriptor.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function resolveAdb(deps) {
|
|
195
|
+
const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
196
|
+
const candidates = [
|
|
197
|
+
configured ? path.join(configured, "platform-tools", "adb") : "",
|
|
198
|
+
path.join(os.homedir(), "Library", "Android", "sdk", "platform-tools", "adb"),
|
|
199
|
+
"adb"
|
|
200
|
+
].filter(Boolean);
|
|
201
|
+
return firstRunnable(deps, candidates, ["version"], "adb");
|
|
202
|
+
}
|
|
203
|
+
function readAndroidPackageIdentity(deps, file) {
|
|
204
|
+
const analyzer = resolveApkAnalyzer(deps);
|
|
205
|
+
if (analyzer) {
|
|
206
|
+
const packageId2 = deps.run(analyzer, ["manifest", "application-id", file]);
|
|
207
|
+
const versionName2 = deps.run(analyzer, ["manifest", "version-name", file]);
|
|
208
|
+
const versionCode2 = deps.run(analyzer, ["manifest", "version-code", file]);
|
|
209
|
+
if (packageId2.status === 0 && packageId2.stdout.trim() && versionName2.status === 0 && versionName2.stdout.trim() && versionCode2.status === 0 && versionCode2.stdout.trim()) {
|
|
210
|
+
return {
|
|
211
|
+
packageId: packageId2.stdout.trim(),
|
|
212
|
+
versionName: versionName2.stdout.trim(),
|
|
213
|
+
versionCode: versionCode2.stdout.trim()
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const aapt2 = resolveAapt2(deps);
|
|
218
|
+
const badging = runChecked(deps, aapt2, ["dump", "badging", file]);
|
|
219
|
+
const line = badging.split(/\r?\n/u).find((entry) => entry.startsWith("package: ")) ?? "";
|
|
220
|
+
const packageId = /\bname='([^']+)'/u.exec(line)?.[1] ?? "";
|
|
221
|
+
const versionCode = /\bversionCode='([^']+)'/u.exec(line)?.[1] ?? "";
|
|
222
|
+
const versionName = /\bversionName='([^']+)'/u.exec(line)?.[1] ?? "";
|
|
223
|
+
if (!packageId || !versionCode || !versionName) {
|
|
224
|
+
throw new Error("aapt2 could not read the Android artifact package identity.");
|
|
225
|
+
}
|
|
226
|
+
return { packageId, versionName, versionCode };
|
|
227
|
+
}
|
|
228
|
+
function resolveApkAnalyzer(deps) {
|
|
229
|
+
const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
230
|
+
const candidates = [
|
|
231
|
+
configured ? path.join(configured, "cmdline-tools", "latest", "bin", "apkanalyzer") : "",
|
|
232
|
+
path.join(os.homedir(), "Library", "Android", "sdk", "cmdline-tools", "latest", "bin", "apkanalyzer")
|
|
233
|
+
].filter(Boolean);
|
|
234
|
+
for (const candidate of candidates) {
|
|
235
|
+
try {
|
|
236
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
237
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
238
|
+
} catch {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const probe = deps.run("apkanalyzer", []);
|
|
243
|
+
if (`${probe.stdout}
|
|
244
|
+
${probe.stderr}`.includes("manifest application-id")) return "apkanalyzer";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
function resolveAapt2(deps) {
|
|
248
|
+
const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
249
|
+
const buildToolsRoot = configured ? path.join(configured, "build-tools") : path.join(os.homedir(), "Library", "Android", "sdk", "build-tools");
|
|
250
|
+
let candidates = [];
|
|
251
|
+
try {
|
|
252
|
+
candidates = fs.readdirSync(buildToolsRoot).sort((left, right) => right.localeCompare(left, void 0, { numeric: true })).map((version) => path.join(buildToolsRoot, version, "aapt2"));
|
|
253
|
+
} catch {
|
|
254
|
+
candidates = [];
|
|
255
|
+
}
|
|
256
|
+
candidates.push("aapt2");
|
|
257
|
+
return firstRunnable(deps, candidates, ["version"], "apkanalyzer or aapt2");
|
|
258
|
+
}
|
|
259
|
+
function firstRunnable(deps, candidates, args, label) {
|
|
260
|
+
for (const candidate of candidates) {
|
|
261
|
+
if (deps.run(candidate, args).status === 0) return candidate;
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`${label} is required to validate and install an Android release artifact.`);
|
|
264
|
+
}
|
|
265
|
+
function runChecked(deps, command, args) {
|
|
266
|
+
const result = deps.run(command, args);
|
|
267
|
+
if (result.status !== 0) {
|
|
268
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
|
|
269
|
+
throw new Error(`${path.basename(command)} failed: ${detail}`);
|
|
270
|
+
}
|
|
271
|
+
return result.stdout;
|
|
272
|
+
}
|
|
273
|
+
const defaultDeps = {
|
|
274
|
+
run(command, args) {
|
|
275
|
+
const result = spawnSync(command, args, {
|
|
276
|
+
encoding: "utf8",
|
|
277
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
278
|
+
timeout: 12e4,
|
|
279
|
+
maxBuffer: 4 * 1024 * 1024
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
status: result.status ?? 1,
|
|
283
|
+
stdout: result.stdout || "",
|
|
284
|
+
stderr: result.stderr || result.error?.message || ""
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
sleep(ms) {
|
|
288
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
export {
|
|
292
|
+
assertAndroidReleaseArtifactRuntime,
|
|
293
|
+
installAndroidReleaseArtifact,
|
|
294
|
+
relaunchAndroidReleaseArtifactRuntime
|
|
295
|
+
};
|