mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
583 lines
20 KiB
JavaScript
583 lines
20 KiB
JavaScript
import { WebSocket } from 'ws'
|
|
import { evaluateHostedWebViewCdp } from './hosted-webview-cdp-evaluation.mjs'
|
|
import {
|
|
assertNoHostedMobileWebCdpTarget,
|
|
CDP_TARGET_LIMIT,
|
|
HOSTED_DOCUMENT_TEXT_LIMIT,
|
|
isHostedMobileWebUrl,
|
|
probeHostedWebView,
|
|
readCdpTargets
|
|
} from './hosted-webview-cdp-target-discovery.mjs'
|
|
|
|
const CDP_MESSAGE_MAX_BYTES = 2 * 1024 * 1024
|
|
const HOSTED_CONTROL_ACTIVATION_ATTRIBUTE = 'data-orca-cdp-activation'
|
|
const HOSTED_CONNECTION_OBSERVATION_PROPERTY = '__orcaE2eConnectionObservation'
|
|
let hostedControlActivationSequence = 0
|
|
|
|
export { assertNoHostedMobileWebCdpTarget, isHostedMobileWebUrl }
|
|
|
|
export function selectVisibleHostedWebView(
|
|
probes,
|
|
expectedText,
|
|
expectedHrefIncludes,
|
|
requireInteractiveControls = true
|
|
) {
|
|
const eligible = probes.filter(
|
|
(probe) =>
|
|
isHostedMobileWebUrl(probe.href) &&
|
|
(!expectedHrefIncludes || probe.href.includes(expectedHrefIncludes)) &&
|
|
probe.visibility === 'visible' &&
|
|
probe.bridgeListening &&
|
|
probe.bodyText.includes(expectedText) &&
|
|
(!requireInteractiveControls || probe.buttonCount > 0)
|
|
)
|
|
return eligible.find((probe) => probe.focused) ?? eligible[0] ?? null
|
|
}
|
|
|
|
export async function waitForVisibleHostedWebView({
|
|
discoveryUrl,
|
|
expectedText,
|
|
expectedHrefIncludes,
|
|
requireInteractiveControls = true,
|
|
timeoutMs,
|
|
fetchImpl = fetch,
|
|
WebSocketCtor = WebSocket
|
|
}) {
|
|
const deadline = Date.now() + timeoutMs
|
|
let lastSummary = 'No inspectable WebKit targets'
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const targets = await readCdpTargets(discoveryUrl, fetchImpl)
|
|
const probes = (
|
|
await Promise.all(
|
|
targets
|
|
.slice(-CDP_TARGET_LIMIT)
|
|
.map((target) => probeHostedWebView(target, WebSocketCtor))
|
|
)
|
|
).filter(Boolean)
|
|
lastSummary = JSON.stringify(
|
|
probes.map(({ href, visibility, focused, bridgeListening, bodyText, buttonCount }) => ({
|
|
href,
|
|
visibility,
|
|
focused,
|
|
bridgeListening,
|
|
buttonCount,
|
|
textLength: bodyText.length,
|
|
textPreview: bodyText.slice(0, 240)
|
|
}))
|
|
)
|
|
const selected = selectVisibleHostedWebView(
|
|
probes,
|
|
expectedText,
|
|
expectedHrefIncludes,
|
|
requireInteractiveControls
|
|
)
|
|
if (selected) {
|
|
return selected
|
|
}
|
|
} catch (error) {
|
|
lastSummary = error instanceof Error ? error.message : String(error)
|
|
}
|
|
await delay(500)
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for the visible hosted mobile document. Last targets: ${lastSummary}`
|
|
)
|
|
}
|
|
|
|
export async function verifyHostedWebViewNetworkIsolation({
|
|
document,
|
|
probeId,
|
|
settleDelayMs = 250,
|
|
WebSocketCtor = WebSocket
|
|
}) {
|
|
if (!document?.webSocketDebuggerUrl) {
|
|
throw new Error('Hosted WebView inspector target is unavailable')
|
|
}
|
|
if (typeof probeId !== 'string' || probeId.length === 0) {
|
|
throw new Error('Hosted WebView network probe token is unavailable')
|
|
}
|
|
const started = await evaluateHostedDocumentWithRetry(
|
|
document,
|
|
`(() => {
|
|
const run = globalThis.__orcaRunSecurityProbe;
|
|
if (typeof run !== 'function') return '';
|
|
run();
|
|
return ${JSON.stringify(probeId)};
|
|
})()`,
|
|
WebSocketCtor
|
|
)
|
|
if (started !== probeId) {
|
|
throw new Error('Hosted WebView network probe could not start')
|
|
}
|
|
await delay(settleDelayMs)
|
|
const completed = await evaluateHostedDocumentWithRetry(
|
|
document,
|
|
`String(globalThis.__orcaDebugNetworkProbeCompletion ?? '')`,
|
|
WebSocketCtor
|
|
)
|
|
if (completed !== probeId) {
|
|
throw new Error('Hosted WebView network probe did not complete')
|
|
}
|
|
return {
|
|
fetch: 'attempted',
|
|
xhr: 'attempted',
|
|
webSocket: 'attempted',
|
|
image: 'attempted'
|
|
}
|
|
}
|
|
|
|
export async function verifyHostedWebViewNavigationIsolation({
|
|
document,
|
|
discoveryUrl,
|
|
probeId,
|
|
settleDelayMs = 500,
|
|
WebSocketCtor = WebSocket,
|
|
fetchImpl = fetch
|
|
}) {
|
|
if (!document?.webSocketDebuggerUrl) {
|
|
throw new Error('Hosted WebView inspector target is unavailable')
|
|
}
|
|
if (typeof probeId !== 'string' || probeId.length === 0) {
|
|
throw new Error('Hosted WebView navigation probe token is unavailable')
|
|
}
|
|
await delay(settleDelayMs)
|
|
const value = await evaluateHostedDocumentWithRetry(
|
|
document,
|
|
`String(globalThis.__orcaDebugNavigationProbeCompletion ?? '')`,
|
|
WebSocketCtor
|
|
)
|
|
let result
|
|
try {
|
|
result = JSON.parse(value)
|
|
} catch {
|
|
throw new Error('Hosted WebView navigation probe did not complete')
|
|
}
|
|
const popupBlocked =
|
|
result.popupBlocked === true ||
|
|
(discoveryUrl ? await verifyNoPopupInspectorTarget(document, discoveryUrl, fetchImpl) : false)
|
|
const expected = {
|
|
token: probeId,
|
|
documentRetained: true,
|
|
popupBlocked: true,
|
|
serviceWorkerBlocked: true,
|
|
redirectFrameAttempted: true,
|
|
downloadAttempted: true,
|
|
externalSchemeAttempted: true
|
|
}
|
|
const verifiedResult = { ...result, popupBlocked }
|
|
if (JSON.stringify(verifiedResult) !== JSON.stringify(expected)) {
|
|
throw new Error(`Hosted WebView navigation isolation failed: ${JSON.stringify(result)}`)
|
|
}
|
|
const { token: _, ...evidence } = verifiedResult
|
|
return evidence
|
|
}
|
|
|
|
async function verifyNoPopupInspectorTarget(document, discoveryUrl, fetchImpl) {
|
|
const targets = await readCdpTargets(discoveryUrl, fetchImpl)
|
|
const pages = targets.filter((target) => target.type === 'page')
|
|
return pages.length === 1 && pages[0]?.id === document.targetId
|
|
}
|
|
|
|
export async function readHostedWebViewState(document, WebSocketCtor = WebSocket) {
|
|
const expression = `JSON.stringify({
|
|
href: String(location.href).slice(0, 2048),
|
|
bodyText: String(document.body?.innerText ?? '').slice(0, ${HOSTED_DOCUMENT_TEXT_LIMIT}),
|
|
labels: Array.from(document.querySelectorAll('[aria-label]')).filter((element) => {
|
|
const style = getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
return style.display !== 'none' &&
|
|
style.visibility !== 'hidden' &&
|
|
rect.width > 0 &&
|
|
rect.height > 0 &&
|
|
rect.bottom > 0 &&
|
|
rect.right > 0 &&
|
|
rect.top < innerHeight &&
|
|
rect.left < innerWidth;
|
|
}).slice(0, 128)
|
|
.map((element) => String(element.getAttribute('aria-label') ?? '').slice(0, 240)),
|
|
placeholders: Array.from(document.querySelectorAll('input[placeholder],textarea[placeholder]'))
|
|
.slice(0, 32)
|
|
.map((element) => String(element.getAttribute('placeholder') ?? '').slice(0, 240))
|
|
})`
|
|
const value = await evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor)
|
|
const parsed = JSON.parse(value)
|
|
if (
|
|
typeof parsed?.href !== 'string' ||
|
|
typeof parsed.bodyText !== 'string' ||
|
|
!Array.isArray(parsed.labels) ||
|
|
!parsed.labels.every((label) => typeof label === 'string') ||
|
|
!Array.isArray(parsed.placeholders) ||
|
|
!parsed.placeholders.every((placeholder) => typeof placeholder === 'string')
|
|
) {
|
|
throw new Error('Hosted WebView state probe returned an invalid value')
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
export async function readHostedWebViewTextPoint(
|
|
document,
|
|
text,
|
|
WebSocketCtor = WebSocket,
|
|
options = {}
|
|
) {
|
|
const horizontalPosition = options.horizontalPosition ?? 0.5
|
|
if (!Number.isFinite(horizontalPosition) || horizontalPosition < 0 || horizontalPosition > 1) {
|
|
throw new Error('Hosted WebView text point position is invalid')
|
|
}
|
|
const expression = `(() => {
|
|
const expected = ${JSON.stringify(text)};
|
|
const comparableExpected = ${
|
|
options.ignoreCase === true
|
|
} ? expected.toLocaleLowerCase() : expected;
|
|
const isVisible = (candidate) => {
|
|
const style = getComputedStyle(candidate);
|
|
const rect = candidate.getBoundingClientRect();
|
|
return style.display !== 'none' &&
|
|
style.visibility !== 'hidden' &&
|
|
rect.width > 0 &&
|
|
rect.height > 0 &&
|
|
rect.bottom > 0 &&
|
|
rect.right > 0 &&
|
|
rect.top < innerHeight &&
|
|
rect.left < innerWidth;
|
|
};
|
|
const matches = Array.from(document.querySelectorAll('body *')).filter((candidate) =>
|
|
candidate.children.length === 0 && (${options.reveal === true} || isVisible(candidate)) && (
|
|
${
|
|
options.ignoreCase === true
|
|
? "String(candidate.textContent ?? '').trim().toLocaleLowerCase()"
|
|
: "String(candidate.textContent ?? '').trim()"
|
|
}
|
|
) === comparableExpected
|
|
);
|
|
const element = matches[${options.occurrence ?? 0}];
|
|
if (!element) return '';
|
|
if (${options.reveal === true}) {
|
|
element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
}
|
|
const rect = element.getBoundingClientRect();
|
|
const screenWidth = Number(screen.width);
|
|
const screenHeight = Number(screen.height);
|
|
const viewportTop = Math.max(0, screenHeight - Number(innerHeight));
|
|
return JSON.stringify({
|
|
x: (rect.left + rect.width * ${horizontalPosition}) / screenWidth,
|
|
y: (viewportTop + rect.top + rect.height / 2) / screenHeight
|
|
});
|
|
})()`
|
|
const value = await evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor)
|
|
let point
|
|
try {
|
|
point = JSON.parse(value)
|
|
} catch {
|
|
throw new Error(`Hosted WebView control was not found: ${text}`)
|
|
}
|
|
if (
|
|
!point ||
|
|
!Number.isFinite(point.x) ||
|
|
!Number.isFinite(point.y) ||
|
|
point.x < 0 ||
|
|
point.x > 1 ||
|
|
point.y < 0 ||
|
|
point.y > 1
|
|
) {
|
|
throw new Error(`Hosted WebView returned an invalid text point: ${text}`)
|
|
}
|
|
return point
|
|
}
|
|
|
|
export function terminateHostedWebViewProcess(document, WebSocketCtor = WebSocket) {
|
|
if (!document?.webSocketDebuggerUrl) {
|
|
return Promise.reject(new Error('Hosted WebView inspector target is unavailable'))
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const socket = new WebSocketCtor(document.webSocketDebuggerUrl, {
|
|
maxPayload: CDP_MESSAGE_MAX_BYTES
|
|
})
|
|
const timer = setTimeout(
|
|
() => finish(new Error('Hosted WebView process termination timed out')),
|
|
5_000
|
|
)
|
|
let settled = false
|
|
|
|
const finish = (error) => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timer)
|
|
socket.close()
|
|
if (error) {
|
|
reject(error)
|
|
} else {
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
socket.once('open', () => {
|
|
socket.send(JSON.stringify({ id: 1, method: 'Page.crash' }))
|
|
})
|
|
socket.on('message', (data) => {
|
|
try {
|
|
if (data.length > CDP_MESSAGE_MAX_BYTES) {
|
|
finish(new Error('WebKit CDP response exceeded its size limit'))
|
|
return
|
|
}
|
|
const message = JSON.parse(String(data))
|
|
if (
|
|
message.method === 'Inspector.detached' &&
|
|
message.params?.reason === 'Render process gone.'
|
|
) {
|
|
finish()
|
|
} else if (message.id === 1 && message.error) {
|
|
finish(new Error(message.error.message ?? 'WebKit process termination failed'))
|
|
}
|
|
} catch (error) {
|
|
finish(error)
|
|
}
|
|
})
|
|
socket.once('error', (error) => finish(error))
|
|
socket.once('close', () => {
|
|
if (!settled) {
|
|
finish(new Error('WebKit process closed without termination evidence'))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function startHostedWebViewConnectionObservation(
|
|
document,
|
|
{ expectedText, expectedHrefIncludes },
|
|
WebSocketCtor = WebSocket
|
|
) {
|
|
const expression = `(() => {
|
|
const key = ${JSON.stringify(HOSTED_CONNECTION_OBSERVATION_PROPERTY)};
|
|
const observation = globalThis[key] ??= { entries: [], listening: false };
|
|
observation.entries.length = 0;
|
|
observation.expectedText = ${JSON.stringify(expectedText)};
|
|
observation.expectedHrefIncludes = ${JSON.stringify(expectedHrefIncludes)};
|
|
if (!observation.listening) {
|
|
addEventListener('message', (event) => {
|
|
try {
|
|
const message = typeof event.data === 'string' ? JSON.parse(event.data) : null;
|
|
if (message?.type !== 'connection') return;
|
|
const state = message.state;
|
|
if (!['connecting', 'connected', 'offline', 'recovering'].includes(state)) return;
|
|
requestAnimationFrame(() => {
|
|
const current = globalThis[key];
|
|
if (!current) return;
|
|
const href = String(location.href).slice(0, 2048);
|
|
current.entries.push({
|
|
state,
|
|
href,
|
|
retainedExpectedText: String(document.body?.innerText ?? '')
|
|
.includes(current.expectedText),
|
|
retainedExpectedRoute: href.includes(current.expectedHrefIncludes)
|
|
});
|
|
if (current.entries.length > 32) current.entries.shift();
|
|
});
|
|
} catch {}
|
|
});
|
|
observation.listening = true;
|
|
}
|
|
return JSON.stringify({ started: true });
|
|
})()`
|
|
const value = await evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor)
|
|
if (JSON.parse(value)?.started !== true) {
|
|
throw new Error('Hosted WebView connection observation did not start')
|
|
}
|
|
}
|
|
|
|
export async function waitForHostedWebViewConnectionSequence(
|
|
document,
|
|
expectedStates,
|
|
timeoutMs,
|
|
transport = WebSocket
|
|
) {
|
|
const WebSocketCtor =
|
|
typeof transport === 'function' ? transport : (transport.WebSocketCtor ?? WebSocket)
|
|
const reacquireDocument =
|
|
typeof transport === 'function' ? undefined : transport.reacquireDocument
|
|
const deadline = Date.now() + timeoutMs
|
|
let entries = []
|
|
let activeDocument = document
|
|
const observedEntryCounts = new Map()
|
|
const expression = `JSON.stringify(globalThis[${JSON.stringify(
|
|
HOSTED_CONNECTION_OBSERVATION_PROPERTY
|
|
)}]?.entries ?? [])`
|
|
while (Date.now() < deadline) {
|
|
let currentEntries
|
|
try {
|
|
const value = await evaluateHostedDocumentWithRetry(activeDocument, expression, WebSocketCtor)
|
|
currentEntries = JSON.parse(value)
|
|
} catch (error) {
|
|
if (!reacquireDocument) {
|
|
throw error
|
|
}
|
|
activeDocument = await reacquireDocument(Math.max(1, deadline - Date.now()))
|
|
continue
|
|
}
|
|
const documentKey = activeDocument.webSocketDebuggerUrl
|
|
const previousCount = observedEntryCounts.get(documentKey) ?? 0
|
|
const nextOffset = currentEntries.length < previousCount ? 0 : previousCount
|
|
entries.push(...currentEntries.slice(nextOffset))
|
|
observedEntryCounts.set(documentKey, currentEntries.length)
|
|
if (hasOrderedConnectionStates(entries, expectedStates)) {
|
|
return entries
|
|
}
|
|
await delay(250)
|
|
}
|
|
throw new Error(
|
|
`Hosted WebView did not observe connection sequence ${expectedStates.join(
|
|
' → '
|
|
)}. Last states: ${entries.map((entry) => entry.state).join(', ')}`
|
|
)
|
|
}
|
|
|
|
function hasOrderedConnectionStates(entries, expectedStates) {
|
|
let expectedIndex = 0
|
|
for (const entry of entries) {
|
|
if (entry?.state === expectedStates[expectedIndex]) {
|
|
expectedIndex += 1
|
|
}
|
|
}
|
|
return expectedIndex === expectedStates.length
|
|
}
|
|
|
|
export async function evaluateHostedDocumentWithRetry(
|
|
document,
|
|
expression,
|
|
WebSocketCtor = WebSocket
|
|
) {
|
|
let lastError = null
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
return await evaluateHostedDocument(document, expression, WebSocketCtor)
|
|
} catch (error) {
|
|
lastError = error
|
|
await delay(250)
|
|
}
|
|
}
|
|
throw lastError
|
|
}
|
|
|
|
export async function activateHostedWebViewControl(document, target, WebSocketCtor = WebSocket) {
|
|
const activationToken = `activation-${Date.now()}-${hostedControlActivationSequence++}`
|
|
const locateExpression = `(() => {
|
|
const expected = ${JSON.stringify(target.value)};
|
|
const comparableExpected = ${
|
|
target.ignoreCase === true
|
|
} ? expected.toLocaleLowerCase() : expected;
|
|
const token = ${JSON.stringify(activationToken)};
|
|
const isVisible = (candidate) => {
|
|
const style = getComputedStyle(candidate);
|
|
const rect = candidate.getBoundingClientRect();
|
|
return style.display !== 'none' &&
|
|
style.visibility !== 'hidden' &&
|
|
rect.width > 0 &&
|
|
rect.height > 0 &&
|
|
rect.bottom > 0 &&
|
|
rect.right > 0 &&
|
|
rect.top < innerHeight &&
|
|
rect.left < innerWidth;
|
|
};
|
|
const candidates = Array.from(document.querySelectorAll(${
|
|
target.kind === 'label' ? "'[aria-label]'" : "'body *'"
|
|
}));
|
|
const matches = candidates.filter((candidate) => (${
|
|
target.reveal === true
|
|
} || isVisible(candidate)) && ${
|
|
target.kind === 'label'
|
|
? "candidate.getAttribute('aria-label') === expected"
|
|
: `candidate.children.length === 0 && (
|
|
${
|
|
target.ignoreCase === true
|
|
? "String(candidate.textContent ?? '').trim().toLocaleLowerCase()"
|
|
: "String(candidate.textContent ?? '').trim()"
|
|
}
|
|
) === comparableExpected`
|
|
});
|
|
const interactive = ${
|
|
target.kind === 'label'
|
|
? 'matches'
|
|
: 'Array.from(new Set(matches.map((candidate) => candidate.closest(\'button,[role="button"],a,[tabindex]\')).filter(Boolean)))'
|
|
};
|
|
const controls = interactive.length > 0 ? interactive : matches;
|
|
const element = controls[${target.occurrence ?? 0}];
|
|
if (!(element instanceof HTMLElement)) return JSON.stringify({ found: false });
|
|
if (${target.reveal === true}) element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
element.setAttribute(${JSON.stringify(HOSTED_CONTROL_ACTIVATION_ATTRIBUTE)}, token);
|
|
return JSON.stringify({ found: true });
|
|
})()`
|
|
const locatedValue = await evaluateHostedDocumentWithRetry(
|
|
document,
|
|
locateExpression,
|
|
WebSocketCtor
|
|
)
|
|
const located = JSON.parse(locatedValue)
|
|
if (located?.found !== true) {
|
|
throw new Error(`Hosted WebView control was not found: ${target.value}`)
|
|
}
|
|
|
|
const activateExpression = `(() => {
|
|
const token = ${JSON.stringify(activationToken)};
|
|
const ledger = globalThis.__orcaCdpActivationLedger ??= new Set();
|
|
if (ledger.has(token)) return JSON.stringify({ activated: true, duplicate: true });
|
|
const element = Array.from(document.querySelectorAll('[${HOSTED_CONTROL_ACTIVATION_ATTRIBUTE}]'))
|
|
.find((candidate) => candidate.getAttribute(${JSON.stringify(
|
|
HOSTED_CONTROL_ACTIVATION_ATTRIBUTE
|
|
)}) === token);
|
|
if (!(element instanceof HTMLElement)) return JSON.stringify({ activated: false });
|
|
ledger.add(token);
|
|
element.click();
|
|
return JSON.stringify({ activated: true });
|
|
})()`
|
|
const value = await evaluateHostedDocumentWithRetry(document, activateExpression, WebSocketCtor)
|
|
const parsed = JSON.parse(value)
|
|
if (parsed?.activated !== true) {
|
|
throw new Error(`Hosted WebView control was not found: ${target.value}`)
|
|
}
|
|
}
|
|
|
|
export async function setHostedWebViewInput(document, target, WebSocketCtor = WebSocket) {
|
|
const expression = `(() => {
|
|
const input = Array.from(document.querySelectorAll('input[placeholder],textarea[placeholder]'))
|
|
.find((candidate) => candidate.getAttribute('placeholder') === ${JSON.stringify(
|
|
target.placeholder
|
|
)});
|
|
if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLTextAreaElement)) {
|
|
return JSON.stringify({ updated: false });
|
|
}
|
|
const prototype = input instanceof HTMLTextAreaElement
|
|
? HTMLTextAreaElement.prototype
|
|
: HTMLInputElement.prototype;
|
|
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
|
|
if (!setter) return JSON.stringify({ updated: false });
|
|
setter.call(input, ${JSON.stringify(target.value)});
|
|
input.dispatchEvent(new InputEvent('input', {
|
|
bubbles: true,
|
|
composed: true,
|
|
inputType: 'insertText',
|
|
data: null
|
|
}));
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return JSON.stringify({ updated: true });
|
|
})()`
|
|
const value = await evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor)
|
|
const parsed = JSON.parse(value)
|
|
if (parsed?.updated !== true) {
|
|
throw new Error(`Hosted WebView input was not found: ${target.placeholder}`)
|
|
}
|
|
}
|
|
|
|
function evaluateHostedDocument(document, expression, WebSocketCtor) {
|
|
if (!document?.webSocketDebuggerUrl) {
|
|
throw new Error('Hosted WebView inspector target is unavailable')
|
|
}
|
|
return evaluateHostedWebViewCdp(document.webSocketDebuggerUrl, expression, WebSocketCtor)
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|