onibrowser
Version:
A browser in the browser
859 lines (751 loc) • 27.4 kB
JavaScript
import {takeShot} from './handlers/takeShot.js';
import {handleSelectMessage} from './handlers/selectInput.js';
import {fetchTabs} from './handlers/targetInfo.js';
import {demoZombie, fetchDemoTabs} from './handlers/demo.js';
import {handleKeysCanInputMessage} from './handlers/keysCanInput.js';
import {handleElementInfo} from './handlers/elementInfo.js';
import {handleScrollNotification} from './handlers/scrollNotify.js';
import {resetLoadingIndicator,showLoadingIndicator} from './handlers/loadingIndicator.js';
import {resetFavicon, handleFaviconMessage} from './handlers/favicon.js';
import EventQueue from './eventQueue.js';
import transformEvent from './transformEvent.js';
import {sleep, throttle, debounce, DEBUG, BLANK, isFirefox,logit, isSafari, deviceIsMobile} from './common.js';
import {component, subviews} from './view.js';
import installDemoPlugin from '../../plugins/demo/installPlugin.js';
import installAppminifierPlugin from '../../plugins/appminifier/installPlugin.js';
import installProjectorPlugin from '../../plugins/projector/installPlugin.js';
const ThrottledEvents = new Set([
"mousemove", "pointermove", "touchmove"
]);
const SessionlessEvents = new Set([
"window-bounds",
"window-bounds-preImplementation",
"user-agent",
"hide-scrollbars"
]);
const IMMEDIATE = 0;
const SHORT_DELAY = 20;
const LONG_DELAY = 300;
const VERY_LONG_DELAY = 60000;
const EVENT_THROTTLE_MS = 40; /* 20, 40, 80 */
// view frame debug
let latestRequestId = 0;
export default async function voodoo(selector, position, {
postInstallTasks: postInstallTasks = [],
preInstallTasks: preInstallTasks = [],
canvasBondTasks: canvasBondTasks = [],
bondTasks: bondTasks = [],
useViewFrame: useViewFrame = false,
demoMode: demoMode = false,
} = {}) {
const sessionToken = location.hash && location.hash.slice(1);
location.hash = '';
const closed = new Set();
const listeners = new Map();
const lastTarget = '[lastTarget]';
const {tabs,activeTarget,requestId} = await (demoMode ? fetchDemoTabs() : fetchTabs({sessionToken}));
latestRequestId = requestId;
const state = {
H,
// bandwidth
totalBytes: 0,
totalBytesThisSecond: 0,
totalBandwidth: 0,
// 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
convertTypingEventsToSyncValueEvents: isFirefox() && deviceIsMobile(),
//convertTypingEventsToSyncValueEvents: false,
// 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
lastTarget,
activeTarget,
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: {},
clearViewport,
addListener(name, func) {
let funcList = listeners.get(name);
if ( ! funcList ) {
funcList = [];
listeners.set(name, funcList);
}
funcList.push(func);
}
};
const updateTabs = debounce(rawUpdateTabs, LONG_DELAY);
if ( state.demoMode ) {
state.demoEventConsumer = demoZombie;
}
if ( DEBUG.dev ) {
Object.assign(self, {state});
}
const queue = new EventQueue(state, sessionToken);
// 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"});
}
if ( isFirefox() ) {
queue.send({type:"isFirefox"});
}
if ( deviceIsMobile() ) {
state.hideScrollbars();
}
let nextSend;
// event handlers
// input
queue.addMetaListener('selectInput', meta => handleSelectMessage(meta, state));
queue.addMetaListener('keyInput', meta => handleKeysCanInputMessage(meta, state));
queue.addMetaListener('favicon', meta => handleFaviconMessage(meta, state));
queue.addMetaListener('navigated', meta => canKeysInput());
queue.addMetaListener('navigated', ({navigated:{targetId}}) => resetFavicon({targetId}, state));
//queue.addMetaListener('navigated', meta => takeShot(meta, state));
// element info
queue.addMetaListener('elementInfo', meta => handleElementInfo(meta, state));
// scroll
queue.addMetaListener('scroll', meta => handleScrollNotification(meta, state));
// loading
queue.addMetaListener('resource', meta => showLoadingIndicator(meta, state));
queue.addMetaListener('navigated', meta => resetLoadingIndicator(meta, state));
if ( DEBUG.val >= DEBUG.med ) {
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));
}
// patch tabs array with changes as they come through
queue.addMetaListener('changed', ({changed}) => {
const tab = findTab(changed.targetId);
if ( !! tab ) {
Object.assign(tab, changed);
subviews.TabList(state);
}
updateTabs({changed});
});
// tabs
queue.addMetaListener('created', meta => {
if ( meta.created.type == 'page') {
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);
} else {
asyncSizeBrowserToBounds(state.viewState.canvasEl);
emulateNavigator();
}
updateTabs();
}
});
queue.addMetaListener('navigated', updateTabs);
queue.addMetaListener('detached', updateTabs);
queue.addMetaListener('destroyed', ({destroyed}) => {
closed.delete(destroyed.targetId);
updateTabs();
});
queue.addMetaListener('crashed', updateTabs);
//modals
queue.addMetaListener('modal', modalMessage => subviews.openModal(modalMessage, state));
// remote secure downloads
queue.addMetaListener('download', ({download}) => {
const {sessionId, frameId, url} = download;
const modal = {
sessionId,
type: 'notice',
message: `Please purchase a license to use secure file view in Community-Edition. To purchase a license mail cris@dosycorp.com to speak with someone who will help.`,
title: "SecureView\u2122",
};
subviews.openModal({modal}, state);
});
queue.addMetaListener('secureview', ({secureview}) => {
const {url} = secureview;
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 Auth`,
};
subviews.openModal({modal}, state);
});
// File chooser
queue.addMetaListener('fileChooser', ({fileChooser}) => {
const {sessionId, mode} = fileChooser;
const modal = {
sessionId, mode,
type: 'filechooser',
message: `Securely send files to the remote page.`,
title: `File Chooser`,
};
subviews.openModal({modal}, state);
});
// bond tasks
canvasBondTasks.push(indicateNoOpenTabs);
canvasBondTasks.push(installZoomListener);
canvasBondTasks.push(asyncSizeBrowserToBounds);
if ( isSafari() ) {
canvasBondTasks.push(installSafariLongTapListener);
}
bondTasks.push(canKeysInput);
bondTasks.push(getFavicon);
bondTasks.push(installTopLevelKeyListeners);
const preInstallView = {queue};
for( const task of preInstallTasks ) {
try {
task(preInstallView);
} catch(e) {
console.error(`Task ${task} failed with ${e}`);
}
}
component(state).to(selector, position);
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 {
task(postInstallView);
} catch(e) {
console.error(`Task ${task} failed with ${e}`);
}
}
if ( activeTarget ) {
activateTab(null, {targetId:activeTarget});
}
return poppetView;
// closures
function doShot() {
setTimeout(() => {
queue.send({
type: "doShot",
synthetic: true
});
}, SHORT_DELAY);
}
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("All tabs closed.");
}
}
}
function writeCanvas(text) {
const canv = state.viewState.canvasEl;
const ctx = state.viewState.ctx;
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canv.width, canv.height);
ctx.fillStyle = 'silver';
ctx.font = 'italic 3vmax sans-serif';
ctx.textAlign = "center";
ctx.fillText(text, innerWidth/2, innerHeight/2-6*Math.max(innerWidth/100, innerHeight/100));
}
function clearViewport() {
if ( !! state.useViewFrame ) {
try {
state.viewState.viewFrameEl.contentDocument.body.innerHTML = ``;
} catch(e) {
console.warn(e);
}
} else {
const canv = state.viewState.canvasEl;
const ctx = state.viewState.ctx;
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canv.width, canv.height);
}
}
function sendKey(keyEvent) {
const {viewState} = state;
if ( document.activeElement !== viewState.keyinput && document.activeElement !== viewState.textarea ) {
let ev = keyEvent;
if ( keyEvent.key == "Tab" || keyEvent.key == "Space" ) {
event.preventDefault();
ev = cloneKeyEvent(event, true);
}
H(ev);
}
}
function installTopLevelKeyListeners() {
//self.addEventListener('keydown', sendKey);
//self.addEventListener('keyup', sendKey);
}
function installSafariLongTapListener(el) {
const FLAGS = {passive:true, capture:true};
const MIN_DURATION = 200;
const MAX_MOVEMENT = 20;
let lastStart;
el.addEventListener('touchstart', ts => lastStart = ts, FLAGS);
el.addEventListener('touchend', triggerContextMenuIfLongEnough, FLAGS);
el.addEventListener('touchcancel', triggerContextMenuIfLongEnough, FLAGS);
function triggerContextMenuIfLongEnough(tf) {
// space
const touch1 = lastStart.changedTouches[0];
const touch2 = tf.changedTouches[0];
const movement = Math.hypot(
touch2.pageX - touch1.pageX,
touch2.pageY - touch1.pageY
);
// time
const duration = tf.timeStamp - lastStart.timeStamp;
if ( duration > MIN_DURATION && movement < MAX_MOVEMENT ) {
lastStart.preventDefault();
tf.preventDefault();
const {pageX,pageY,clientX,clientY} = touch1;
el.dispatchEvent(new CustomEvent('contextmenu', {detail:{pageX,pageY,clientX,clientY}}));
}
}
}
function installZoomListener(el) {
const FLAGS = {passive:true};
let lastScale = 1.0;
let scaling = false;
let startDist = 0;
let lastDist = 0;
let touch;
el.addEventListener('touchstart', begin, FLAGS);
el.addEventListener('touchmove', move, FLAGS);
el.addEventListener('touchend', end, FLAGS);
el.addEventListener('touchcancel', end, FLAGS);
el.addEventListener('wheel', sendZoom, {passive:true, capture: true});
function sendZoom(event) {
if ( event.ctrlKey || event.deltaZ != 0 ) {
const delta = event.deltaZ || event.deltaY;
const direction = Math.sign(delta);
let multiplier;
if ( direction > 0 ) {
multiplier = 1/1.25;
} else {
multiplier = 1.25;
}
const scale = lastScale * multiplier;
lastScale = scale;
DEBUG.val > DEBUG.low && console.log('sending zoom ' + scale);
H({ synthetic:true,
type:'zoom',
scale,
event
});
}
}
function begin(event) {
if ( event.touches.length == 2 ) {
startDist = Math.hypot(
event.touches[0].pageX - event.touches[1].pageX,
event.touches[0].pageY - event.touches[1].pageY
);
if ( startDist > 8 ) {
scaling = true;
touch = event.touches[0];
} else {
scaling = false;
}
}
}
function move(event) {
if ( scaling ) {
const dist = Math.hypot(
event.touches[0].pageX - event.touches[1].pageX,
event.touches[0].pageY - event.touches[1].pageY
);
lastDist = dist;
}
}
function end(event) {
if ( scaling ) {
if ( lastDist < 8 ) {
// do nothing,
} else {
const scale = lastScale * Math.abs(lastDist / startDist);
lastScale = scale;
DEBUG.val > DEBUG.low && console.log('sending zoom ' + scale);
H({ synthetic:true,
type:'zoom',
scale,
event: touch
});
}
scaling = false;
startDist = 0;
lastDist = 0;
touch = false;
}
}
}
function H(event) {
// block if no tabs
if (state.tabs.length == 0) {
if ( SessionlessEvents.has(event.type) ) {
DEBUG.val > DEBUG.low && console.log(`passing through sessionless event of type ${event.type}`);
} else return;
}
const mouseEventOnPointerDevice = event.type.startsWith("mouse") && event.type !== "wheel" && !state.DoesNotSupportPointerEvents;
const tabKeyPressForBrowserUI = event.key == "Tab" && !event.vRetargeted;
const eventCanBeIgnored = mouseEventOnPointerDevice || tabKeyPressForBrowserUI;
if ( eventCanBeIgnored ) return;
const pointerEvent = event.type.startsWith("pointer");
const mouseWheel = event.type == "wheel";
const syntheticNonTypingEventWrapper = event.synthetic && event.type != "typing" && event.event;
if ( mouseWheel ) {
// do nothing
} else if ( pointerEvent ) {
state.DoesNotSupportPointerEvents = false;
} else if ( syntheticNonTypingEventWrapper ) {
event.event.preventDefault && event.event.preventDefault();
}
const simulated = event.event && event.event.simulated;
const hasTarget = event.event && event.event.target;
if ( event.type == "typing" && hasTarget
&& ! simulated && state.convertTypingEventsToSyncValueEvents ) {
event.type = 'typing-syncValue';
event.value = event.event.target.value;
event.contextId = state.contextIdOfFocusedInput;
event.data = "";
}
const isThrottled = ThrottledEvents.has(event.type);
const transformedEvent = transformEvent(event);
if ( mouseWheel ) {
transformedEvent.contextId = state.viewState.latestScrollContext;
}
if ( isThrottled ) {
// FIXME: this is not the right way to throttle
// because we are mixing events (pointer, touch mouse all together)
// and we get a delayed event, not good)
// for now we comment out the old code
/**
if ( !nextSend ) {
nextSend = setTimeout(() => {
nextSend = false;
queue.send(transformedEvent);
}, EVENT_THROTTLE_MS);
}
**/
// and send the event straight away because we will throttle
// at event capture
queue.send(transformedEvent);
} else {
if ( event.type == "keydown" && event.key == "Enter" ) {
// We do this to make sure we send composed input data when enter is pressed
// in an input field, if we do not do this, the current composition is not printed
if ( !! state.latestData && !! event.target.matches('input') && state.latestData.length > 1 ) {
queue.send(transformEvent({
synthetic: true,
type: 'typing',
data: state.latestData,
event: {enterKey:true,simulated:true}
}));
state.latestCommitData = state.latestData;
state.latestData = "";
}
} else if ( event.type == "keydown" && event.key == "Backspace" ) {
state.backspaceFiring = true;
} else if ( event.type == "keyup" && event.key == "Backspace" ) {
state.backspaceFiring = false;
} else if ( event.type == "pointerdown" || event.type == "mousedown" ) {
const {timeStamp,type} = event;
const {latestData} = state;
if ( !! state.viewState.shouldHaveFocus && !! latestData && latestData.length > 1 && latestData != state.latestCommitData) {
state.isComposing = false;
const data = latestData;
queue.send(transformEvent({
synthetic: true,
type: 'typing',
data: data,
event: {pointerDown:true,simulated:true}
}));
state.latestCommitData = data;
state.latestData = "";
}
} else if ( event.type == "pointerup" || event.type == "mouseup" ) {
if ( state.viewState.killNextMouseReleased ) {
state.viewState.killNextMouseReleased = false;
return;
}
}
queue.send(transformedEvent);
}
}
function sizeBrowserToBounds(el) {
let {width, height} = el.getBoundingClientRect();
width = Math.round(width);
height = Math.round(height);
const {innerWidth:iw, outerWidth:ow, innerHeight:ih, outerHeight:oh} = window;
const {width:w, availWidth:aw, height:h, availHeight:ah} = screen;
width = iw;
if ( DEBUG.val > DEBUG.high ) {
logit({iw,ow,ih,oh,width,height, w, aw, h, ah});
}
if ( ! el.dataset.sized && (el.width != width || el.height != height)) {
el.dataset.sized = true;
el.width = width;
el.height = height;
}
H({ synthetic: true,
type: "window-bounds-preImplementation",
width:width + (deviceIsMobile() ? 0 : 17), /* scrollbar */
height,
targetId: state.activeTarget
});
self.ViewportWidth = width;
self.ViewportHeight = height;
}
function asyncSizeBrowserToBounds(el) {
setTimeout(() => (sizeBrowserToBounds(el), indicateNoOpenTabs()), 0);
}
function emulateNavigator() {
const {platform,userAgent,language:acceptLanguage} = navigator;
H({ synthetic: true,
type: "user-agent",
userAgent, platform , acceptLanguage
});
}
function hideScrollbars() {
H({ synthetic: true,
type: "hide-scrollbars",
});
}
async function activateTab(click, tab) {
click && click.preventDefault();
if ( state.activeTarget == tab.targetId ) {
if ( state.viewState.omniBoxInput == state.viewState.lastActive ) {
state.viewState.omniBoxInput.focus();
}
return;
}
const {targetId} = tab;
queue.send({
command: {
name: "Target.activateTarget",
params: {targetId},
requiresShot: true,
}
});
canKeysInput();
state.lastTarget = state.activeTarget;
state.activeTarget = targetId;
// we assume that a listener will call clearviewport
// this returns false if there are no listeners
if ( ! runListeners('activateTab') ) {
clearViewport();
}
state.active = activeTab();
subviews.TabList(state);
subviews.OmniBox(state);
subviews.LoadingIndicator(state);
setTimeout(() => {
if ( state.active && state.active.url != BLANK ) {
canKeysInput();
} else {
writeCanvas("This is a BrowserGap SecureTab.");
state.viewState.omniBoxInput.focus();
}
}, SHORT_DELAY);
}
async function closeTab(click, tab, index) {
const {targetId} = tab;
closed.add(targetId);
resetLoadingIndicator({navigated:targetId},state);
setTimeout(() => closed.delete(targetId), VERY_LONG_DELAY);
const events = [
{
command: {
name: "Target.closeTarget",
params: {targetId},
}
}
];
await queue.send(events);
state.tabs.splice(index,1);
if ( state.activeTarget == targetId ) {
if ( state.tabs.length == 0 ) {
state.activeTarget = null;
} else {
if ( index >= state.tabs.length ) {
index = state.tabs.length-1;
}
const newActive = state.tabs[index];
activateTab(click, newActive);
}
} else {
updateTabs();
}
subviews.TabList(state);
subviews.LoadingIndicator(state);
}
async function rawUpdateTabs() {
let {tabs,activeTarget,requestId} = await (demoMode ? fetchDemoTabs() : fetchTabs({sessionToken}));
tabs = tabs.filter(({targetId}) => !closed.has(targetId));
if ( requestId <= latestRequestId ) {
return;
}
else {
latestRequestId = requestId;
}
state.tabs = tabs;
if ( demoMode ) {
state.activeTarget = activeTarget;
}
state.active = activeTab();
if( !state.activeTarget || !state.active) {
if ( state.tabs.length ) {
await activateTab(null, state.tabs[0]);
}
}
subviews.Controls(state);
subviews.TabList(state);
if ( state.tabs.length == 0 ) {
indicateNoOpenTabs();
}
}
async function createTab(click, url = BLANK) {
queue.send({
command: {
name: "Target.createTarget",
params: {
url,
enableBeginFrameControl: DEBUG.frameControl
},
}
});
}
function canKeysInput() {
if ( !! state.viewState.viewFrameEl ) return;
setTimeout(() => {
queue.send({
type: "canKeysInput",
synthetic: true
});
}, SHORT_DELAY);
}
function getFavicon() {
setTimeout(() => {
queue.send({
type: "getFavicon",
synthetic: true
});
}, IMMEDIATE);
}
function loadPlugin(plugin) {
plugins.set(plugin.name, plugin);
plugin.load(pluginView);
}
function addToQueue(...events) {
console.warn("Unimplemented");
}
function requestRender(pluginRenderedView) {
console.warn("Unimplemented");
}
function subscribeToQueue(name, listener) {
console.warn("Unimplemented");
}
}
export function cloneKeyEvent(event, vRetargeted) {
return {
type: event.type,
keyCode: event.keyCode,
key: event.key,
code: event.code,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
vRetargeted
};
}