UNPKG

remoteview

Version:

Remote isolated browser platform by Dosyago

803 lines (744 loc) 27.3 kB
{ const SIZE = false; const ARIA_FILL = 'a, button, [role="button"]'; const ATTR_WHITELIST = new Set([ 'zig', 'href', 'id', 'target', 'for', 'method', 'action', 'name', 'contenteditable', 'type', 'hidden', 'role', 'placeholder', 'value', 'title', 'selected', 'checked', 'aria-label', 'download', 'rowspan', 'colspan', 'cellpadding', 'cellspacing', 'required', 'max', 'min', 'maxlength', 'size', 'pattern', 'inputmethod', 'novalidate', 'enctype', 'autocomplete', 'tabindex', 'aria-hidden', 'open' ]); const EXEMPT = new Set([ 'select', 'option' ]); const MO_CONF = { attributes: true, subtree: true, childList: true }; const DEFAULT_VALUE_EL = `input[type="submit"], input[type="reset"], input[type="button"]`; const FORM_CONTROL = `input, option, select, form, fieldset, legend, label, textarea, [contenteditable], optgroup, output`; const TABLE_ELEMENT = `table, thead, tbody, tr, th, td, col, colgroup, tfoot`; const NOSPLICE = `[aria-label], [role], ` + TABLE_ELEMENT; const DATAIDMAP = new Map(); const RDATAIDMAP = new Map(); const GZIG = new Map(); const INVISIBLE = new Set(); const sleep = ms => new Promise(res => setTimeout(res, ms)); let installed = false; const MAX_GENERATIONS = 100; let generation = 0; let isFirstMutation = true; let lastKey = ''; let lastData = ''; let lastRoot; let lastTree; let Binding; let observer; let urlCalculator; self.lastRequest = {}; say({msg:`Installing injections for appminifier`}); if ( self == top ) { const sbInterval = setInterval(setupBinding, 20); setupBinding(); function setupBinding() { const binding = self.instructZombie; if ( typeof binding == "function" ) { delete self.instructZombie; Binding = { send: msg => binding(JSON.stringify(msg)), onmessage: msg => say(msg) }; Object.defineProperty(self, 'instructZombie', { get: () => Binding }); Binding.send({bindingAttached:true}); clearInterval(sbInterval); } } } function prepare(el) { if ( el.scrollIntoViewIfNeeded ) { el.scrollIntoViewIfNeeded(true); } else { el.scrollIntoView({block:'center'}); } } self.addEventListener('click', e => { if ( self == top ) { reportClick(e); } }); self.addEventListener('keypress', e => { if ( e.key == "Enter" ) { self.lastRequest.el = null; } }, {capture: true, passive: true}); self.clearFocusedInputAndInsertValue = async (encodedValue) => { // should have strong focus check here, not just "supposed to be some focused element" // but supposed to be THIS PARTICULAR focussed element try { if ( ! document.activeElement ) throw new TypeError(`Supposed to be focused element`); const input = document.activeElement; const stringVal = decodeURIComponent(escape(atob(encodedValue))); input.value = ""; //input.selectionStart = input.selectionEnd = input.value.length; await Binding.send({ name: "Input.insertText", params: { text: stringVal } }); await Promise.resolve(); } catch(e) { console.log(JSON.stringify({errorInCFIAIV:e+''})); } }; self.getBoundingBox = async ({dataId, generation:gen}) => { const { pageXOffset:scrollX, pageYOffset:scrollY, document: { documentElement: { clientWidth: innerWidth, clientHeight: innerHeight } } } = self; self.lastRequest = {dataId, gen}; const Gen = generation + ""; const map = DATAIDMAP.get(gen); if ( map ) { const el = map.get(dataId); try { if ( el ) { self.lastRequest.el = el; prepare(el); await sleep(50); const {left:x,top:y,width,height} = el.getBoundingClientRect(); // upgrade request to latest generation //console.log(JSON.stringify({gen,Gen,width})); if ( width == 0 && gen !== Gen ) { const newBox = await self.getBoundingBox({dataId,generation:Gen}); //console.log(JSON.stringify({newBox})); return newBox; } const box = {boundingBox:{scrollX,scrollY,x,y,width,height,innerWidth,innerHeight},currentGeneration:Gen,requestGeneration:gen}; //console.log(JSON.stringify(box)); return box; } } catch(e) { console.log(JSON.stringify({err:e+''})); return {boundingBox:{error:true}}; } } else console.log({msg:`Generation ${gen} expired`}); }; self.getDOMTree = (force = false) => { console.log(JSON.stringify({msg:`get dom tree requested`,force})); update(force); }; install(); async function install() { if ( top !== self ) return; if ( ! installed ) { self.addEventListener('load', install); document.addEventListener('domcontentloaded', install); installed = true; } if ( ! observer ) { await createObserver(); } getDOMTree(); } async function createObserver() { if ( document.documentElement ) { observer = new MutationObserver(updateOnMutation); observer.observe(document.documentElement, MO_CONF); } } async function updateOnMutation(a) { if ( isFirstMutation ) { isFirstMutation = false; update(); } else { updateDiff(a); } } async function update(force) { if ( self !== top ) return; if ( document.body ) { nextGeneration(); const {data,key,tree, root } = await filterMarkup(document.body); if ( data == undefined ) return; const hasChanged = data.length !== lastData.length || key !== lastKey; if ( force || hasChanged ) { lastRoot = root; lastTree = tree; lastKey = key; lastData = data; console.log(JSON.stringify({msg:`remote updates tree`})); s({length: data.length, open: data, targetId}); } } } async function updateDiff(mutations, force = false) { if ( self !== top ) return; if ( document.body ) { nextGeneration(); const {data,key,tree, root} = await filterMarkup(document.body); if ( data == undefined ) return; const hasChanged = data.length !== lastData.length || key !== lastKey; if ( force || hasChanged ) { lastKey = key; lastData = data; const diffs = await getDiffs({lastTree, tree, lastRoot}); if ( diffs.length ) { lastRoot = root; lastTree = tree; const length = JSON.stringify({diffs}).length + '.' + generation + '.' + Math.random(); console.log(JSON.stringify({treeDiff:{length, diffs, targetId}})); } } } } async function getDiffs({lastTree, tree, lastRoot}) { const deleted = new Set(); const diffs = []; let walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); let nextNode; do { const node = walker.currentNode; const lastTreeHas = lastTree.has(node); const thisTreeHas = tree.has(node); const inserted = ! lastTreeHas && thisTreeHas; const removed = lastTreeHas && ! thisTreeHas; // const moved ??? if ( inserted ) { const addedNode = tree.get(node); const hasParent = !! addedNode.parentElement; if ( ! hasParent ) { say({addedNode:{hasParent, outerHTML:node.cloneNode().outerHTML}}); } else { const originalParent = tree.get(addedNode.parentElement); const lastTreeParent = lastTree.get(originalParent); if ( ! lastTreeParent || ! lastTreeParent.getAttribute ) { // we need to step up the tree until we find a last tree parent // with a zig and add all the missing to the outerHTML // repoint the added node to match that, in other words // but we can get it from 'this tree parent' zig // in other words step up addedNode parent } else { let parentZig = lastTreeParent.getAttribute('zig'); diffs.push({ insert: { parentZig: lastTreeParent.getAttribute('zig'), outerHTML: addedNode.outerHTML } }); } } nextNode = walker.nextSibling(); if ( ! nextNode ) { // walk the subtree until we're out of it while(node.contains(walker.currentNode)) { nextNode = walker.nextNode(); if ( ! nextNode ) break; } } } else if ( removed ) { const removedNode = lastTree.get(node); const hasParent = !! removedNode.parentElement; const hasGetAttribute = !! removedNode.getAttribute; if ( ! hasParent || ! hasGetAttribute ) { const n = node.cloneNode(); say({removedNode1:{hasParent,hasGetAttribute, val:n.outerHTML||n.nodeValue}}); } else { const zigToDelete = GZIG.get(removedNode) || removedNode.getAttribute('zig'); deleted.add(zigToDelete); diffs.push({ remove: { parentZig: removedNode.parentElement.getAttribute('zig'), zig: zigToDelete } }); } nextNode = walker.nextSibling(); if ( ! nextNode ) { // walk the subtree until we're out of it while(node.contains(walker.currentNode)) { nextNode = walker.nextNode(); if ( ! nextNode ) break; } } } else { nextNode = walker.nextNode(); } } while (nextNode); if ( lastRoot ) { walker = document.createTreeWalker(lastRoot, NodeFilter.SHOW_ELEMENT); do { const node = walker.currentNode; const oNode = lastTree.get(node); const removed = !document.body.contains(oNode); const zig = GZIG.get(oNode) || node.getAttribute('zig'); if ( deleted.has(zig) ) { nextNode = walker.nextSibling(); if ( ! nextNode ) { // walk the subtree until we're out of it while(node.contains(walker.currentNode)) { nextNode = walker.nextNode(); if ( ! nextNode ) break; } } } else if ( removed ) { const removedNode = node; const hasParent = !! removedNode.parentElement; const hasGetAttribute = !! removedNode.getAttribute; if ( ! hasParent || ! hasGetAttribute ) { const n = oNode.cloneNode(); say({removedNode2:{hasParent,hasGetAttribute, val:n.outerHTML||n.nodeValue}}); } else { deleted.add(zig); //const zigToDelete = removedNode.getAttribute('zig'); const oParent = lastTree.get(removedNode.parentElement); const pZig = GZIG.get(oParent); diffs.push({ remove: { parentZig: pZig, // removedNode.parentElement.getAttribute('zig'), zig: zig // zigToDelete }, source: 'lastRoot-bodymissing' }); } nextNode = walker.nextSibling(); if ( ! nextNode ) { // walk the subtree until we're out of it while(node.contains(walker.currentNode)) { nextNode = walker.nextNode(); if ( ! nextNode ) break; } } } else { nextNode = walker.nextNode(); } } while (nextNode); } return diffs; } function nextGeneration() { if ( generation > MAX_GENERATIONS ) { // roll it over, this shouldn't cause a problem with the previous // generations effectively acting as a list of previous cache snapshots generation = 0; } generation++; } function FILLMAP(root) { INVISIBLE.clear(); const map = new Map(); const rmap = new Map(); let id = 0; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); do { const node = walker.currentNode; id++; map.set(id+'', node); rmap.set(node, id+''); const zig = `${id} ${generation}`; if ( EXEMPT.has(node.localName) ) { //console.log(`${node.localName} exmept from visibility check.`); } else if ( !visible(node) ) { INVISIBLE.add(id); } } while(walker.nextNode()) DATAIDMAP.set(generation+'', map); RDATAIDMAP.set(generation+'', rmap); } async function filterMarkup(originalRoot, nextId) { const tree = new Map(); originalRoot.normalize(); if ( originalRoot.nodeType == Node.TEXT_NODE ) return { data: originalRoot.nodeValue, key: originalRoot.nodeValue }; const U = location.protocol + '//' + location.hostname; FILLMAP(originalRoot); let key = ''; if ( nextId != undefined ) { nextId = nextId || 0; if ( ! Number.isInteger(nextId) ) { throw new TypeError(`Next id ${nextId} is not integer`); } } else { nextId = 0; } const toInsert = []; const toRemove = []; const toSplice = []; const root = originalRoot.cloneNode(true); buildTree({root, originalRoot, tree}); let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); do { const node = walker.currentNode; nextId++; switch(node.localName) { case "head": case "script": case "noscript": case "link": case "style": case "br": case "meta": if ( ! INVISIBLE.has(nextId) ) { toRemove.push(node); } break; case "img": case "video": case "iframe": case "picture": case "audio": case "canvas": case "svg": { try { let f; const id = nextId; const g = generation; const role = node.getAttribute('role') || node.role; let name = node.localName == 'img' ? 'image' : node.localName; //name = (name.charAt(0).toUpperCase() + name.slice(1)); if ( node.hasAttribute('alt')) { let text = role || name; const alt = node.getAttribute('alt'); if ( !! alt && ! new RegExp(text, "i").test(alt) ) { text = `(${alt} ${text})`; } else { text = `(${alt||text})`; } f = () => node.insertAdjacentHTML('beforeBegin', `<span ${role?`role=${role}`:''} zig="${i} ${g}">${text}</span>`); } else if ( node.hasAttribute('aria-label') ) { let text = role || name; const alt = node.getAttribute('aria-label'); if ( !! alt && ! new RegExp(text, "i").test(alt) ) { text = `(${alt} ${text})`; } else { text = `(${alt||text})`; } f = () => node.insertAdjacentHTML('beforeBegin', `<span ${role?`role=${role}`:''} zig="${id} ${g}">${text}</span>`); } else if ( node.hasAttribute('title') ) { let text = role || name; const alt = node.getAttribute('title'); if ( !! alt && ! new RegExp(text, "i").test(alt) ) { text = `(${alt} ${text})`; } else { text = `(${alt||text})`; } f = () => node.insertAdjacentHTML('beforeBegin', `<span ${role?`role=${role}`:''} zig="${id} ${g}">${text}</span>`); } else if ( node.querySelector('title') ) { let text = role || name; const alt = node.querySelector('title').textContent; if ( !! alt && ! new RegExp(text, "i").test(alt) ) { text = `(${alt} ${text})`; } else { text = `(${alt||text})`; } f = () => node.insertAdjacentHTML('beforeBegin', `<span ${role?`role=${role}`:''} zig="${id} ${g}">${text}</span>`); } if ( f ) toInsert.push(f); } catch(e) { console.log(JSON.stringify({err:e+''})) } if ( ! INVISIBLE.has(nextId) ) { INVISIBLE.add(nextId); toRemove.push(node); } break; } case "li": { try { if (! node.querySelector(FORM_CONTROL) && ! node.querySelector('a') && node.innerText.trim().length == 0) { INVISIBLE.add(nextId); toRemove.push(node); } } catch(e) { console.info(e); } } default: { if ( node.attributes ) { Array.from(node.attributes).forEach(x => !ATTR_WHITELIST.has(x.name) && node.removeAttribute(x.name)); // we explicitly set them to get FULL href if ( node.hasAttribute('href') || node.href ) { node.setAttribute('href', node.href); } if ( node.hasAttribute('action') || node.action ) { node.setAttribute('action', getOriginURL(node.getAttribute('action'))); if ( node.action.startsWith('http:') ) { // this gets rid of a mixed content warning node.removeAttribute('action'); } } if ( node.hasAttribute('value') || node.value ) { node.setAttribute('value', node.value); } if ( node.localName == 'input' ) { node.autocomplete = "off"; } if ( node.selected ) { node.setAttribute('selected', node.selected); } if ( node.checked ) { node.setAttribute('checked', node.checked); } } break; } } const oNode = tree.get(node); const existingZig = GZIG.get(oNode); if ( existingZig ) { node.setAttribute('zig', existingZig); } else { const zig = `${nextId} ${generation}`; GZIG.set(oNode, zig); GZIG.set(zig, oNode); node.setAttribute('zig', zig); } if ( INVISIBLE.has(nextId) || node.hasAttribute('hidden') || node.matches('[aria-hidden="true"]')) { toRemove.push(node); } else { if ( node.hasAttribute('placeholder') && node.placeholder.length ) continue; else if ( node.matches('textarea, [contenteditable], input, select, br, hr') ) continue; else if ( ! node.innerText || ! node.innerText.trim().length ) { try { let f; const g = generation; const role = node.getAttribute('role') || node.role; if ( node.hasAttribute('alt')) { f = () => node.insertAdjacentHTML('afterBegin', `<span ${role?`role=${role}`:''} zig="${nextId++} ${g}">${node.getAttribute('alt')}</span>`); } else if ( node.hasAttribute('aria-label') ) { f = () => node.insertAdjacentHTML('afterBegin', `<span ${role?`role=${role}`:''} zig="${nextId++} ${g}">${node.getAttribute('aria-label')}</span>`); } if ( f ) toInsert.push(f); } catch(e) { console.log(JSON.stringify({err:e+''})) } } } } while(walker.nextNode()); for ( const f of toInsert ) { try { f(); } catch(e) {} } for ( const node of toRemove ) { try { node.remove(); const oNode = tree.get(node); tree.delete(node); tree.delete(oNode); } catch(e) {} } toRemove.length = 0; walker = document.createTreeWalker(root, NodeFilter.SHOW_ALL); do { const node = walker.currentNode; if ( node.nodeType == Node.ELEMENT_NODE ) { if ( node.localName == 'a' ) { const a = node; if ( a.hasAttribute('download') && a.href ) { a.target = "_blank"; } else if ( a.href && a.href.match(/^(mailto|tel)/) ) { a.target = "_blank"; } } const zeroText = !node.innerText || node.innerText.trim().length == 0; if ( node.matches(ARIA_FILL) && zeroText ) { const titleEl = node.hasAttribute('title') ? node : node.querySelector('[title]'); const ariaLabelEl = node.hasAttribute('aria-label') ? node : node.querySelector('[aria-label]'); let text = ''; if ( titleEl ) { text = titleEl.getAttribute('title'); } else if ( ariaLabelEl ) { text = ariaLabelEl.getAttribute('aria-label'); } else if ( node.localName == 'a' && node.href ) { let url = node.href + ''; if ( url.startsWith(U) ) { text = url.slice(U.length); if ( text == '/' ) text = 'home'; } else if (url.startsWith('mailto') ) { text = 'Email'; } else if (url.startsWith('tel') ) { text = 'Telephone'; } else { try { url = new URL(url); text = url.hostname; } catch(e) { // console.warn("Bad url", e, url); text = url; } } text = text.slice(0,22); } if ( text ) { node.insertAdjacentText('afterBegin', text); } } if ( (node.localName == 'input' || node.localName == 'textarea') && ! node.placeholder ) { let text = node.getAttribute('aria-label') || node.getAttribute('title') || ''; node.placeholder = text; } try { const nodeHasNoTextContent = !node.innerText || node.innerText.trim().length == 0; const nodeIsNotFormControl = ! isFormControl(node); const nodeIsNotTableElement = ! node.matches(TABLE_ELEMENT); const nodeHasNoRole = ! ( node.matches('[role]') && node.matches('[aria-label], [data-tooltip]')); const nodeHasNoValueContent = (node.value + '').trim().length == 0; const nodeAddsNothing = nodeHasNoTextContent && nodeIsNotFormControl && nodeIsNotTableElement && nodeHasNoRole && nodeHasNoValueContent; if ( nodeAddsNothing ) { toRemove.push(node); } else if ( isUselessContainer(node) ) { toSplice.unshift(node); } else { key += node.localName; } } catch(e) { console.info(e) } } else if ( node.nodeType == Node.COMMENT_NODE ) { toRemove.push(node); } else if ( node.nodeType == Node.TEXT_NODE ) { key += node.nodeValue; if ( node.nodeValue.trim().length == 0 ) { toRemove.push(node); } } }while(walker.nextNode()); for ( const node of toRemove ) { try { node.remove(); const oNode = tree.get(node); tree.delete(node); tree.delete(oNode); } catch(e) {} } for ( const node of toSplice ) { try { spliceContainer(node, tree); } catch(e) {} } if ( SIZE ) { const originalSize = originalRoot.ownerDocument.documentElement.outerHTML.length; const newSize = root.innerHTML.length; console.log(`Reduction: ${(newSize/originalSize*100).toFixed(3)}% of original`); } key = key.replace(/\s+/g, ''); const data = root.outerHTML || root.nodeValue; if ( data == undefined || data == null ) { say({emptyData:{root:root.localName,roots:root+''}}); } return { key, data, nextId, tree, root }; } function buildTree({root, originalRoot, tree}) { const oWalker = document.createTreeWalker(originalRoot, NodeFilter.SHOW_ALL); const cloneWalker = document.createTreeWalker(root, NodeFilter.SHOW_ALL); let hasNextPair; do { tree.set(oWalker.currentNode, cloneWalker.currentNode); tree.set(cloneWalker.currentNode, oWalker.currentNode); const oNext = !! oWalker.nextNode(); const cloneNext = !! cloneWalker.nextNode(); hasNextPair = oNext && cloneNext; } while(hasNextPair); } function hasDefaultValue(node) { return node.matches && node.matches(DEFAULT_VALUE_EL); } function isFormControl(node) { return node.matches && node.matches(FORM_CONTROL); } function s(o) { console.log(JSON.stringify({treeUpdate:o})); } function say(o) { console.log(JSON.stringify(o)); } function visible(el) { if (el.nodeType == Node.TEXT_NODE ) return true; return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); } function reportClick(e) { const {clientX,clientY,type, target} = e; const node = target.cloneNode().outerHTML.slice(0,50); const lastRequestEl = self.lastRequest.el; const {dataId, generation} = lastRequest; const intendedLink = !! lastRequestEl && lastRequestEl.closest('a'); let hitsTarget, intendedHref, clickModifiers, lastRequestHTML; if ( intendedLink ) { hitsTarget = [...e.composedPath()].includes(intendedLink); hitsTarget = hitsTarget && withinViewport(intendedLink); if ( ! hitsTarget ) { intendedHref = intendedLink.href; clickModifiers = encodeModifiers(e); e.preventDefault(); e.stopPropagation(); } } else if ( ! lastRequestEl ) { hitsTarget = true; } else { hitsTarget = [...e.composedPath()].includes(lastRequestEl); lastRequestHTML = lastRequestEl.cloneNode().outerHTML.slice(0,50); } if ( ! hitsTarget ) { //e.preventDefault(); //e.stopPropagation(); } console.log(JSON.stringify({click:{clientX,clientY,type,node,hitsTarget, intendedHref, clickModifiers, dataId, generation, lastRequestHTML}})); } function encodeModifiers(originalEvent) { let modifiers = 0; if (originalEvent.altKey ) { modifiers += 1; } if (originalEvent.ctrlKey || originalEvent.metaKey) { modifiers += 2; } if (originalEvent.metaKey ) { modifiers += 4; } if (originalEvent.shiftKey ) { modifiers += 8; } return modifiers; } function withinViewport(el) { const W = innerWidth; const H = innerHeight; const {left:x,top:y,width,height} = el.getBoundingClientRect(); const inX = x >= 0 && x < W || x + width > 0; const inY = y >=0 && y < H || y + height > 0; return inX && inY; } function isUselessContainer(el) { return false; return el.childNodes.length == 1 && el.parentNode.childNodes.length == 1 && ! el.matches(NOSPLICE); } function spliceContainer(el, tree) { const singleChild = el.childNodes[0]; if ( singleChild ) { el.parentNode.insertBefore(singleChild, el); el.remove(); const oNode = tree.get(el); tree.delete(el); tree.delete(oNode); } } function debounce(func, wait) { let timeout; return function (...args) { const later = () => { timeout = null; func.apply(this, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); } } function getOriginURL(possiblyPartialURL) { if ( ! urlCalculator ) { urlCalculator = document.createElement('a'); } urlCalculator.setAttribute('href', possiblyPartialURL); return urlCalculator.href; } }