UNPKG

remoteview

Version:

Remote isolated browser platform by Dosyago

1,068 lines (969 loc) 37.3 kB
import {spawn} from 'child_process'; import ws from 'ws'; import fetch from 'node-fetch'; import fs from 'fs'; import path from 'path'; import {URL} from 'url'; import {unescape} from 'querystring'; import {APP_ROOT, DEBUG,sleep,SECURE_VIEW_SCRIPT} from '../common.js'; import {username} from '../args.js'; import {WorldName} from '../public/translateVoodooCRDP.js'; import {makeCamera} from './screenShots.js'; import {blockAds,onInterceptRequest as adBlockIntercept} from './adblocking/blockAds.js'; import {LatestCSRFToken, fileChoosers} from '../ws-server.js'; import docViewerSecret from '../secrets/docViewer.js'; //import {overrideNewtab,onInterceptRequest as newtabIntercept} from './newtab/overrideNewtab.js'; //import {blockSites,onInterceptRequest as whitelistIntercept} from './demoblocking/blockSites.js'; // standard injections const selectDropdownEvents = fs.readFileSync(path.join(APP_ROOT, 'zombie-lord', 'injections', 'selectDropdownEvents.js')).toString(); const keysCanInputEvents = fs.readFileSync(path.join(APP_ROOT, 'zombie-lord', 'injections', 'keysCanInput.js')).toString(); const textComposition = fs.readFileSync(path.join(APP_ROOT, 'zombie-lord', 'injections', 'textComposition.js')).toString(); const favicon = fs.readFileSync(path.join(APP_ROOT, 'zombie-lord', 'injections', 'favicon.js')).toString(); const elementInfo = fs.readFileSync(path.join(APP_ROOT, 'zombie-lord', 'injections', 'elementInfo.js')).toString(); const scrollNotify = fs.readFileSync(path.join(APP_ROOT, 'zombie-lord', 'injections', 'scrollNotify.js')).toString(); const botDetectionEvasions = fs.readFileSync(path.join(APP_ROOT, 'zombie-lord', 'injections', 'pageContext', 'botDetectionEvasions.js')).toString(); // plugins injections const appMinifier = fs.readFileSync(path.join(APP_ROOT, 'plugins', 'appminifier', 'injections.js')).toString(); const projector = fs.readFileSync(path.join(APP_ROOT, 'plugins', 'projector', 'injections.js')).toString(); // just concatenate the scripts together and do one injection // but for debugging better to add each separately // we can put in an array, and loop over to add each const injectionsScroll = botDetectionEvasions + favicon + keysCanInputEvents + scrollNotify + elementInfo + textComposition + selectDropdownEvents; const pageContextInjectionsScroll = botDetectionEvasions; const RECONNECT_MS = 5000; const WAIT_FOR_DOWNLOAD_BEGIN_DELAY = 5000; const WAIT_FOR_COALESCED_NETWORK_EVENTS = 1000 const deskUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:78.0) Gecko/20100101 Firefox/78.0"; //const mobUA = "Mozilla/5.0 (Android 9; Mobile; rv:79.0) Gecko/79.0 Firefox/79.0"; //const deskUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36"; const mobUA = "Mozilla/5.0 (Android 9; Mobile; rv:79.0) Gecko/79.0 Firefox/79.0"; const LANG = "en-US"; const deskPLAT = "Win64"; //const deskPLAT = "MacIntel"; const mobPLAT = "Android"; //const VEND = "Apple Computer, Inc."; //const VEND = "Google, Inc."; const VEND = ""; const GrantedPermissions = ["geolocation", "notifications", "flash", "midi"]; //const PromptText = "Dosy was here."; const ROOT_SESSION = 'root'; // for fun const Area51Lat = 37.234332396; const Area51Long = -115.80666344; const UA = mobUA; const PLAT = mobPLAT; const targets = new Set(); //const waiting = new Map(); const sessions = new Map(); const loadings = new Map(); const tabs = new Map(); const Frames = new Map(); //const originalMessage = new Map(); let AD_BLOCK_ON = true; let DEMO_BLOCK_ON = false; function addSession(targetId, sessionId) { sessions.set(targetId,sessionId); sessions.set(sessionId,targetId); } function startLoading(sessionId) { let loading = loadings.get(sessionId); if ( ! loading ) { loading = {waiting:0, complete:0,targetId:sessions.get(sessionId)} loadings.set(sessionId,loading); } loading.waiting++; return loading; } function endLoading(sessionId) { let loading = loadings.get(sessionId); if ( ! loading ) throw new Error(`Expected loading for ${sessionId}`); loading.waiting--; loading.complete++; return loading; } function clearLoading(sessionId) { loadings.delete(sessionId); } function removeSession(id) { const otherId = sessions.get(id); sessions.delete(id); sessions.delete(otherId); } //let id = 0; export default async function Connect({port}, {adBlock:adBlock = true, demoBlock: demoBlock = false} = {}) { AD_BLOCK_ON = adBlock; if ( demoBlock ) { AD_BLOCK_ON = false; DEMO_BLOCK_ON = true; } const connection = { zombie: await makeZombie({port}), port, browserTargetId: null, loadingCount: 0, totalBandwidth: 0, record: {}, frameBuffer: [], meta: [], pausing: new Map(), worlds: new Map(), sessionSend, sessions, targets, tabs, sessionId: null, bounds: {width: 1280, height: 800}, navigator: { userAgent: UA, platform: PLAT, acceptLanguage: LANG, vendor: VEND }, plugins: {}, setClientErrorSender(e) { this.zombie.sendErrorToClient = e; } }; connection.zombie.on('disconnect', async () => { console.log(`Reconnecting to zombie in ${RECONNECT_MS}`); await sleep(RECONNECT_MS); setTimeout(async () => { const next_connection = await Connect( {port:connection.port}, {adBlock, demoBlock, sendErrorToClient} ); Object.assign(connection,next_connection); }, RECONNECT_MS); }); { const {doShot, queueTailShot, shrinkImagery, growImagery} = makeCamera(connection); connection.doShot = doShot; connection.queueTailShot = queueTailShot; connection.shrinkImagery = shrinkImagery; connection.growImagery = growImagery; } const {send,on, ons} = connection.zombie; const {targetInfo:browserTargetInfo} = await send("Target.getTargetInfo"); connection.browserTargetId = browserTargetInfo.targetId; ! DEBUG.legacyShots && await send("HeadlessExperimental.enable", {}); await send("Target.setDiscoverTargets", {discover:true}); await send("Target.setAutoAttach", { autoAttach:false, waitForDebuggerOnStart:false, flatten:true, windowOpen:true }); //UPGRADE for future //Page.setDownloadBehavior is deprecated /** await send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: `/home/${username}/browser-downloads/` }); **/ on("Target.targetCreated", async ({targetInfo}) => { const {targetId} = targetInfo; targets.add(targetId); tabs.set(targetId,targetInfo); connection.meta.push({created:targetInfo,targetInfo}); if ( targetInfo.type == "page" ) { await send("Target.attachToTarget", {targetId, flatten:true}); } }); on("Target.targetInfoChanged", async ({targetInfo}) => { const {targetId} = targetInfo; if ( tabs.has(targetId) ) { tabs.set(targetId,targetInfo); connection.meta.push({changed:targetInfo,targetInfo}); if ( targetInfo.type == "page" ) { connection.doShot(); } } else { DEBUG.val > DEBUG.med && console.log("Changed event for removed target", targetId, targetInfo); } }); on("Target.attachedToTarget", async ({sessionId,targetInfo,waitingForDebugger}) => { const attached = {sessionId,targetInfo,waitingForDebugger}; const {targetId} = targetInfo; DEBUG.val > DEBUG.med && console.log("Attached to target", sessionId, targetId); targets.add(targetId); addSession(targetId, sessionId); connection.meta.push({attached}); await setupTab({attached}); }); on("Target.detachedFromTarget", ({sessionId}) => { const detached = {sessionId}; const targetId = sessions.get(sessionId); targets.delete(targetId); tabs.delete(targetId); removeSession(sessionId); deleteWorld(targetId); connection.meta.push({detached}); }); on("Target.targetCrashed", meta => endTarget(meta, 'crashed')); on("Target.targetDestroyed", meta => endTarget(meta, 'destroyed')); ons("Target.receivedMessageFromTarget", receiveMessage); ons("LayerTree.layerPainted", receiveMessage); ons("Network.requestWillBeSent", receiveMessage); ons("Network.dataReceived", receiveMessage); ons("Network.loadingFailed", receiveMessage); ons("Network.responseReceived", receiveMessage); ons("Fetch.requestPaused", receiveMessage); ons("Fetch.authRequired", receiveMessage); ons("Runtime.bindingCalled", receiveMessage); ons("Runtime.consoleAPICalled", receiveMessage); ons("Runtime.executionContextCreated", receiveMessage); ons("Runtime.executionContextDestroyed", receiveMessage); ons("Runtime.executionContextsCleared", receiveMessage); ons("Page.frameNavigated", receiveMessage); ons("Page.fileChooserOpened", receiveMessage); ons("Page.javascriptDialogOpening", receiveMessage); ons("Page.downloadWillBegin", receiveMessage); ons("Page.downloadProgress", receiveMessage); ons("Runtime.exceptionThrown", receiveMessage); ons("Target.detachedFromTarget", receiveMessage); async function receiveMessage({message, sessionId}) { if ( message.method == "Network.dataReceived" ) { const {encodedDataLength, dataLength} = message.params; connection.totalBandwidth += (encodedDataLength || dataLength); } else if ( message.method == "Target.detachedFromTarget" ) { removeSession(message.params.targetId); connection.meta.push({detached:message.params}); } else if ( message.method == "Runtime.bindingCalled" ) { const {name, executionContextId} = message.params; let {payload} = message.params; try { payload = JSON.parse(payload); } catch(e) {console.warn(e)} let response; if ( !!payload.method && !! payload.params ) { payload.name = payload.method; payload.params.sessionId = sessionId; response = await sessionSend(payload); } DEBUG.val >= DEBUG.med && console.log(JSON.stringify({bindingCalled:{name,payload,response,executionContextId}})); await send( "Runtime.evaluate", { expression: `self.instructZombie.onmessage(${JSON.stringify({response})})`, contextId: executionContextId, awaitPromise: true }, sessionId ); } else if ( message.method == "Runtime.consoleAPICalled" ) { const consoleMessage = message.params; const {type,args,executionContextId} = consoleMessage; const logMessages = args.map(convertRemoteObjectToString); try { DEBUG.val && console.log("Runtime.consoleAPICalled", {executionContextId}, {logMessageCount:logMessages.length}, {type}, JSON.stringify(logMessages).slice(0,255) ); } catch(e) { console.warn("Could not show messages from console API"); DEBUG.val && console.log(args); } if ( ! args.length ) return; const activeContexts = connection.worlds.get(connection.sessionId); DEBUG.val > DEBUG.low && console.log(`Active context`, activeContexts); /*if ( ! activeContexts || ! activeContexts.has(executionContextId) ) { DEBUG.val && console.log(`Blocking as is not a context in the active target.`); return; }*/ message = consoleMessage; const firstArg = args[0]; try { message = JSON.parse(firstArg.value); message.executionContextId = executionContextId; connection.meta.push(message); } catch(e) { void 0; } finally { DEBUG.val > DEBUG.med && connection.meta.push({consoleMessage}); } } else if ( message.method == "Runtime.executionContextCreated" ) { DEBUG.val && console.log(JSON.stringify({createdContext:message.params.context})); const {name:worldName, id:contextId} = message.params.context; if ( worldName == WorldName ) { addContext(sessionId,contextId); await send( "Runtime.addBinding", { name: "instructZombie", executionContextId: contextId }, sessionId ); } } else if ( message.method == "Runtime.executionContextDestroyed" ) { const contextId = message.params.executionContextId; deleteContext(sessionId, contextId); } else if ( message.method == "Runtime.executionContextsCleared" ) { DEBUG.val > DEBUG.med && console.log("Execution contexts cleared"); deleteWorld(sessionId); } else if ( message.method == "LayerTree.layerPainted" ) { connection.doShot(); } else if ( message.method == "Page.javascriptDialogOpening" ) { const {params:modal} = message; modal.sessionId = sessionId; console.log(JSON.stringify({modal})); connection.meta.push({modal}); } else if ( message.method == "Page.frameNavigated" ) { const {url, securityOrigin, unreachableUrl, parentId} = message.params.frame; const topFrame = !parentId; if ( !!topFrame && (!! url || !! unreachableUrl) ) { clearLoading(sessionId); const targetId = sessions.get(sessionId); const navigated = { targetId, topFrame, url, unreachableUrl }; connection.meta.push({navigated}); // this is strangely necessary to not avoid the situation where the layer tree is not updated // on page navigation, meaning that layerPainted events stop firing after a couple of navigations await send( "LayerTree.enable", {}, sessionId ); } if ( ! unreachableUrl && securityOrigin || url ) { const origin = securityOrigin || new URL(url).origin; await send( "Browser.grantPermissions", { origin, permissions: GrantedPermissions } ); await send( "Emulation.setGeolocationOverride", { latitude: Area51Lat, longitude: Area51Long, accuracy: 5 }, sessionId ); } } else if ( message.method == "Page.fileChooserOpened" ) { const {mode,backendNodeId} = message.params; const fileChooser = {mode, sessionId}; fileChoosers.set(sessionId, backendNodeId); DEBUG.val > DEBUG.med && console.log(fileChooser, message); try { const {node:{attributes:fileInputAttributes}} = await send("DOM.describeNode", { backendNodeId }, sessionId); if ( fileInputAttributes ) { for( let i = 0; i < fileInputAttributes.length; i++ ) { if ( fileInputAttributes[i] == "accept" ) { fileChooser.accept = fileInputAttributes[i+1]; break; } } } } catch(e) { console.info(`Error getting FileInput.accept attribute by describing backend node from id`, e, fileChooser) } fileChooser.csrfToken = LatestCSRFToken; connection.meta.push({fileChooser}); } else if ( message.method == "Page.downloadProgress" ) { const {params:downloadProgress} = message; const {done, receivedBytes, totalBytes, state} = downloadProgress; if ( ! done && state == 'inProgress' ) return; const amountToAddToServerData = Math.max(receivedBytes, totalBytes, 1); if ( Number.isInteger(amountToAddToServerData) ) { connection.totalBandwidth += amountToAddToServerData; } } else if ( message.method == "Page.downloadWillBegin" ) { // MARK 3 ///** const {params:download} = message; const {suggestedFilename} = download; const downloadFileName = suggestedFilename || getFileFromURL(download.url); download.sessionId = sessionId; download.filename = downloadFileName; // MARK 6 DEBUG.val && console.log({download}); DEBUG.val && console.log({suggestedFilename}); // TODO: // bisect by // 1. disable download behaviour in Page domain // 2. enable it and disable various parts of below code // find out which part of the code creates the 'lock up' // issue we are experiencing // notification connection.meta.push({download}); connection.lastDownloadFileName = downloadFileName; // do only once if ( connection.lastDownloadGUID == download.guid ) return; connection.lastDownloadGUID = download.guid; // logging DEBUG.val > DEBUG.med && console.log({downloadFileName,SECURE_VIEW_SCRIPT,username}); // MARK 4 // This shouldn't be in the community edition console.log({docViewerSecret}); if ( docViewerSecret ) { const subshell = spawn(SECURE_VIEW_SCRIPT, [username, `${downloadFileName}`, docViewerSecret]); let uri = ''; let done = false; // subshell collect data and send once subshell.stderr.pipe(process.stderr); subshell.stdout.on('data', data => { uri += data; }); subshell.stdout.on('end', sendURL); subshell.on('close', sendURL); subshell.on('exit', sendURL); async function sendURL(code) { //MARK 5 const url = uri ? uri.trim() : ""; if ( ! uri || url.length == 0 ) { console.warn("No URI", downloadFileName); //throw new Error( "No URI" ); return; } if ( code == 0 ) { // only do once if ( done ) return; done = true; connection.lastSentFileName = connection.lastDownloadFileName; // trim any whitespace added by the shell echo in the script const secureview = {url}; DEBUG.val > DEBUG.med && console.log("Send secure view", secureview); console.log("Send secure view", secureview); connection.meta.push({secureview}); } else if ( code == undefined ) { console.log(`No code. Probably STDOUT end event.`, url); // only do once if ( done ) return; done = true; connection.lastSentFileName = connection.lastDownloadFileName; // trim any whitespace added by the shell echo in the script const secureview = {url}; DEBUG.val > DEBUG.med && console.log("Send secure view", secureview); console.log("Send secure view", secureview); connection.meta.push({secureview}); } else { console.warn(`Secure View subshell exited with code ${code}`); } } } //**/ } else if ( message.method == "Network.requestWillBeSent" ) { const resource = startLoading(sessionId); const {requestId,frameId, request:{url}} = message.params; if ( requestId && frameId ) { Frames.set(requestId,{url,frameId}); } connection.meta.push({resource}); } else if ( message.method == "Network.requestServedFromCache" ) { const resource = endLoading(sessionId); const {requestId} = message.params; connection.meta.push({resource}); setTimeout(() => Frames.delete(requestId), WAIT_FOR_COALESCED_NETWORK_EVENTS); } else if ( message.method == "Network.loadingFinished" ) { const resource = endLoading(sessionId); const {requestId} = message.params; connection.meta.push({resource}); setTimeout(() => Frames.delete(requestId), WAIT_FOR_COALESCED_NETWORK_EVENTS); } else if ( message.method == "Network.loadingFailed" ) { const resource = endLoading(sessionId); const {requestId} = message.params; const savedFrame = Frames.get(requestId) if ( savedFrame ) { const {url,frameId} = savedFrame; if ( message.params.type == "Document" ) { const someFileName = getFileFromURL(url); message.frameId = frameId; DEBUG.val && console.log({failedURL:url}); if ( !(url.startsWith('http')) ) { const modal = { type: 'intentPrompt', title: 'External App Request', message: `This page is asking to open an external app via URL: ${url}`, url }; DEBUG.val && console.log(JSON.stringify({modal},null,2)); connection.meta.push({modal}); } else { setTimeout(() => { if ( someFileName == connection.lastDownloadFileName ) { // this is not a failure DEBUG.val && console.log({expectDownload:someFileName}); } else { connection.meta.push({failed:message}); DEBUG.val && console.log({failed:message}); } }, WAIT_FOR_DOWNLOAD_BEGIN_DELAY ); } } connection.meta.push({resource}); setTimeout(() => Frames.delete(requestId), WAIT_FOR_COALESCED_NETWORK_EVENTS); } else { console.warn(`No url or frameId saved for requestId: ${requestId}`); } } else if ( message.method == "Network.responseReceived" ) { const resource = endLoading(sessionId); connection.meta.push({resource}); } else if ( message.method == "Runtime.exceptionThrown" ) { DEBUG.val && console.log(JSON.stringify({exception:message.params}, null,2)); } else if ( message.method == "Fetch.requestPaused" ) { //newtabIntercept({sessionId, message}, Target); if ( AD_BLOCK_ON ) { await adBlockIntercept({sessionId, message}, connection.zombie); } else if ( DEMO_BLOCK_ON ) { console.warn("Demo block disabled"); //whitelistIntercept({sessionId, message}, Target); } } else if ( message.method == "Fetch.authRequired" ) { const {requestId, request, /*frameId, */ resourceType, authChallenge} = message.params; connection.pausing.set(requestId, request.url); connection.pausing.set(request.url, requestId); const authRequired = {authChallenge, requestId, resourceType}; DEBUG.val > DEBUG.med && console.log({authRequired}); connection.meta.push({authRequired}); } else if ( message.method && ( message.method.startsWith("LayerTree") || message.method.startsWith("Page") || message.method.startsWith("Network")) ) { // ignore } else { console.warn("Unknown message from target", message); } } return connection; async function setupTab({attached}) { const {sessionId, targetInfo:{targetId}} = attached; try { DEBUG.val && console.log(sessionId, targetId, 'setting up'); ! DEBUG.legacyShots && await send("HeadlessExperimental.enable", {}, sessionId); if ( ! loadings.has(sessionId) ) { const loading = {waiting:0, complete:0,targetId} loadings.set(sessionId,loading); } await send("Network.enable", {}, sessionId); await send("Network.setBlockedURLs", { urls: [ "file://*", ] }, sessionId ) if ( AD_BLOCK_ON ) { await send("Fetch.enable",{ handleAuthRequests: true, patterns: [ { urlPattern: 'http://*/*', requestStage: "Response" }, { urlPattern: 'https://*/*', requestStage: "Response" } ], }, sessionId ); } await send("Page.enable", {}, sessionId); await send("Page.setInterceptFileChooserDialog", { enabled: true }, sessionId); //MARK 1 ///** await send("Page.setDownloadBehavior", { behavior: "allow", downloadPath: `/home/${username}/browser-downloads/` }, sessionId ); //**/ await send( "DOMSnapshot.enable", {}, sessionId ); await send( "Runtime.enable", {}, sessionId ); // Page context injection (to set values in the page's original JS execution context await send( "Page.addScriptToEvaluateOnNewDocument", { // NOTE: NO world name to use the Page context source: pageContextInjectionsScroll }, sessionId ); // Isolated world injection let modeInjectionScroll = ''; if ( connection.plugins.appminifier ) { modeInjectionScroll += appMinifier; } if ( connection.plugins.projector ) { modeInjectionScroll += projector; } await send( "Page.addScriptToEvaluateOnNewDocument", { source: [ saveTargetIdAsGlobal(targetId), injectionsScroll, modeInjectionScroll ].join(''), worldName: WorldName }, sessionId ); await send( "Emulation.setDeviceMetricsOverride", connection.bounds, sessionId ); await send( "Emulation.setScrollbarsHidden", {hidden:connection.hideBars}, sessionId ); await send( "Network.setUserAgentOverride", connection.navigator, sessionId ); //id = await overrideNewtab(connection.zombie, sessionId, id); if ( AD_BLOCK_ON ) { await blockAds(/*connection.zombie, sessionId*/); } else if ( DEMO_BLOCK_ON ) { console.warn("Demo block disabled."); //await blockSites(connection.zombie, sessionId); } await send( "LayerTree.enable", {}, sessionId ); } catch(e) { console.warn("Error setting up", e, targetId, sessionId); } } async function sessionSend(command) { const that = this || connection; let sessionId; const {targetId} = command.params; // FIXME: I want THIS kind of debug. Cool. //DEBUG.has("commands") && console.log(JSON.stringify(command)); if ( command.name == "Page.navigate" ) { let {url} = command.params; url = url.trim(); if ( url.startsWith("file:") || isFileURL(url) ) { console.log("Blocking file navigation"); return {}; } else if ( url.startsWith("vbscript:") ) { console.log("Blocking vbscript protocol url"); return {}; } else if ( url.startsWith("javascript:") ) { console.log("Blocking javascript protocol url"); return {}; } else if ( url.startsWith("data:text/html") ) { console.log("Blocking HTML data URL"); return {}; } } if ( command.name == "Emulation.setDeviceMetricsOverride" ) { Object.assign(connection.bounds, command.params); } if ( command.name == "Emulation.setScrollbarsHidden" ) { connection.hideBars = command.params.hidden; } if ( command.name == "Network.setUserAgentOverride" ) { //connection.navigator.platform = command.params.platform; //connection.navigator.userAgent = command.params.userAgent; //command.params.userAgent = connection.navigator.userAgent; //command.params.platform = connection.navigator.platform; command.params.userAgent = connection.isMobile ? mobUA : deskUA; command.params.platform = connection.isMobile ? mobPLAT : deskPLAT; connection.navigator.platform = command.params.platform; connection.navigator.userAgent = command.params.userAgent; connection.navigator.acceptLanguage = command.params.acceptLanguage; } if ( !! targetId && !targets.has(targetId) ) { DEBUG.val && console.log("Blocking as target does not exist.", targetId); return {}; } if ( command.name == "Target.closeTarget" ) { targets.delete(targetId); tabs.delete(targetId); const tSessionId = sessions.get(targetId); if ( sessions.get(that.sessionId) == targetId ) { that.sessionId = null; } removeSession(targetId); if ( tSessionId ) { DEBUG.val > DEBUG.med && console.log("Received close. Will send detach first."); // FIX NOTE: these sleeps (have not test ms sensitivity, maybe we could go lower), FIX issue #130 // in other words, they prevent the seg fault crash on Target.closeTarget we get sometimes await sleep(300); await send("Target.detachFromTarget", {sessionId:tSessionId}); await sleep(300); } } if ( command.name == "Fetch.continueWithAuth" ) { const {requestId} = command.params; const url = connection.pausing.get(requestId); connection.pausing.delete(requestId); connection.pausing.delete(url); } if ( !command.name.startsWith("Target") && !(command.name.startsWith("Browser") && command.name != "Browser.getWindowForTarget") ) { sessionId = command.params.sessionId || that.sessionId; } else if ( command.name == "Target.activateTarget" ) { that.sessionId = sessions.get(targetId); that.targetId = targetId; sessionId = that.sessionId; const worlds = connection.worlds.get(sessionId); if ( ! worlds ) { DEBUG.val && console.log("reloading because no worlds we can access yet"); await send("Page.reload", {}, sessionId); } else { DEBUG.val && console.log("Activate",sessionId); } } if ( command.name.startsWith("Target") || ! sessionId ) { if ( command.name.startsWith("Page") || command.name.startsWith("Runtime") ) { sessionId = that.sessionId; if ( sessionId ) { return await send(command.name, command.params, sessionId); } else { DEBUG.val && console.log(`Blocking as ${command.name} must be run with session.`, command); return {}; } } else { DEBUG.val > DEBUG.med && console.log({zombieNoSessionCommand:command}); const resp = await send(command.name, command.params); return resp; } } else { sessionId = command.params.sessionId || that.sessionId; if ( ! sessions.has(sessionId) ) { DEBUG.val && console.log("Blocking as session not exist.", sessionId); return {}; } if ( !! command.params.contextId && ! hasContext(sessionId, command.params.contextId) ) { DEBUG.val && console.log("Blocking as context does not exist.", command, sessionId, connection.worlds, connection.worlds.get(sessionId) ); return {}; } DEBUG.val > DEBUG.med && command.name !== "Page.captureScreenshot" && command.name !== "HeadlessExperimental.beginFrame" && console.log({zombieSessionCommand:command}); try { const r = await send(command.name, command.params, sessionId); return r; } catch(e) { console.log(e); try { if ( e.Error && e.Error.indexOf("session") ) { removeSession(e.request.params.sessionId); DEBUG.val > DEBUG.med && console.log("Removed session"); } } finally { void 0; } } } } function addContext(id, contextId) { DEBUG.val > DEBUG.med && console.log({addingContext:{id,contextId}}); const otherId = sessions.get(id); let contexts = connection.worlds.get(id); if ( ! contexts ) { contexts = new Set(); connection.worlds.set(id, contexts); connection.worlds.set(otherId, contexts); } contexts.add(contextId); } function hasContext(sessionId, contextId) { const id = sessionId || connection.sessionId; const contexts = connection.worlds.get(id); if ( ! contexts ) return false; else return contexts.has(contextId); } function deleteContext(id, contextId) { DEBUG.val > DEBUG.med && console.log({deletingContext:{id,contextId}}); //const otherId = sessions.get(id); let contexts = connection.worlds.get(id); if ( contexts ) { contexts.delete(contextId); } } function deleteWorld(id) { const otherId = sessions.get(id); connection.worlds.delete(id); connection.worlds.delete(otherId); } function endTarget({targetId}, label) { DEBUG.val > DEBUG.med && console.warn({[label]:{targetId}}); targets.delete(targetId); tabs.delete(targetId); removeSession(targetId); deleteWorld(targetId); connection.meta.push({[label]:{targetId}}); } } function saveTargetIdAsGlobal(targetId) { return ` { const targetId = "${targetId}"; Object.defineProperty(self, 'targetId', { get: () => targetId }); } `; } function isFileURL(url) { const firstColonIndex = url.indexOf(':'); const scheme = url.slice(firstColonIndex-4, firstColonIndex); return scheme == 'file'; } async function makeZombie({port:port = 9222} = {}) { const {webSocketDebuggerUrl} = await fetch(`http://localhost:${port}/json/version`).then(r => r.json()); const socket = new ws(webSocketDebuggerUrl); const Zombie = { disconnected: true }; const Resolvers = {}; const Handlers = {}; socket.on('message', handle); socket.on('close', () => { Zombie.disconnected = true; }); let id = 0; async function send(method, params = {}, sessionId) { if ( Zombie.disconnected ) { Zombie.sendErrorToClient('Our connection to chrome is disconnected. Probably means chrome shut down or crashed.'); return; } const message = { method, params, sessionId, id: ++id }; const key = `${sessionId||ROOT_SESSION}:${message.id}`; let resolve; let promise = new Promise(res => resolve = res); if ( DEBUG.commands ) { if ( !(message.method == "Page.captureScreenshot" && ! DEBUG.shotDebug) ) { console.log({send:message}); promise = promise.then(resp => { if ( resp && resp.data ) { if ( resp.data.length < 1000 ) { console.log({message,resp}); } else { console.log(JSON.stringify({message,resp:'[long response]'},null,2)); } } else { console.log(JSON.stringify({message,resp: resp || '[no response]'},null,2)); } return resp; }).catch(err => { console.warn({sendFail:err}); }); } } Resolvers[key] = resolve; try { socket.send(JSON.stringify(message)); } catch(e) { console.warn("Error sending to chrome", e); Zombie.sendErrorToClient(e); } return promise; } async function handle(message) { const stringMessage = message; message = JSON.parse(message); const {sessionId} = message; const {method} = message; const {id, result} = message; if ( id ) { const key = `${sessionId||ROOT_SESSION}:${id}`; const resolve = Resolvers[key]; if ( ! resolve ) { console.warn(`No resolver for key`, key, stringMessage.slice(0,140)); } else { Resolvers[key] = undefined; try { await resolve(result); } catch(e) { console.warn(`Resolver failed`, e, key, stringMessage.slice(0,140), resolve); } } } else if ( method ) { const listeners = Handlers[method]; if ( Array.isArray(listeners) ) { for( const func of listeners ) { try { await func({message, sessionId}); } catch(e) { console.warn(`Listener failed`, method, e, func.toString().slice(0,140), stringMessage.slice(0,140)); } } } } else { console.warn(`Unknown message on socket`, message); } } function on(method, handler) { let listeners = Handlers[method]; if ( ! listeners ) { Handlers[method] = listeners = []; } listeners.push(wrap(handler)); } function ons(method, handler) { let listeners = Handlers[method]; if ( ! listeners ) { Handlers[method] = listeners = []; } listeners.push(handler); } function wrap(fn) { return ({message}) => fn(message.params) } let resolve; const promise = new Promise(res => resolve = res); socket.on('open', () => { Zombie.disconnected = false; resolve(); }); await promise; Object.assign(Zombie, { send, on, ons }); return Zombie; } function getFileFromURL(url) { url = new URL(url); const {pathname} = url; const nodes = pathname.split('/'); let lastNode = nodes.pop(); if ( ! lastNode ) { DEBUG.val > DEBUG.med && console.warn({url, nodes, fileNameError: Error(`URL cannot be parsed to get filename`)}); return `download${Date.now()}`; } const name = unescape(lastNode); // MARK 2 DEBUG.val && console.log({name}); return name; } function convertRemoteObjectToString({type, className, value, unserializableValue, description}) { let asString; if ( value ) { try { asString = JSON.stringify(value); } finally { void 0; } } else if ( unserializableValue ) { try { asString = unserializableValue + ""; } finally { void 0; } } return `${type}:${className||type}:${asString||''}:${description||'[unknown value]'}`; }