bbpro
Version:
BrowserBox - remote browser isolation tool
1,308 lines (1,171 loc) • 84.2 kB
JavaScript
// imports
import {handleSelectMessage} from './handlers/selectInput.js';
import {fetchTabs} from './handlers/targetInfo.js';
import {detectIMEInput} from './ime_detection.js';
import {handleMultiplayerMessage} from './handlers/multiplayer.js';
import {handleKeysCanInputMessage} from './handlers/keysCanInput.js';
import {handleElementInfo} from './handlers/elementInfo.js';
import {CTX_MENU_THRESHOLD, makeContextMenuBondTasks} from './subviews/contextMenu.js';
import {handleScrollNotification} from './handlers/scrollNotify.js';
import {resetLoadingIndicator,showLoadingIndicator} from './handlers/loadingIndicator.js';
import {resetFavicon, handleFaviconMessage} from './handlers/favicon.js';
import DEFAULT_FAVICON from './subviews/faviconDataURL.js';
import EventQueue from './eventQueue.js';
import {default as transformEvent, getKeyId, controlChars} from './transformEvent.js';
import {saveClick} from './subviews/controls.js';
import {
untilTrue,
logit, sleep, debounce, DEBUG, BLANK,
CONFIG,
OPTIONS,
isFirefox, isSafari, deviceIsMobile,
SERVICE_COUNT,
// for bang
CHAR,
iden, throttle, elogit,
logitKeyInputEvent,
} from './common.js';
import {component, subviews, saveCanvas, audio_login_url} from './view.js';
//import installDemoPlugin from '../../plugins/demo/installPlugin.js';
//import installAppminifierPlugin from '../../plugins/appminifier/installPlugin.js';
//import installProjectorPlugin from '../../plugins/projector/installPlugin.js';
// for bang
import {s as R, c as X} from '../node_modules/bang.html/src/vv/vanillaview.js';
// constants
const ThrottledEvents = new Set([
"mousemove", "pointermove", "touchmove"
]);
const CancelWhenSyncValue = new Set([
/*
"keydown",
"keyup",
"keypress",
"compositionstart",
"compositionend",
"compositionupdate",
*/
]);
const EnsureCancelWhenSyncValue = e => {
if ( !e.type.startsWith("key") ) {
return true;
} else {
const id = getKeyId(e);
return !controlChars.has(id);
}
};
const SessionlessEvents = new Set([
"window-bounds",
"user-agent",
"hide-scrollbars",
"respond-to-modal",
]);
const DOC_FORMATS = new Set([
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ps',
'rtf',
'odf',
'ppt',
'pptx',
'pub',
'pages'
]);
const FLASH_FORMATS = new Set([
'as',
'swf',
'jsfl',
'fla'
]);
// exponential backoff until fail constants for audio websocket reconnect
const AUDIO_RECONNECT_MS = 200;
const AUDIO_RECONNECT_MAX = 60000;
const AUDIO_RECONNECT_MULTIPLIER = 1.618;
const IMMEDIATE = 0;
const SHORT_DELAY = 20;
const LONG_DELAY = 300;
const VERY_LONG_DELAY = 60000;
const EVENT_THROTTLE_MS = 60; /* 20, 40, 80 */
// main export
export default async function voodoo(selector, position, {
postInstallTasks: postInstallTasks = [],
preInstallTasks: preInstallTasks = [],
canvasBondTasks: canvasBondTasks = [],
bondTasks: bondTasks = [],
useViewFrame: useViewFrame = false,
demoMode: demoMode = false,
} = {}) {
// security
const sessionToken = globalThis._sessionToken();
location.hash = '';
// constants
const closed = new Set();
const LastFrames = new Map();
const listeners = new Map();
const lastTarget = '[lastTarget]';
const state = {
mySource: (Math.random()*1e10).toString(36)
};
const {tabs,vmPaused,activeTarget,requestId} = await (/*demoMode ? fetchDemoTabs() : */ fetchTabs({sessionToken}, () => state));
// when app loads get favicons for any existing tabs
tabs.forEach(({targetId}) => {
//initialGetFavicon(targetId);
//getFavicon(targetId);
});
// MIGRATE (go)
const {
go,
limitCursor,
} = subviews;
// url params
const urlParams = new URLSearchParams(location.search);
const urlFlags = parseURLFlags(urlParams);
// app state
magicAssign(state, {
H,
checkResults,
// set up progress
safariLongTapInstalled: false,
// chrome browser UI (tabs, address bar, etc)
chromeUI: (
CONFIG.uiDefaultOff ? false :
urlFlags.has('chromeUI') ? urlFlags.get('chromeUI') : (
!DEBUG.toggleUI ||
!deviceIsMobile() ||
DEBUG.mobileUIDefault
)
),
// Client IME UI
hideIMEUI,
showIMEUI,
// IME input detection
detectIMEInput,
// bandwidth
showBandwidthIssue: false,
messageDelay: 0, // time it takes to receive an average, non-frame message
showBandwidthRate: true,
myBandwidth: 0,
serverBandwidth: 0,
totalBytes: 0,
totalServerBytesThisSecond: 0,
totalBytesThisSecond: 0,
totalBandwidth: 0,
frameBandwidth: [],
// demo mode
demoMode,
// if we are using a view frame (instead of canvas)
useViewFrame,
// for chrome vs firefox and mobile vs desktop to handle
// different ways of doing IME input and keypress
openKey: '',
lastKeypressKey: '',
// for firefox because it's IME does not fire inputType
// so we have no simple way to handle deleting content backward
// this should be FF on MOBILE only probably so that's why it's false
isMobile: deviceIsMobile(),
get currentInputLanguageUsesIME() {
//console.log('requested ime use', this, (new Error).stack);
self._state = state;
return state.usingIME;
},
get convertTypingEventsToSyncValueEvents() {
return state.isMobile || state.currentInputLanguageUsesIME;
},
// for safari to detect if pointerevents work
DoesNotSupportPointerEvents: true,
// safari to keep track of composition
isComposing: false,
// useful for input events that don't support data
// invalidated by the first data prop set
DataIsNotSupported: true,
sizeBrowserToBounds,
asyncSizeBrowserToBounds,
emulateNavigator,
hideScrollbars,
bondTasks,
canvasBondTasks,
// tabs
updateTabsTasks: [],
lastTarget,
activeTarget: null,
tabs: tabs || [],
attached: new Set(),
activateTab,
closeTab,
createTab,
activeTab,
favicons: new Map(),
// timing constants
IMMEDIATE,
SHORT_DELAY,
LONG_DELAY,
VERY_LONG_DELAY,
EVENT_THROTTLE_MS,
viewState: {
showIMEUI: true,
ModalRef: {
},
bounds: {
x: window.innerWidth,
y: window.innerHeight,
}
},
clearViewport,
doShot,
addListener(name, func) {
let funcList = listeners.get(name);
if ( ! funcList ) {
funcList = [];
listeners.set(name, funcList);
}
funcList.push(func);
},
sidebarMenuActive: false,
sessionToken,
// services
loggedInCount: 0,
//multiplayer
unreadChatCount: 0,
chatRoute: true,
onlineCount: 1,
// shot acks
screenshotReceived: false,
latestFrameReceived: 0,
latestCastSession: 0xCC55151C,
// for bang!
DEBUG,
subviews,
R,
elogit,
saveCanvas,
throttle,
deviceIsMobile,
iden,
audio_login_url,
X,
CHAR,
limitCursor,
disabled: () => state.tabs.length === 0,
refocus: false,
loadings: new Map(),
contextMenuActive: false,
contextMenuEvent: null,
contextMenuBondTasks: makeContextMenuBondTasks(state),
untilTrue,
// MIGRATE (go)
go,
saveClick,
logitKeyInputEvent,
runUpdateTabs,
// for Magic bar
// and also for Tor
CONFIG,
// for offline
OPTIONS,
writeCanvas,
get setTopState() {
return () => {
setState('bbpro', state);
}
},
clearBrowser() {
state.tabs = [];
state.attached = new Set();
state.activeTarget = null;
state.lastTarget = null;
state.favicons = new Map();
state.tabMap = new Map();
state.closed = new Set();
state.latestRequestId = 0;
},
latestRequestId: 0
});
// variables
let Root = document;
let lastTime = new Date;
let modaler;
state.latestRequestId = requestId;
DEBUG.exposeState && (self.state = state);
patchGlobals();
const updateTabs = debounce(rawUpdateTabs, LONG_DELAY);
/*
if ( state.demoMode ) {
state.demoEventConsumer = demoZombie;
}
*/
if ( DEBUG.dev ) {
Object.assign(self, {state});
}
const {searchParams} = new URL(location);
if ( searchParams.has('url') ) {
let urls = [];
try {
const data = JSON.parse(searchParams.get('url'));
let isList = Array.isArray(data);
if ( isList ) {
urls.push(...data);
} else {
urls.push(data);
}
urls = urls.map(url => {
try {
url = new URL(url.replace(/ /g, '+'));
if ( url.protocol == 'web+bb:' ) {
//url = url.href.slice(9);
url = `${url.pathname.replace(/\/\//, '')}${url.search}${url.hash}`;
}
return url+'';
} catch(e) {
console.warn(e, url);
return new Error(`not a URL`);
}
}).filter(thing => !(thing instanceof Error));
} catch(e) {
alert(`Issue with starting URL: ${e}`);
console.warn(e);
}
postInstallTasks.push(async ({queue}) => {
const sourceURL = new URL(location.href);
sourceURL.hash = '';
const source = sourceURL.origin+'';
for( const taskUrl of urls ) {
state.createTab(null, taskUrl, {source});
}
history.pushState("", "", "/");
});
}
const queue = new EventQueue(state, sessionToken);
DEBUG.val && console.log({queue});
if ( DEBUG.fullScope ) {
globalThis.queue = queue;
} else {
globalThis.queue = queue;
}
// check tor status
{
const isTorAPI = new URL(location.origin);
isTorAPI.pathname = '/isTor';
fetch(isTorAPI).then(r => r.json()).then(({isTor}) => {
state.isTor = isTor;
if ( state.isTor ) {
setState('bbpro', state);
}
});
}
// plugins
/**
const plugins = new Map;
if ( state.useViewFrame ) {
installAppminifierPlugin(state,queue);
if ( location.pathname == "/factory.html" ) {
installProjectorPlugin(state,queue);
}
if ( state.demoMode ) {
installDemoPlugin(state,queue);
}
}
if ( isSafari() ) {
queue.send({type:"isSafari"});
} else if ( isFirefox() ) {
queue.send({type:"isFirefox"});
}
**/
if ( deviceIsMobile() ) {
state.hideScrollbars();
queue.send({type:"isMobile"});
}
// event handlers
// multiplayer connections
const MENU = new URL(location);
MENU.port = parseInt(location.port) - 1;
const CHAT = new URL(location);
CHAT.port = parseInt(location.port) + 2;
self.addEventListener('message', ({data, origin, source}) => {
if ( origin === CHAT.origin ) {
if ( data.multiplayer ) {
const {chatRoute, messageReceived} = data.multiplayer;
if ( chatRoute !== undefined ) {
DEBUG.val && console.log({chatRoute});
state.chatRoute = chatRoute;
}
if ( messageReceived ) {
DEBUG.val && console.log('message received');
state.unreadChatCount += 1;
}
}
} else if ( origin === MENU.origin ) {
if ( data.multiplayer ) {
const {view} = data.multiplayer;
DEBUG.val && console.log({view});
state.menuView = view;
}
}
if ( state.sidebarMenuActive ) {
if ( state.menuView === 'tab-chat' && state.chatRoute ) {
state.unreadChatCount = 0;
}
}
DEBUG.val && console.log({sidebarMenuActive: state.sidebarMenuActive});
subviews.UnreadBadge(state);
});
queue.addMetaListener('resize', meta => {
DEBUG.debugResize && console.log(`resize event`, meta);
clearViewport();
});
queue.addMetaListener('multiplayer', meta => handleMultiplayerMessage(meta, state));
// download progress
queue.addMetaListener('downloPro', ({downloPro}) => {
DEBUG.debugDownloadProgress && console.log(JSON.stringify({downloPro}, null, 2));
const {receivedBytes, totalBytes, done, state: dlState} = downloPro;
if ( dlState == 'canceled' ) {
const {guid, receivedBytes} = downloPro;
console.warn(`Download ${guid} cancelled after ${receivedBytes} bytes received.`);
}
state.topBarComponent.updateDownloadStatus(downloPro);
});
// should go in bb-view script.js
// (so we can use el.shadowRoot instead of document)
// as context for querySelector
// audio login
{
const AUDIO = CONFIG.isOnion ? new URL(
`${location.protocol}//${localStorage.getItem(CONFIG.audioServiceFileName)}`
)
:
new URL(location)
;
AUDIO.pathname = DEBUG.useStraightAudioStream ? '/' : '/stream';
AUDIO.port = CONFIG.isOnion ? 443 : parseInt(location.port) - 2;
AUDIO.searchParams.set('ran', Math.random());
if ( CONFIG.isOnion ) {
// due to 3rd-party cookie restrictions in Tor browser we take an easy approach for now
// simple logging in to the audio stream using a token every time, avoiding any need for cookies
AUDIO.searchParams.set('token', localStorage.getItem(CONFIG.sessionTokenFileName));
setupAudioElement('audio/wav');
} else {
self.addEventListener('message', ({data, origin, source}) => {
DEBUG.val && console.log('message for audio', {data,origin,source});
if ( origin === AUDIO.origin ) {
if ( data.request ) {
if ( data.request.login ) {
DEBUG.val && console.log('send session token', data, state.sessionToken);
source.postMessage({login:{sessionToken:state.sessionToken}}, origin);
} else if ( data.request.audio ) {
DEBUG.debugAudio && console.log('doing audio');
state.loggedInCount += 1;
if ( state.loggedInCount >= SERVICE_COUNT ) {
console.log("Everybody logged in");
state.sessionToken = null;
}
const frame = Root.querySelector('iframe#audio-login');
frame?.remove();
if ( DEBUG.useStraightAudioStream ) {
setupAudioElement('audio/wav');
} else {
let activateAudio;
let fetchedData;
if ( DEBUG.includeAudioElementAnyway ) {
const audio = Root.querySelector('video#audio');
const source = document.createElement('source');
source.type = 'audio/wav';
source.src = '/silent_half-second.wav'; // this is needed to trigger web audio audibility in some browsers
if ( audio ) {
audio.append(source);
audio.addEventListener('playing', () => {
DEBUG.debugAudio && console.log('audio playing ~~ for first time since page load, so clearing buffer');
fetchedData = new Float32Array( 0 );
audio.playing = true;
});
audio.addEventListener('waiting', () => audio.playing = false);
audio.addEventListener('ended', () => audio.playing = false);
activateAudio = async () => {
DEBUG.debugAudio && console.log('called activate audio');
audio.muted = false;
audio.removeAttribute('muted');
if ( true || !audio.playing ) {
try {
DEBUG.debugAudio && console.log(`Trying to play...`);
await audio.play();
send("ack");
} catch(err) {
DEBUG.debugAudio && console.info(`Could not yet play audio`, err);
return;
}
}
if ( CONFIG.removeAudioStartHandlersAfterFirstStart ) {
Root.removeEventListener('pointerdown', activateAudio);
Root.removeEventListener('touchend', activateAudio);
DEBUG.debugAudio && console.log('Removed audio start handlers');
}
};
setTimeout(activateAudio, 0);
Root.addEventListener('pointerdown', activateAudio);
Root.addEventListener('touchend', activateAudio);
Root.addEventListener('click', activateAudio, {once:true});
DEBUG.debugAudio && console.log('added handlers', Root, audio);
} else {
console.log(Root);
console.warn(`Audio element 'video#audio' not found.`);
}
}
const audios = [];
self.audios = audios;
const wsUri = new URL(AUDIO);
wsUri.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUri.hash = '';
let ws;
const channels = 1;
const min_sample_duration = 0.5; // seconds;
const sample_rate = 44100; // Hz;
const min_sample_size = min_sample_duration * sample_rate;
let chunk_size = 2**14;
let counter = 1;
let playing = false;
let active = false;
let ctx;
let gain;
let activeNode;
let is_reading = false;
let audioReconnectMs = AUDIO_RECONNECT_MS;
let stopped = true;
fetchedData = new Float32Array( 0 );
const ensureResume = () => {
if ( ! ctx ) return;
ctx && ctx.resume();
document.removeEventListener('click', ensureResume);
};
document.addEventListener('click', ensureResume);
document.addEventListener('click', beginSound, {once: true});
beginSound();
function beginSound() {
stopped = false;
active = true;
ctx = new AudioContext({
sampleRate: sample_rate
});
gain = ctx.createGain();
gain.gain.value = 0.618;
gain.connect( ctx.destination );
connectAudio();
readingLoop();
}
function connectAudio() {
// 0 - CONNECTING, 1 - OPEN, 2 - CLOSING, 3 - CLOSED
if ( ws?.readyState < 2 ) return;
DEBUG.debugAudio && console.log(`Creating websocket...`);
ws = new WebSocket(wsUri);
DEBUG.debugAudio && console.log('Create ws');
DEBUG.debugAudio && console.log(`Created`, ws);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', msg => {
DEBUG.debugAudio && console.log('ws open');
DEBUG.debugAudio && console.log(`Audio websocket connected`, msg);
state.audioConnected = true;
state.setTopState();
audioReconnectMs = AUDIO_RECONNECT_MS;
setTimeout(activateAudio, 0);
readingLoop();
});
ws.addEventListener('close', msg => {
DEBUG.debugAudio && console.log(`Audio websocket closing`, msg);
state.audioConnected = false;
audioReconnectMs = AUDIO_RECONNECT_MS;
setTimeout(audioReconnect, 0);
state.setTopState();
});
ws.addEventListener('message', async msg => {
DEBUG.debugAudioAcks && console.log('client got msg %s', msg.data.length || msg.data.byteLength);
send("ack");
if ( typeof msg.data === "string" ) {
DEBUG.debugAudioAck && console.log('audio ws message', msg.data);
return;
}
const audioBuffer = new Int16Array(msg.data);
fetchedData = concat(fetchedData, audioBuffer);
if ( !is_reading && fetchedData.length >= min_sample_size ) {
readingLoop();
}
});
ws.addEventListener('error', async err => {
console.warn('Audio websocket client got error', err);
state.audioConnected = false;
audioReconnect(err);
state.setTopState();
});
}
async function audioReconnect(msg) {
DEBUG.debugAudio && console.log(`Audio websocket waiting ${audioReconnectMs}ms...`, {msg});
await sleep(audioReconnectMs);
audioReconnectMs *= AUDIO_RECONNECT_MULTIPLIER;
if ( audioReconnectMs <= AUDIO_RECONNECT_MAX ) {
DEBUG.debugAudio && console.log(`Audio websocket attempting reconnect...`);
setTimeout(connectAudio, 0);
} else {
const keepTrying = confirm(
"We haven't been about to connect to the audio service for a while. Should we keep trying?"
);
if ( keepTrying ) {
audioReconnectMs = AUDIO_RECONNECT_MS;
setTimeout(audioReconnect, 0);
}
}
}
function readingLoop() {
if ( stopped || fetchedData.length < min_sample_size ) {
is_reading = false;
DEBUG.debugAudio && console.log('not playing');
return;
}
is_reading = true;
DEBUG.debugAudioAcks && console.log('reading');
const audio_buffer = ctx.createBuffer(
channels,
fetchedData.length,
sample_rate
);
try {
DEBUG.debugAudioAcks && console.log(fetchedData.length);
audio_buffer.copyToChannel( fetchedData, 0 );
fetchedData = new Float32Array( 0 );
activeNode = ctx.createBufferSource();
activeNode.buffer = audio_buffer;
// ended not end
activeNode.addEventListener('ended', () => {
DEBUG.debugAudioAcks && console.log('node end');
send("stop");
readingLoop();
});
activeNode.connect( gain );
activeNode.start( 0 );
DEBUG.debugAudioAcks && console.log('playing');
} catch(e) {
console.warn(e);
}
}
function concat( arr1, arr2 ) {
if( !arr2 || !arr2.length ) {
return arr1 && arr1.slice();
}
const arr2_32 = new Float32Array( arr2.byteLength / 2 );
const dv = new DataView( arr2.buffer );
for( let i = 0, offset = 0; offset < arr2.byteLength; i++, offset += 2 ) {
const v = dv.getInt16(offset, true);
arr2_32[i] = v > 0 ? (v / 32767.0) : (v / 32768.0);
}
const out = new arr1.constructor( arr1.length + arr2_32.length );
out.set( arr1 );
out.set( arr2_32, arr1.length );
DEBUG.debugAudioAcks && console.log('out length ' + out.length);
return out;
}
function send(o) {
if ( typeof o === 'string' ) {
ws.send(o);
} else if ( o instanceof ArrayBuffer ) {
ws.send(o);
} else {
ws.send(JSON.stringify(o));
}
}
function startPlaying() {
DEBUG.debugAudio && console.log('start playing', playing, audios);
if ( playing || !active ) return;
console.warn(`startPlaying not implemented yet`);
}
}
}
}
}
});
}
async function setupAudioElement(type = 'audio/wav') {
const bb = document.querySelector('bb-view');
if ( !bb?.shadowRoot ) {
await untilTrue(() => !!document.querySelector('bb-view')?.shadowRoot, 1000, 600);
}
Root = document.querySelector('bb-view').shadowRoot;
Root = document.querySelector('bb-view').shadowRoot;
const audio = Root.querySelector('video#audio');
//const source = document.createElement('source');
setAudioSource(AUDIO);
DEBUG.debugAudio && console.log({'audio?': audio});
let existingTimer = null;
let waiting = false;
if ( audio ) {
//audio.append(source);
// Event listener for when audio is successfully loaded
audio.addEventListener('loadeddata', () => {
console.log('Audio loaded');
audio.play();
});
audio.addEventListener('playing', () => {
audio.playing = true;
audio.hasError = false;
});
// Event listener for playing state
audio.addEventListener('play', () => {
audio.playing = true;
audio.hasError = false;
console.log('Audio playing');
});
audio.addEventListener('waiting', () => audio.playing = false);
audio.addEventListener('ended', () => {
audio.playing = false;
DEBUG.debugAudio && console.log(`Audio stream ended`);
existingTimer = setTimeout(startAudioStream, 100);
});
audio.addEventListener('error', err => {
audio.playing = false;
audio.hasError = true;
DEBUG.debugAudio && console.log(`Audio stream errored`, err);
});
// Event listener for pause state
audio.addEventListener('pause', () => {
audio.playing = false;
console.log('Audio paused');
});
const activateAudio = async () => {
DEBUG.debugAudio && console.log('called activate audio');
if ( audio.muted || audio.hasAttribute('muted') ) {
audio.muted = false;
audio.removeAttribute('muted');
}
if ( !audio.playing ) {
try {
startAudioStream();
//send("ack");
} catch(err) {
DEBUG.debugAudio && console.info(`Could not yet play audio`, err);
return;
}
}
if ( CONFIG.removeAudioStartHandlersAfterFirstStart /*|| CONFIG.isOnion */ ) {
Root.removeEventListener('pointerdown', activateAudio);
Root.removeEventListener('touchend', activateAudio);
DEBUG.debugAudio && console.log('Removed audio start handlers');
}
};
Root.addEventListener('pointerdown', activateAudio);
Root.addEventListener('touchend', activateAudio);
Root.addEventListener('click', activateAudio, {once:true});
DEBUG.debugAudio && console.log('added handlers', Root, audio);
async function startAudioStream() {
setAudioSource(AUDIO);
}
} else {
console.log(Root);
console.warn(`Audio element 'video#audio' not found.`);
}
function setAudioSource(src) {
// Check if the audio is already loading or playing the same source
if (audio.src === src && !audio.error && ! audio.hasError && (audio.readyState > 2 || audio.playing)) {
console.log('Audio is already playing or loading this source');
return;
}
// Reset error state and set the new source
audio.hasError = null;
audio.src = src;
}
}
}
// input
queue.addMetaListener('selectInput', meta => handleSelectMessage(meta, state));
queue.addMetaListener('selectInput', meta => console.log({meta}));
queue.addMetaListener('keyInput', meta => handleKeysCanInputMessage(meta, state));
queue.addMetaListener('favicon', meta => handleFaviconMessage(meta, state));
//queue.addMetaListener('navigated', () => canKeysInput());
queue.addMetaListener('navigated', async ({navigated:{targetId}}) => {
await resetFavicon({targetId}, state);
// reset favicon event to send to remote
//initialGetFavicon(targetId);
//getFavicon(targetId);
});
//queue.addMetaListener('navigated', meta => takeShot(meta, state));
// element info
queue.addMetaListener('elementInfo', meta => handleElementInfo(meta, state));
DEBUG.debugCopyPaste && queue.addMetaListener('elementInfo', meta => console.info('Element info', meta));
// scroll
queue.addMetaListener('scroll', meta => handleScrollNotification(meta, state));
// loading
queue.addMetaListener('resource', meta => showLoadingIndicator(meta, state));
queue.addMetaListener('failed', meta => {
if ( meta.failed.params.type == "Document" ) {
// we also need to make sure the failure happens at the top level document
// rather than writing the top level document for any failure in a sub frame
if ( meta.failed.params.errorText && meta.failed.params.errorText.includes("ABORTED") ) {
// do nothing. Aborts are normal, and just mean existing document stays there. If we
// overwrite that document with an aborted message, the normal function of the page
// will fail
// so we could notify with a writeCanvas instead
// writeCanvas(`Request failed: ${meta.failed.params.errorText}`, meta.failed.frameId, meta.failed.sessionId);
// but there's not really any point since these aborted errors are normally
// quickly followed by something that works
// plus people are not used to seeing aborted
} else {
writeDocument(`Request failed: ${meta.failed.params.errorText}`, meta.failed.frameId, meta.failed.sessionId);
}
}
});
queue.addMetaListener('navigated', meta => resetLoadingIndicator(meta, state));
if ( DEBUG.val >= DEBUG.med ) {
queue.addMetaListener('vm', meta => console.log(meta));
queue.addMetaListener('modal', meta => console.log(meta));
queue.addMetaListener('navigated', meta => console.log(meta));
queue.addMetaListener('changed', meta => console.log(meta));
queue.addMetaListener('created', meta => console.log(meta));
queue.addMetaListener('attached', meta => console.log(meta));
queue.addMetaListener('detached', meta => console.log(meta));
queue.addMetaListener('destroyed', meta => console.log(meta));
queue.addMetaListener('crashed', meta => console.log(meta));
queue.addMetaListener('consoleMessage', meta => console.log(meta));
}
// vm
queue.addMetaListener('vm', ({vm}) => {
// as soon as the remote JS vm is unpaused (after dialog closed, for example)
// fetch the tabs
DEBUG.debugModal && console.log({vm});
if ( !vm.paused ) {
// update tabs (to trigger vm paused warning if it's still paused
// 1 second after a modal closes only if there is no other modal open then
clearTimeout(modaler);
modaler = setTimeout(() => !state.viewState.currentModal && updateTabs(), 1000);
}
});
// tabs
// patch tabs array with changes as they come through
queue.addMetaListener('changed', ({changed}) => {
const tab = findTab(changed.targetId);
if ( tab ) {
Object.assign(tab, changed);
setState('bbpro', state);
} else {
DEBUG.activateDebug && console.warn(`changed tab not found in our list`, changed);
}
updateTabs();
});
queue.addMetaListener('created', meta => {
if ( meta.created.type == 'page') {
meta.created.hello = 'oncreated';
const activate = () => activateTab(null, meta.created, {notify: false, forceFrame:true})
const tab = findTab(meta.created.targetId);
if ( tab ) {
if ( DEBUG.activateNewTab ) {
DEBUG.val && console.log('Setting activate to occur after delay for new tab');
setTimeout(activate, LONG_DELAY);
}
} else {
DEBUG.activateDebug && console.warn('created tab not found in our list', meta.created);
if ( DEBUG.activateNewTab ) {
DEBUG.activateDebug && console.log('Pushing activate for new tab');
state.updateTabsTasks.push(activate);
}
updateTabs();
}
}
});
queue.addMetaListener('attached', meta => {
const attached = meta.attached.targetInfo;
if ( attached.type == 'page' ) {
state.attached.add(attached.targetId);
if ( state.useViewFrame ) {
//sizeBrowserToBounds(state.viewState.viewFrameEl, attached.targetId);
} else {
//sizeBrowserToBounds(state.viewState.canvasEl, attached.targetId);
emulateNavigator();
}
//state.updateTabsTasks.push(() => initialGetFavicon(attached.targetId));
updateTabs();
}
});
queue.addMetaListener('navigated', updateTabs);
queue.addMetaListener('detached', updateTabs);
queue.addMetaListener('destroyed', ({destroyed}) => {
closed.delete(destroyed.targetId);
state.attached.delete(destroyed.targetId);
LastFrames.delete(destroyed.targetId);
updateTabs();
});
queue.addMetaListener('crashed', updateTabs);
queue.addMetaListener('activateTarget', ({activateTarget}) => {
(DEBUG.val || DEBUG.activateDebug) && console.log(`Received activate request`, {activateTarget});
if ( activateTarget.source == state.mySource ) {
(DEBUG.val || DEBUG.activateDebug) && console.log(`Ignoring activate request as came from us`);
return;
}
const tab = findTab(activateTarget.targetId);
const activate = () => activateTab(null, activateTarget, {notify: false, forceFrame:true})
activateTarget.hello = 'refered->active';
if ( tab ) {
if ( DEBUG.activateNewTab ) {
(DEBUG.val || DEBUG.activateDebug) && console.log('Refered -> Activate now');
activate();
}
} else {
DEBUG.activateDebug && console.warn(
'activateTarget: created tab not found in our list',
activateTarget
);
if ( DEBUG.activateNewTab ) {
DEBUG.activateDebug && console.log('Refered -> Pushing activate for new tab');
state.updateTabsTasks.push(activate);
}
updateTabs();
}
});
//modals
queue.addMetaListener('modal', modalMessage => state.viewState.modalComponent.openModal(modalMessage));
queue.addMetaListener('closeModal', modalCloseRequest => {
DEBUG.val && console.log({modalCloseRequest});
DEBUG.debugModal && console.log({modalCloseRequest});
state.viewState.modalComponent.onlyCloseModal(state.viewState.modalComponent.state);
// update tabs (to trigger vm paused warning if it's still paused
// 1 second after a modal closes only if there is no other modal open then
clearTimeout(modaler);
modaler = setTimeout(() => !state.viewState.currentModal && updateTabs(), 1000);
});
// remote secure downloads
queue.addMetaListener('download', ({download}) => {
const {sessionId, filename} = download;
const ext = filename.toLowerCase().split('.').pop();
let message;
if ( FLASH_FORMATS.has(ext) ) {
message = `The Adobe Flash file "${filename}" is saving to the server and will be opened with the Ruffle Flash Emulator soon.`;
} else if ( DOC_FORMATS.has(ext) ) {
message = `The ${ext.toUpperCase()} document "${filename}" is saving to the server and will be opened with the DocumentSpark Secure Document Viewer CDR Tool soon.`;
} else {
message = `The ${ext.toUpperCase()} file "${filename}" is saving to the server and will be displayed soon if it is a supported format.`;
}
const modal = {
sessionId,
type: 'notice',
message,
/*
otherButton: {
title: 'Buy',
onclick: () => window.top.open('https://buy.stripe.com/dR615g7hL0Mjeek5kx', "_blank")
},
*/
title: "SecureView\u2122 Enabled",
};
CONFIG.showModalOnFileDownload && state.viewState.modalComponent.openModal({modal});
});
queue.addMetaListener('secureview', ({secureview}) => {
DEBUG.val && console.log('secureview', secureview);
const {url} = secureview;
if ( url ) {
if ( DEBUG.useWindowOpenForSecureView ) {
window.top.open(url);
} else {
createTab(null, url);
}
}
});
queue.addMetaListener('bandwidthIssue', ({bandwidthIssue}) => {
const showBandwidthIssue = bandwidthIssue == 'yes';
if ( state.showBandwidthIssue != showBandwidthIssue ) {
DEBUG.logBandwidthIssueChanges && console.log({bandwidthIssue});
state.showBandwidthIssue = showBandwidthIssue;
setState('bbpro', state);
}
});
queue.addMetaListener('flashplayer', ({flashplayer}) => {
const {url} = flashplayer;
if ( url ) {
createTab(null, url);
}
});
// HTTP auth
queue.addMetaListener('authRequired', ({authRequired}) => {
const {requestId} = authRequired;
const modal = {
requestId,
type: 'auth',
message: `Provide credentials to continue`,
title: `HTTP Authentication`,
};
state.viewState.modalComponent.openModal({modal});
});
// File chooser
queue.addMetaListener('fileChooser', ({fileChooser}) => {
const {sessionId, mode, accept, csrfToken} = fileChooser;
DEBUG.val && console.log('client receive file chooser notification', fileChooser);
const modal = {
sessionId, mode, accept, csrfToken,
type: 'filechooser',
message: `Securely send files to the remote page.`,
title: `File Upload`,
};
DEBUG.val && console.log({fileChooserModal:modal});
state.viewState.modalComponent.openModal({modal});
});
// make this so we can call it on resize
window._voodoo_asyncSizeTab = async (opts) => {
await sleep(40);
return sizeTab(opts);
};
// bond tasks
canvasBondTasks.push(indicateNoOpenTabs);
canvasBondTasks.push(installZoomListener);
canvasBondTasks.push(el => asyncSizeBrowserToBounds(el, null, {resetRequested:false}));
canvasBondTasks.push(rawUpdateTabs);
if ( isSafari() ) {
canvasBondTasks.push(installSafariLongTapListener);
}
bondTasks.push(canKeysInput);
bondTasks.push(installTopLevelKeyListeners);
const preInstallView = {queue};
for( const task of preInstallTasks ) {
try {
task(preInstallView);
} catch(e) {
console.error(`Task ${task} failed with ${e}`);
}
}
bangFig({
componentsPath: './voodoo/src/components',
useMagicClone: true
});
await bangLoaded();
{
DEBUG.val && console.log('bang loaded: main voodoo module script.');
/*
component(state).then(async x => {
console.log({x});
const y = await x(state);
console.log({y});
Root = document;
y.to(selector, position);
}).catch(err => console.log({err}));
*/
try {
await component(state);
await subviews.Controls(state);
setEnv({
DEBUG,
DEFAULT_FAVICON
});
setState('bbpro', state);
use('bb-view');
use('bb-bar');
use('bb-tabs');
use('bb-ctrl');
use('bb-select-tab');
use('bb-favicon');
use('bb-loading-indicator');
use('bb-context-menu');
use('bb-omni-box');
use('bb-top-bar');
use('bb-modals');
use('bb-resize-button');
use('bb-bw-spinner');
use('bb-settings-button');
const bb = document.querySelector('bb-view');
if ( !bb?.shadowRoot ) {
await untilTrue(() => !!document.querySelector('bb-view')?.shadowRoot);
}
Root = document.querySelector('bb-view').shadowRoot;
} catch(e) {
console.error(e);
}
}
const api = {
back: () => 1,
forward: () => 1,
reload: () => 1,
stop: () => 1,
mouse: () => 1,
touch: () => 1,
scroll: () => 1,
key: () => 1,
type: () => 1,
omni: () => 1,
newTab: () => 1,
closeTab: () => 1,
switchTab: () => 1,
};
const pluginView = {addToQueue, subscribeToQueue, requestRender, api};
const poppetView = {/*loadPlugin,*/ api};
const postInstallView = {queue};
await sleep(0);
for( const task of postInstallTasks ) {
try {
await task(postInstallView);
} catch(e) {
console.error(`Task ${task} failed with ${e}`);
}
}
window.addEventListener('offline', () => {
setTimeout(() => {
setState('bbpro', state);
writeCanvas("No connection to server");
},0);
});
window.addEventListener('online', () => {
setTimeout(() => {
setState('bbpro', state);
writeCanvas("Online. Connecting...");
}, 0);
});
if ( activeTarget ) {
setTimeout(() => activateTab(null, {hello:'onload', targetId:activeTarget}, {forceFrame:true}), LONG_DELAY);
}
return poppetView;
// closures
function checkResults() {
queue.checkResults();
}
function doShot() {
setTimeout(() => {
queue.send({
type: "doShot",
synthetic: true
});
}, SHORT_DELAY);
}
function runUpdateTabs() {
DEBUG.debugTabs && console.log(`Run update tabs called`);
updateTabs();
}
function runListeners(name, data) {
const funcList = listeners.get(name);
if ( ! funcList || funcList.length == 0 ) return false;
let score = false;
for (const func of funcList) {
try {
func(data);
score = score || true;
} catch(e) {
console.log(`Listeners func for ${name} fails: ${func}\nError: ${e + e.stack}`);
}
}
return score;
}
function findTab(id) {
return state.tabs.find(({targetId}) => id == targetId);
}
function activeTab() {
return state.tabs.length == 1 ? state.tabs[0] : findTab(state.activeTarget) || {};
}
function indicateNoOpenTabs() {
if ( state.tabs.length == 0 ) {
clearViewport();
if ( state.useViewFrame ) {
try {
state.viewState.viewFrameEl.contentDocument.body.innerHTML = `
<em>${state.factoryMode ? 'Factory Mode' : 'Custom Mode'}. No tabs open.</em>
`;
} catch(e) {
console.warn(e);
}
} else {
//writeCanvas("");
//writeCanvas("\u{1f608}", {Y:y => y + 50, noClear: true})
}
}
}
function writeCanvas(text, {X,Y,noClear: noClear = false} = {}) {
const canv = state.viewState.canvasEl;
const ctx = state.viewState.ctx;
if ( ! ctx || ! canv ) {