diff options
Diffstat (limited to 'src/js/logger-ui.js')
-rw-r--r-- | src/js/logger-ui.js | 3044 |
1 files changed, 3044 insertions, 0 deletions
diff --git a/src/js/logger-ui.js b/src/js/logger-ui.js new file mode 100644 index 0000000..177632e --- /dev/null +++ b/src/js/logger-ui.js @@ -0,0 +1,3044 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2015-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +import { hostnameFromURI } from './uri-utils.js'; +import { i18n, i18n$ } from './i18n.js'; +import { dom, qs$, qsa$ } from './dom.js'; + +/******************************************************************************/ + +// TODO: fix the inconsistencies re. realm vs. filter source which have +// accumulated over time. + +const messaging = vAPI.messaging; +const logger = self.logger = { ownerId: Date.now() }; +const logDate = new Date(); +const logDateTimezoneOffset = logDate.getTimezoneOffset() * 60000; +const loggerEntries = []; + +const COLUMN_TIMESTAMP = 0; +const COLUMN_FILTER = 1; +const COLUMN_MESSAGE = 1; +const COLUMN_RESULT = 2; +const COLUMN_INITIATOR = 3; +const COLUMN_PARTYNESS = 4; +const COLUMN_METHOD = 5; +const COLUMN_TYPE = 6; +const COLUMN_URL = 7; + +let filteredLoggerEntries = []; +let filteredLoggerEntryVoidedCount = 0; + +let popupLoggerBox; +let popupLoggerTooltips; +let activeTabId = 0; +let selectedTabId = 0; +let netInspectorPaused = false; +let cnameOfEnabled = false; + +/******************************************************************************/ + +// Various helpers. + +const tabIdFromPageSelector = logger.tabIdFromPageSelector = function() { + const value = qs$('#pageSelector').value; + return value !== '_' ? (parseInt(value, 10) || 0) : activeTabId; +}; + +const tabIdFromAttribute = function(elem) { + const value = dom.attr(elem, 'data-tabid') || ''; + const tabId = parseInt(value, 10); + return isNaN(tabId) ? 0 : tabId; +}; + + +/******************************************************************************/ +/******************************************************************************/ + +const onStartMovingWidget = (( ) => { + let widget = null; + let ondone = null; + let mx0 = 0, my0 = 0; + let mx1 = 0, my1 = 0; + let l0 = 0, t0 = 0; + let pw = 0, ph = 0; + let cw = 0, ch = 0; + let timer; + + const xyFromEvent = ev => { + if ( ev.type.startsWith('mouse') ) { + return { x: ev.pageX, y: ev.pageY }; + } + const touch = ev.touches[0]; + return { x: touch.pageX, y: touch.pageY }; + }; + + const eatEvent = function(ev) { + ev.stopPropagation(); + if ( ev.touches !== undefined ) { return; } + ev.preventDefault(); + }; + + const move = ( ) => { + timer = undefined; + const l1 = Math.min(Math.max(l0 + mx1 - mx0, 0), Math.max(pw - cw, 0)); + if ( (l1+cw/2) < (pw/2) ) { + widget.style.left = `${l1/pw*100}%`; + widget.style.right = ''; + } else { + widget.style.right = `${(pw-l1-cw)/pw*100}%`; + widget.style.left = ''; + } + const t1 = Math.min(Math.max(t0 + my1 - my0, 0), Math.max(ph - ch, 0)); + widget.style.top = `${t1/ph*100}%`; + widget.style.bottom = ''; + }; + + const moveAsync = ev => { + if ( timer !== undefined ) { return; } + const coord = xyFromEvent(ev); + mx1 = coord.x; my1 = coord.y; + timer = self.requestAnimationFrame(move); + eatEvent(ev); + }; + + const stop = ev => { + if ( timer !== undefined ) { + self.cancelAnimationFrame(timer); + timer = undefined; + } + if ( widget === null ) { return; } + if ( widget.classList.contains('moving') === false ) { return; } + widget.classList.remove('moving'); + self.removeEventListener('mousemove', moveAsync, { capture: true }); + self.removeEventListener('touchmove', moveAsync, { capture: true }); + eatEvent(ev); + widget = null; + if ( ondone !== null ) { + ondone(); + ondone = null; + } + }; + + return function(ev, target, callback) { + if ( dom.cl.has(target, 'moving') ) { return; } + widget = target; + ondone = callback || null; + const coord = xyFromEvent(ev); + mx0 = coord.x; my0 = coord.y; + const widgetParent = widget.parentElement; + const crect = widget.getBoundingClientRect(); + const prect = widgetParent.getBoundingClientRect(); + pw = prect.width; ph = prect.height; + cw = crect.width; ch = crect.height; + l0 = crect.x - prect.x; t0 = crect.y - prect.y; + widget.classList.add('moving'); + self.addEventListener('mousemove', moveAsync, { capture: true }); + self.addEventListener('mouseup', stop, { capture: true, once: true }); + self.addEventListener('touchmove', moveAsync, { capture: true }); + self.addEventListener('touchend', stop, { capture: true, once: true }); + eatEvent(ev); + }; +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// Current design allows for only one modal DOM-based dialog at any given time. +// +const modalDialog = (( ) => { + const overlay = qs$('#modalOverlay'); + const container = qs$('#modalOverlayContainer'); + const closeButton = qs$(overlay, ':scope .closeButton'); + let onDestroyed; + + const removeChildren = logger.removeAllChildren = function(node) { + while ( node.firstChild ) { + node.removeChild(node.firstChild); + } + }; + + const create = function(selector, destroyListener) { + const template = qs$(selector); + const dialog = dom.clone(template); + removeChildren(container); + container.appendChild(dialog); + onDestroyed = destroyListener; + return dialog; + }; + + const show = function() { + dom.cl.add(overlay, 'on'); + }; + + const destroy = function() { + dom.cl.remove(overlay, 'on'); + const dialog = container.firstElementChild; + removeChildren(container); + if ( typeof onDestroyed === 'function' ) { + onDestroyed(dialog); + } + onDestroyed = undefined; + }; + + const onClose = function(ev) { + if ( ev.target === overlay || ev.target === closeButton ) { + destroy(); + } + }; + dom.on(overlay, 'click', onClose); + dom.on(closeButton, 'click', onClose); + + return { create, show, destroy }; +})(); + +self.logger.modalDialog = modalDialog; + + +/******************************************************************************/ +/******************************************************************************/ + +const prettyRequestTypes = { + 'main_frame': 'doc', + 'stylesheet': 'css', + 'sub_frame': 'frame', + 'xmlhttprequest': 'xhr' +}; + +const uglyRequestTypes = { + 'doc': 'main_frame', + 'css': 'stylesheet', + 'frame': 'sub_frame', + 'xhr': 'xmlhttprequest' +}; + +let allTabIds = new Map(); +let allTabIdsToken; + +/******************************************************************************/ +/******************************************************************************/ + +const regexFromURLFilteringResult = function(result) { + const beg = result.indexOf(' '); + const end = result.indexOf(' ', beg + 1); + const url = result.slice(beg + 1, end); + if ( url === '*' ) { + return new RegExp('^.*$', 'gi'); + } + return new RegExp('^' + url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); +}; + +/******************************************************************************/ + +// Emphasize hostname in URL, as this is what matters in uMatrix's rules. + +const nodeFromURL = function(parent, url, re, type) { + const fragment = document.createDocumentFragment(); + if ( re === undefined ) { + fragment.textContent = url; + } else { + if ( typeof re === 'string' ) { + re = new RegExp(re.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); + } + const matches = re.exec(url); + if ( matches === null || matches[0].length === 0 ) { + fragment.textContent = url; + } else { + if ( matches.index !== 0 ) { + fragment.appendChild( + document.createTextNode(url.slice(0, matches.index)) + ); + } + const b = document.createElement('b'); + b.textContent = url.slice(matches.index, re.lastIndex); + fragment.appendChild(b); + if ( re.lastIndex !== url.length ) { + fragment.appendChild( + document.createTextNode(url.slice(re.lastIndex)) + ); + } + } + } + if ( /^https?:\/\//.test(url) ) { + const a = document.createElement('a'); + let href = url; + switch ( type ) { + case 'css': + case 'doc': + case 'frame': + case 'object': + case 'other': + case 'script': + case 'xhr': + href = `code-viewer.html?url=${encodeURIComponent(href)}`; + break; + default: + break; + } + dom.attr(a, 'href', href); + dom.attr(a, 'target', '_blank'); + fragment.appendChild(a); + } + parent.appendChild(fragment); +}; + +/******************************************************************************/ + +const padTo2 = function(v) { + return v < 10 ? '0' + v : v; +}; + +const normalizeToStr = function(s) { + return typeof s === 'string' && s !== '' ? s : ''; +}; + +/******************************************************************************/ + +const LogEntry = function(details) { + if ( details instanceof Object === false ) { return; } + const receiver = LogEntry.prototype; + for ( const prop in receiver ) { + if ( + details.hasOwnProperty(prop) && + details[prop] !== receiver[prop] + ) { + this[prop] = details[prop]; + } + } + if ( details.aliasURL !== undefined ) { + this.aliased = true; + } + if ( this.tabDomain === '' ) { + this.tabDomain = this.tabHostname || ''; + } + if ( this.docDomain === '' ) { + this.docDomain = this.docHostname || ''; + } + if ( this.domain === '' ) { + this.domain = details.hostname || ''; + } +}; +LogEntry.prototype = { + aliased: false, + dead: false, + docDomain: '', + docHostname: '', + domain: '', + filter: undefined, + id: '', + method: '', + realm: '', + tabDomain: '', + tabHostname: '', + tabId: undefined, + textContent: '', + tstamp: 0, + type: '', + voided: false, +}; + +/******************************************************************************/ + +const createLogSeparator = function(details, text) { + const separator = new LogEntry(); + separator.tstamp = details.tstamp; + separator.realm = 'message'; + separator.tabId = details.tabId; + separator.type = 'tabLoad'; + separator.textContent = ''; + + const textContent = []; + logDate.setTime(separator.tstamp - logDateTimezoneOffset); + textContent.push( + // cell 0 + padTo2(logDate.getUTCHours()) + ':' + + padTo2(logDate.getUTCMinutes()) + ':' + + padTo2(logDate.getSeconds()), + // cell 1 + text + ); + separator.textContent = textContent.join('\t'); + + if ( details.voided ) { + separator.voided = true; + } + + return separator; +}; + +/******************************************************************************/ + +// TODO: once refactoring is mature, consider using push() instead of +// unshift(). This will require inverting the access logic +// throughout the code. +// +const processLoggerEntries = function(response) { + const entries = response.entries; + if ( entries.length === 0 ) { return; } + + const autoDeleteVoidedRows = qs$('#pageSelector').value === '_'; + const previousCount = filteredLoggerEntries.length; + + for ( const entry of entries ) { + const unboxed = JSON.parse(entry); + if ( unboxed.filter instanceof Object ){ + loggerStats.processFilter(unboxed.filter); + } + if ( netInspectorPaused ) { continue; } + const parsed = parseLogEntry(unboxed); + if ( + parsed.tabId !== undefined && + allTabIds.has(parsed.tabId) === false + ) { + if ( autoDeleteVoidedRows ) { continue; } + parsed.voided = true; + } + if ( + parsed.type === 'main_frame' && + parsed.aliased === false && ( + parsed.filter === undefined || + parsed.filter.modifier !== true + ) + ) { + const separator = createLogSeparator(parsed, unboxed.url); + loggerEntries.unshift(separator); + if ( rowFilterer.filterOne(separator) ) { + filteredLoggerEntries.unshift(separator); + if ( separator.voided ) { + filteredLoggerEntryVoidedCount += 1; + } + } + } + if ( cnameOfEnabled === false && parsed.aliased ) { + qs$('#filterExprCnameOf').style.display = ''; + cnameOfEnabled = true; + } + loggerEntries.unshift(parsed); + if ( rowFilterer.filterOne(parsed) ) { + filteredLoggerEntries.unshift(parsed); + if ( parsed.voided ) { + filteredLoggerEntryVoidedCount += 1; + } + } + } + + const addedCount = filteredLoggerEntries.length - previousCount; + if ( addedCount !== 0 ) { + viewPort.updateContent(addedCount); + rowJanitor.inserted(addedCount); + } +}; + +/******************************************************************************/ + +const parseLogEntry = function(details) { + // Patch realm until changed all over codebase to make this unnecessary + if ( details.realm === 'cosmetic' ) { + details.realm = 'extended'; + } + + const entry = new LogEntry(details); + + // Assemble the text content, i.e. the pre-built string which will be + // used to match logger output filtering expressions. + const textContent = []; + + // Cell 0 + logDate.setTime(details.tstamp - logDateTimezoneOffset); + textContent.push( + padTo2(logDate.getUTCHours()) + ':' + + padTo2(logDate.getUTCMinutes()) + ':' + + padTo2(logDate.getSeconds()) + ); + + // Cell 1 + if ( details.realm === 'message' ) { + textContent.push(details.text); + entry.textContent = textContent.join('\t'); + return entry; + } + + // Cell 1, 2 + if ( entry.filter !== undefined ) { + textContent.push(entry.filter.raw); + if ( entry.filter.result === 1 ) { + textContent.push('--'); + } else if ( entry.filter.result === 2 ) { + textContent.push('++'); + } else if ( entry.filter.result === 3 ) { + textContent.push('**'); + } else if ( entry.filter.source === 'redirect' ) { + textContent.push('<<'); + } else { + textContent.push(''); + } + } else { + textContent.push('', ''); + } + + // Cell 3 + textContent.push(normalizeToStr(entry.docHostname)); + + // Cell 4: partyness + if ( + entry.realm === 'network' && + typeof entry.domain === 'string' && + entry.domain !== '' + ) { + let partyness = ''; + if ( entry.tabDomain !== undefined ) { + if ( entry.tabId < 0 ) { + partyness += '0,'; + } + partyness += entry.domain === entry.tabDomain ? '1' : '3'; + } else { + partyness += '?'; + } + if ( entry.docDomain !== entry.tabDomain ) { + partyness += ','; + if ( entry.docDomain !== undefined ) { + partyness += entry.domain === entry.docDomain ? '1' : '3'; + } else { + partyness += '?'; + } + } + textContent.push(partyness); + } else { + textContent.push(''); + } + + // Cell 5: method + textContent.push(entry.method || ''); + + // Cell 6 + textContent.push( + normalizeToStr(prettyRequestTypes[entry.type] || entry.type) + ); + + // Cell 7 + textContent.push(normalizeToStr(details.url)); + + // Hidden cells -- useful for row-filtering purpose + + // Cell 8 + if ( entry.aliased ) { + textContent.push(`aliasURL=${details.aliasURL}`); + } + + entry.textContent = textContent.join('\t'); + return entry; +}; + +/******************************************************************************/ + +const viewPort = (( ) => { + const vwRenderer = qs$('#vwRenderer'); + const vwScroller = qs$('#vwScroller'); + const vwVirtualContent = qs$('#vwVirtualContent'); + const vwContent = qs$('#vwContent'); + const vwLineSizer = qs$('#vwLineSizer'); + const vwLogEntryTemplate = qs$('#logEntryTemplate > div'); + const vwEntries = []; + + const detailableRealms = new Set([ 'network', 'extended' ]); + + let vwHeight = 0; + let lineHeight = 0; + let wholeHeight = 0; + let lastTopPix = 0; + let lastTopRow = 0; + + const ViewEntry = function() { + this.div = document.createElement('div'); + this.div.className = 'logEntry'; + vwContent.appendChild(this.div); + this.logEntry = undefined; + }; + ViewEntry.prototype = { + dispose: function() { + vwContent.removeChild(this.div); + }, + }; + + const rowFromScrollTopPix = function(px) { + return lineHeight !== 0 ? Math.floor(px / lineHeight) : 0; + }; + + // This is called when the browser fired scroll events + const onScrollChanged = function() { + const newScrollTopPix = vwScroller.scrollTop; + const delta = newScrollTopPix - lastTopPix; + if ( delta === 0 ) { return; } + lastTopPix = newScrollTopPix; + if ( filteredLoggerEntries.length <= 2 ) { return; } + // No entries were rolled = all entries keep their current details + if ( rollLines(rowFromScrollTopPix(newScrollTopPix)) ) { + fillLines(); + } + positionLines(); + vwContent.style.top = `${lastTopPix}px`; + }; + + // Coalesce scroll events + const scrollTimer = vAPI.defer.create(onScrollChanged); + const onScroll = ( ) => { + scrollTimer.onvsync(1000/32); + }; + dom.on(vwScroller, 'scroll', onScroll, { passive: true }); + + const onLayoutChanged = function() { + vwHeight = vwRenderer.clientHeight; + vwContent.style.height = `${vwScroller.clientHeight}px`; + + const vExpanded = + dom.cl.has('#netInspector .vCompactToggler', 'vExpanded'); + + let newLineHeight = qs$(vwLineSizer, '.oneLine').clientHeight; + + if ( vExpanded ) { + newLineHeight *= loggerSettings.linesPerEntry; + } + + const lineCount = newLineHeight !== 0 + ? Math.ceil(vwHeight / newLineHeight) + 1 + : 0; + if ( lineCount > vwEntries.length ) { + do { + vwEntries.push(new ViewEntry()); + } while ( lineCount > vwEntries.length ); + } else if ( lineCount < vwEntries.length ) { + do { + vwEntries.pop().dispose(); + } while ( lineCount < vwEntries.length ); + } + + const cellWidths = Array.from( + qsa$(vwLineSizer, '.oneLine span') + ).map((el, i) => { + return loggerSettings.columns[i] !== false + ? el.clientWidth + 1 + : 0; + }); + const reservedWidth = + cellWidths[COLUMN_TIMESTAMP] + + cellWidths[COLUMN_RESULT] + + cellWidths[COLUMN_PARTYNESS] + + cellWidths[COLUMN_METHOD] + + cellWidths[COLUMN_TYPE]; + cellWidths[COLUMN_URL] = 0.5; + if ( cellWidths[COLUMN_FILTER] === 0 && cellWidths[COLUMN_INITIATOR] === 0 ) { + cellWidths[COLUMN_URL] = 1; + } else if ( cellWidths[COLUMN_FILTER] === 0 ) { + cellWidths[COLUMN_INITIATOR] = 0.35; + cellWidths[COLUMN_URL] = 0.65; + } else if ( cellWidths[COLUMN_INITIATOR] === 0 ) { + cellWidths[COLUMN_FILTER] = 0.35; + cellWidths[COLUMN_URL] = 0.65; + } else { + cellWidths[COLUMN_FILTER] = 0.25; + cellWidths[COLUMN_INITIATOR] = 0.25; + cellWidths[COLUMN_URL] = 0.5; + } + const style = qs$('#vwRendererRuntimeStyles'); + const cssRules = [ + '#vwContent .logEntry {', + ` height: ${newLineHeight}px;`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_TIMESTAMP+1}) {`, + ` width: ${cellWidths[COLUMN_TIMESTAMP]}px;`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_FILTER+1}) {`, + ` width: calc(calc(100% - ${reservedWidth}px) * ${cellWidths[COLUMN_FILTER]});`, + '}', + `#vwContent .logEntry > div.messageRealm > span:nth-of-type(${COLUMN_MESSAGE+1}) {`, + ` width: calc(100% - ${cellWidths[COLUMN_TIMESTAMP]}px);`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_RESULT+1}) {`, + ` width: ${cellWidths[COLUMN_RESULT]}px;`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_INITIATOR+1}) {`, + ` width: calc(calc(100% - ${reservedWidth}px) * ${cellWidths[COLUMN_INITIATOR]});`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_PARTYNESS+1}) {`, + ` width: ${cellWidths[COLUMN_PARTYNESS]}px;`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_METHOD+1}) {`, + ` width: ${cellWidths[COLUMN_METHOD]}px;`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_TYPE+1}) {`, + ` width: ${cellWidths[COLUMN_TYPE]}px;`, + '}', + `#vwContent .logEntry > div > span:nth-of-type(${COLUMN_URL+1}) {`, + ` width: calc(calc(100% - ${reservedWidth}px) * ${cellWidths[COLUMN_URL]});`, + '}', + '', + ]; + for ( let i = 0; i < cellWidths.length; i++ ) { + if ( cellWidths[i] !== 0 ) { continue; } + cssRules.push( + `#vwContent .logEntry > div > span:nth-of-type(${i + 1}) {`, + ' display: none;', + '}' + ); + } + style.textContent = cssRules.join('\n'); + + lineHeight = newLineHeight; + positionLines(); + dom.cl.toggle('#netInspector', 'vExpanded', vExpanded); + + updateContent(0); + }; + + const resizeTimer = vAPI.defer.create(onLayoutChanged); + const updateLayout = function() { + resizeTimer.onvsync(1000/8); + }; + dom.on(window, 'resize', updateLayout, { passive: true }); + + updateLayout(); + + const renderFilterToSpan = function(span, filter) { + if ( filter.charCodeAt(0) !== 0x23 /* '#' */ ) { return false; } + const match = /^#@?#/.exec(filter); + if ( match === null ) { return false; } + let child = document.createElement('span'); + child.textContent = match[0]; + span.appendChild(child); + child = document.createElement('span'); + child.textContent = filter.slice(match[0].length); + span.appendChild(child); + return true; + }; + + const renderToDiv = function(vwEntry, i) { + if ( i >= filteredLoggerEntries.length ) { + vwEntry.logEntry = undefined; + return null; + } + + const details = filteredLoggerEntries[i]; + if ( vwEntry.logEntry === details ) { + return vwEntry.div.firstElementChild; + } + + vwEntry.logEntry = details; + + const cells = details.textContent.split('\t'); + const div = dom.clone(vwLogEntryTemplate); + const divcl = div.classList; + let span; + + // Realm + if ( details.realm !== undefined ) { + divcl.add(details.realm + 'Realm'); + } + + // Timestamp + span = div.children[COLUMN_TIMESTAMP]; + span.textContent = cells[COLUMN_TIMESTAMP]; + + // Tab id + if ( details.tabId !== undefined ) { + dom.attr(div, 'data-tabid', details.tabId); + if ( details.voided ) { + divcl.add('voided'); + } + } + + if ( details.realm === 'message' ) { + if ( details.type !== undefined ) { + dom.attr(div, 'data-type', details.type); + } + span = div.children[COLUMN_MESSAGE]; + span.textContent = cells[COLUMN_MESSAGE]; + return div; + } + + if ( detailableRealms.has(details.realm) ) { + divcl.add('canDetails'); + } + + // Filter + const filter = details.filter || undefined; + let filteringType; + if ( filter !== undefined ) { + if ( typeof filter.source === 'string' ) { + filteringType = filter.source; + } + if ( filteringType === 'static' ) { + divcl.add('canLookup'); + } else if ( details.realm === 'extended' ) { + divcl.toggle('canLookup', /^#@?#/.test(filter.raw)); + divcl.toggle('isException', filter.raw.startsWith('#@#')); + } + if ( filter.modifier === true ) { + dom.attr(div, 'data-modifier', ''); + } + } + span = div.children[COLUMN_FILTER]; + if ( renderFilterToSpan(span, cells[COLUMN_FILTER]) ) { + if ( /^\+js\(.*\)$/.test(span.children[1].textContent) ) { + divcl.add('scriptlet'); + } + } else { + span.textContent = cells[COLUMN_FILTER]; + } + + // Event + if ( cells[COLUMN_RESULT] === '--' ) { + dom.attr(div, 'data-status', '1'); + } else if ( cells[COLUMN_RESULT] === '++' ) { + dom.attr(div, 'data-status', '2'); + } else if ( cells[COLUMN_RESULT] === '**' ) { + dom.attr(div, 'data-status', '3'); + } else if ( cells[COLUMN_RESULT] === '<<' ) { + divcl.add('redirect'); + } + span = div.children[COLUMN_RESULT]; + span.textContent = cells[COLUMN_RESULT]; + + // Origins + if ( details.tabHostname ) { + dom.attr(div, 'data-tabhn', details.tabHostname); + } + if ( details.docHostname ) { + dom.attr(div, 'data-dochn', details.docHostname); + } + span = div.children[COLUMN_INITIATOR]; + span.textContent = cells[COLUMN_INITIATOR]; + + // Partyness + if ( + cells[COLUMN_PARTYNESS] !== '' && + details.realm === 'network' && + details.domain !== undefined + ) { + let text = `${details.tabDomain}`; + if ( details.docDomain !== details.tabDomain ) { + text += ` \u22ef ${details.docDomain}`; + } + text += ` \u21d2 ${details.domain}`; + dom.attr(div, 'data-parties', text); + } + span = div.children[COLUMN_PARTYNESS]; + span.textContent = cells[COLUMN_PARTYNESS]; + + // Method + span = div.children[COLUMN_METHOD]; + span.textContent = cells[COLUMN_METHOD]; + + // Type + span = div.children[COLUMN_TYPE]; + span.textContent = cells[COLUMN_TYPE]; + + // URL + let re; + if ( filteringType === 'static' ) { + re = new RegExp(filter.regex, 'gi'); + } else if ( filteringType === 'dynamicUrl' ) { + re = regexFromURLFilteringResult(filter.rule.join(' ')); + } + nodeFromURL(div.children[COLUMN_URL], cells[COLUMN_URL], re, cells[COLUMN_TYPE]); + + // Alias URL (CNAME, etc.) + if ( cells.length > 8 ) { + const pos = details.textContent.lastIndexOf('\taliasURL='); + if ( pos !== -1 ) { + dom.attr(div, 'data-aliasid', details.id); + } + } + + return div; + }; + + // The idea is that positioning DOM elements is faster than + // removing/inserting DOM elements. + const positionLines = function() { + if ( lineHeight === 0 ) { return; } + let y = -(lastTopPix % lineHeight); + for ( const vwEntry of vwEntries ) { + vwEntry.div.style.top = `${y}px`; + y += lineHeight; + } + }; + + const rollLines = function(topRow) { + let delta = topRow - lastTopRow; + let deltaLength = Math.abs(delta); + // No point rolling if no rows can be reused + if ( deltaLength > 0 && deltaLength < vwEntries.length ) { + if ( delta < 0 ) { // Move bottom rows to the top + vwEntries.unshift(...vwEntries.splice(delta)); + } else { // Move top rows to the bottom + vwEntries.push(...vwEntries.splice(0, delta)); + } + } + lastTopRow = topRow; + return delta; + }; + + const fillLines = function() { + let rowBeg = lastTopRow; + for ( const vwEntry of vwEntries ) { + const newDiv = renderToDiv(vwEntry, rowBeg); + const container = vwEntry.div; + const oldDiv = container.firstElementChild; + if ( newDiv !== null ) { + if ( oldDiv === null ) { + container.appendChild(newDiv); + } else if ( newDiv !== oldDiv ) { + container.removeChild(oldDiv); + container.appendChild(newDiv); + } + } else if ( oldDiv !== null ) { + container.removeChild(oldDiv); + } + rowBeg += 1; + } + }; + + const contentChanged = function(addedCount) { + lastTopRow += addedCount; + const newWholeHeight = Math.max( + filteredLoggerEntries.length * lineHeight, + vwRenderer.clientHeight + ); + if ( newWholeHeight !== wholeHeight ) { + vwVirtualContent.style.height = `${newWholeHeight}px`; + wholeHeight = newWholeHeight; + } + }; + + const updateContent = function(addedCount) { + contentChanged(addedCount); + // Content changed + if ( addedCount === 0 ) { + if ( + lastTopRow !== 0 && + lastTopRow + vwEntries.length > filteredLoggerEntries.length + ) { + lastTopRow = filteredLoggerEntries.length - vwEntries.length; + if ( lastTopRow < 0 ) { lastTopRow = 0; } + lastTopPix = lastTopRow * lineHeight; + vwContent.style.top = `${lastTopPix}px`; + vwScroller.scrollTop = lastTopPix; + positionLines(); + } + fillLines(); + return; + } + + // Content added + // Preserve scroll position + if ( lastTopPix === 0 ) { + rollLines(0); + positionLines(); + fillLines(); + return; + } + + // Preserve row position + lastTopPix += lineHeight * addedCount; + vwContent.style.top = `${lastTopPix}px`; + vwScroller.scrollTop = lastTopPix; + }; + + return { updateContent, updateLayout, }; +})(); + +/******************************************************************************/ + +const updateCurrentTabTitle = (( ) => { + const i18nCurrentTab = i18n$('loggerCurrentTab'); + + return ( ) => { + const select = qs$('#pageSelector'); + if ( select.value !== '_' || activeTabId === 0 ) { return; } + const opt0 = qs$(select, '[value="_"]'); + const opt1 = qs$(select, `[value="${activeTabId}"]`); + let text = i18nCurrentTab; + if ( opt1 !== null ) { + text += ' / ' + opt1.textContent; + } + opt0.textContent = text; + }; +})(); + +/******************************************************************************/ + +const synchronizeTabIds = function(newTabIds) { + const select = qs$('#pageSelector'); + const selectedTabValue = select.value; + const oldTabIds = allTabIds; + + // Collate removed tab ids. + const toVoid = new Set(); + for ( const tabId of oldTabIds.keys() ) { + if ( newTabIds.has(tabId) ) { continue; } + toVoid.add(tabId); + } + allTabIds = newTabIds; + + // Mark as "void" all logger entries which are linked to now invalid + // tab ids. + // When an entry is voided without being removed, we re-create a new entry + // in order to ensure the entry has a new identity. A new identify ensures + // that identity-based associations elsewhere are automatically + // invalidated. + if ( toVoid.size !== 0 ) { + const autoDeleteVoidedRows = selectedTabValue === '_'; + let rowVoided = false; + for ( let i = 0, n = loggerEntries.length; i < n; i++ ) { + const entry = loggerEntries[i]; + if ( toVoid.has(entry.tabId) === false ) { continue; } + if ( entry.voided ) { continue; } + rowVoided = entry.voided = true; + if ( autoDeleteVoidedRows ) { + entry.dead = true; + } + loggerEntries[i] = new LogEntry(entry); + } + if ( rowVoided ) { + rowFilterer.filterAll(); + } + } + + // Remove popup if it is currently bound to a removed tab. + if ( toVoid.has(popupManager.tabId) ) { + popupManager.toggleOff(); + } + + const tabIds = Array.from(newTabIds.keys()).sort(function(a, b) { + return newTabIds.get(a).localeCompare(newTabIds.get(b)); + }); + let j = 3; + for ( const tabId of tabIds ) { + if ( tabId <= 0 ) { continue; } + if ( j === select.options.length ) { + select.appendChild(document.createElement('option')); + } + const option = select.options[j]; + // Truncate too long labels. + option.textContent = newTabIds.get(tabId).slice(0, 80); + dom.attr(option, 'value', tabId); + if ( option.value === selectedTabValue ) { + select.selectedIndex = j; + dom.attr(option, 'selected', ''); + } else { + dom.attr(option, 'selected', null); + } + j += 1; + } + while ( j < select.options.length ) { + select.removeChild(select.options[j]); + } + if ( select.value !== selectedTabValue ) { + select.selectedIndex = 0; + select.value = ''; + dom.attr(select.options[0], 'selected', ''); + pageSelectorChanged(); + } + + updateCurrentTabTitle(); +}; + +/******************************************************************************/ + +const onLogBufferRead = function(response) { + if ( !response || response.unavailable ) { return; } + + // Disable tooltips? + if ( + popupLoggerTooltips === undefined && + response.tooltips !== undefined + ) { + popupLoggerTooltips = response.tooltips; + if ( popupLoggerTooltips === false ) { + dom.attr('[data-i18n-title]', 'title', ''); + } + } + + // Tab id of currently active tab + let activeTabIdChanged = false; + if ( response.activeTabId ) { + activeTabIdChanged = response.activeTabId !== activeTabId; + activeTabId = response.activeTabId; + } + + if ( Array.isArray(response.tabIds) ) { + response.tabIds = new Map(response.tabIds); + } + + // List of tab ids has changed + if ( response.tabIds !== undefined ) { + synchronizeTabIds(response.tabIds); + allTabIdsToken = response.tabIdsToken; + } + + if ( activeTabIdChanged ) { + pageSelectorFromURLHash(); + } + + processLoggerEntries(response); + + // Synchronize DOM with sent logger data + dom.cl.toggle(dom.html, 'colorBlind', response.colorBlind === true); + dom.cl.toggle('#clean', 'disabled', filteredLoggerEntryVoidedCount === 0); + dom.cl.toggle('#clear', 'disabled', filteredLoggerEntries.length === 0); +}; + +/******************************************************************************/ + +const readLogBuffer = (( ) => { + let reading = false; + + const readLogBufferNow = async function() { + if ( logger.ownerId === undefined ) { return; } + if ( reading ) { return; } + + reading = true; + + const msg = { + what: 'readAll', + ownerId: logger.ownerId, + tabIdsToken: allTabIdsToken, + }; + + // This is to detect changes in the position or size of the logger + // popup window (if in use). + if ( + popupLoggerBox instanceof Object && + ( + self.screenX !== popupLoggerBox.x || + self.screenY !== popupLoggerBox.y || + self.outerWidth !== popupLoggerBox.w || + self.outerHeight !== popupLoggerBox.h + ) + ) { + popupLoggerBox.x = self.screenX; + popupLoggerBox.y = self.screenY; + popupLoggerBox.w = self.outerWidth; + popupLoggerBox.h = self.outerHeight; + msg.popupLoggerBoxChanged = true; + } + + const response = await vAPI.messaging.send('loggerUI', msg); + + onLogBufferRead(response); + + reading = false; + + timer.on(1200); + }; + + const timer = vAPI.defer.create(readLogBufferNow); + + readLogBufferNow(); + + return ( ) => { + timer.on(1200); + }; +})(); + +/******************************************************************************/ + +const pageSelectorChanged = function() { + const select = qs$('#pageSelector'); + window.location.replace('#' + select.value); + pageSelectorFromURLHash(); +}; + +const pageSelectorFromURLHash = (( ) => { + let lastHash; + let lastSelectedTabId; + + return function() { + let hash = window.location.hash.slice(1); + let match = /^([^+]+)\+(.+)$/.exec(hash); + if ( match !== null ) { + hash = match[1]; + activeTabId = parseInt(match[2], 10) || 0; + window.location.hash = '#' + hash; + } + + if ( hash !== lastHash ) { + const select = qs$('#pageSelector'); + let option = qs$(select, `option[value="${hash}"]`); + if ( option === null ) { + hash = '0'; + option = select.options[0]; + } + select.selectedIndex = option.index; + select.value = option.value; + lastHash = hash; + } + + selectedTabId = hash === '_' + ? activeTabId + : parseInt(hash, 10) || 0; + + if ( lastSelectedTabId === selectedTabId ) { return; } + + rowFilterer.filterAll(); + document.dispatchEvent(new Event('tabIdChanged')); + updateCurrentTabTitle(); + dom.cl.toggle('.needdom', 'disabled', selectedTabId <= 0); + dom.cl.toggle('.needscope', 'disabled', selectedTabId <= 0); + lastSelectedTabId = selectedTabId; + }; +})(); + +/******************************************************************************/ + +const reloadTab = function(bypassCache = false) { + const tabId = tabIdFromPageSelector(); + if ( tabId <= 0 ) { return; } + messaging.send('loggerUI', { + what: 'reloadTab', + tabId, + bypassCache, + }); +}; + +dom.on('#refresh', 'click', ev => { + reloadTab(ev.ctrlKey || ev.metaKey || ev.shiftKey); +}); + +dom.on(document, 'keydown', ev => { + if ( ev.isComposing ) { return; } + let bypassCache = false; + switch ( ev.key ) { + case 'F5': + bypassCache = ev.ctrlKey || ev.metaKey || ev.shiftKey; + break; + case 'r': + if ( (ev.ctrlKey || ev.metaKey) !== true ) { return; } + break; + case 'R': + if ( (ev.ctrlKey || ev.metaKey) !== true ) { return; } + bypassCache = true; + break; + default: + return; + } + reloadTab(bypassCache); + ev.preventDefault(); + ev.stopPropagation(); +}, { capture: true }); + +/******************************************************************************/ +/******************************************************************************/ + +(( ) => { + const reRFC3986 = /^([^:\/?#]+:)?(\/\/[^\/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?/; + const reSchemeOnly = /^[\w-]+:$/; + const staticFilterTypes = { + 'beacon': 'ping', + 'doc': 'document', + 'css': 'stylesheet', + 'frame': 'subdocument', + 'object_subrequest': 'object', + 'csp_report': 'other', + }; + const createdStaticFilters = {}; + const reIsExceptionFilter = /^@@|^[\w.-]*?#@#/; + + let dialog = null; + let targetRow = null; + let targetType; + let targetURLs = []; + let targetFrameHostname; + let targetPageHostname; + let targetTabId; + let targetDomain; + let targetPageDomain; + let targetFrameDomain; + + const uglyTypeFromSelector = pane => { + const prettyType = selectValue('select.type.' + pane); + if ( pane === 'static' ) { + return staticFilterTypes[prettyType] || prettyType; + } + return uglyRequestTypes[prettyType] || prettyType; + }; + + const selectNode = selector => { + return qs$(dialog, selector); + }; + + const selectValue = selector => { + return selectNode(selector).value || ''; + }; + + const staticFilterNode = ( ) => { + return qs$(dialog, 'div.panes > div.static textarea'); + }; + + const toExceptionFilter = (filter, extended) => { + if ( reIsExceptionFilter.test(filter) ) { return filter; } + return extended ? filter.replace('##', '#@#') : `@@${filter}`; + }; + + const onColorsReady = function(response) { + dom.cl.toggle(dom.body, 'dirty', response.dirty); + for ( const url in response.colors ) { + if ( response.colors.hasOwnProperty(url) === false ) { continue; } + const colorEntry = response.colors[url]; + const node = qs$(dialog, `.dynamic .entry .action[data-url="${url}"]`); + if ( node === null ) { continue; } + dom.cl.toggle(node, 'allow', colorEntry.r === 2); + dom.cl.toggle(node, 'noop', colorEntry.r === 3); + dom.cl.toggle(node, 'block', colorEntry.r === 1); + dom.cl.toggle(node, 'own', colorEntry.own); + } + }; + + const colorize = async function() { + const response = await messaging.send('loggerUI', { + what: 'getURLFilteringData', + context: selectValue('select.dynamic.origin'), + urls: targetURLs, + type: uglyTypeFromSelector('dynamic'), + }); + onColorsReady(response); + }; + + const parseStaticInputs = function() { + const options = []; + const block = selectValue('select.static.action') === ''; + let filter = ''; + if ( !block ) { + filter = '@@'; + } + let value = selectValue('select.static.url'); + if ( value !== '' ) { + if ( reSchemeOnly.test(value) ) { + value = `|${value}`; + } else { + if ( value.endsWith('/') ) { + value += '*'; + } else if ( /[/?]/.test(value) === false ) { + value += '^'; + } + value = `||${value}`; + } + } + filter += value; + value = selectValue('select.static.type'); + if ( value !== '' ) { + options.push(uglyTypeFromSelector('static')); + } + value = selectValue('select.static.origin'); + if ( value !== '' ) { + if ( value === targetDomain ) { + options.push('1p'); + } else { + options.push('domain=' + value); + } + } + if ( block && selectValue('select.static.importance') !== '' ) { + options.push('important'); + } + if ( options.length ) { + filter += '$' + options.join(','); + } + staticFilterNode().value = filter; + updateWidgets(); + }; + + const updateWidgets = function() { + const value = staticFilterNode().value; + dom.cl.toggle( + qs$(dialog, '#createStaticFilter'), + 'disabled', + createdStaticFilters.hasOwnProperty(value) || value === '' + ); + }; + + const onClick = async function(ev) { + const target = ev.target; + const tcl = target.classList; + + // Close entry tools + if ( tcl.contains('closeButton') ) { + ev.stopPropagation(); + toggleOff(); + return; + } + + // Select a pane + if ( tcl.contains('header') ) { + ev.stopPropagation(); + dom.attr(dialog, 'data-pane', dom.attr(target, 'data-pane')); + return; + } + + // Toggle temporary exception filter + if ( tcl.contains('exceptor') ) { + ev.stopPropagation(); + const filter = filterFromTargetRow(); + const status = await messaging.send('loggerUI', { + what: 'toggleInMemoryFilter', + filter: toExceptionFilter(filter, dom.cl.has(targetRow, 'extendedRealm')), + }); + const row = target.closest('div'); + dom.cl.toggle(row, 'exceptored', status); + return; + } + + // Create static filter + if ( target.id === 'createStaticFilter' ) { + ev.stopPropagation(); + const value = staticFilterNode().value; + // Avoid duplicates + if ( createdStaticFilters.hasOwnProperty(value) ) { return; } + createdStaticFilters[value] = true; + // https://github.com/uBlockOrigin/uBlock-issues/issues/1281#issuecomment-704217175 + // TODO: + // Figure a way to use the actual document URL. Currently using + // a synthetic URL derived from the document hostname. + if ( value !== '' ) { + messaging.send('loggerUI', { + what: 'createUserFilter', + autoComment: true, + filters: value, + docURL: `https://${targetFrameHostname}/`, + }); + } + updateWidgets(); + return; + } + + // Save url filtering rule(s) + if ( target.id === 'saveRules' ) { + ev.stopPropagation(); + await messaging.send('loggerUI', { + what: 'saveURLFilteringRules', + context: selectValue('select.dynamic.origin'), + urls: targetURLs, + type: uglyTypeFromSelector('dynamic'), + }); + colorize(); + return; + } + + const persist = !!ev.ctrlKey || !!ev.metaKey; + + // Remove url filtering rule + if ( tcl.contains('action') ) { + ev.stopPropagation(); + await messaging.send('loggerUI', { + what: 'setURLFilteringRule', + context: selectValue('select.dynamic.origin'), + url: dom.attr(target, 'data-url'), + type: uglyTypeFromSelector('dynamic'), + action: 0, + persist: persist, + }); + colorize(); + return; + } + + // add "allow" url filtering rule + if ( tcl.contains('allow') ) { + ev.stopPropagation(); + await messaging.send('loggerUI', { + what: 'setURLFilteringRule', + context: selectValue('select.dynamic.origin'), + url: dom.attr(target.parentNode, 'data-url'), + type: uglyTypeFromSelector('dynamic'), + action: 2, + persist: persist, + }); + colorize(); + return; + } + + // add "block" url filtering rule + if ( tcl.contains('noop') ) { + ev.stopPropagation(); + await messaging.send('loggerUI', { + what: 'setURLFilteringRule', + context: selectValue('select.dynamic.origin'), + url: dom.attr(target.parentNode, 'data-url'), + type: uglyTypeFromSelector('dynamic'), + action: 3, + persist: persist, + }); + colorize(); + return; + } + + // add "block" url filtering rule + if ( tcl.contains('block') ) { + ev.stopPropagation(); + await messaging.send('loggerUI', { + what: 'setURLFilteringRule', + context: selectValue('select.dynamic.origin'), + url: dom.attr(target.parentNode, 'data-url'), + type: uglyTypeFromSelector('dynamic'), + action: 1, + persist: persist, + }); + colorize(); + return; + } + + // Highlight corresponding element in target web page + if ( tcl.contains('picker') ) { + ev.stopPropagation(); + messaging.send('loggerUI', { + what: 'launchElementPicker', + tabId: targetTabId, + targetURL: 'img\t' + targetURLs[0], + select: true, + }); + return; + } + + // Reload tab associated with event + if ( tcl.contains('reload') ) { + ev.stopPropagation(); + messaging.send('loggerUI', { + what: 'reloadTab', + tabId: targetTabId, + bypassCache: ev.ctrlKey || ev.metaKey || ev.shiftKey, + }); + return; + } + }; + + const onSelectChange = function(ev) { + const tcl = ev.target.classList; + + if ( tcl.contains('dynamic') ) { + colorize(); + return; + } + + if ( tcl.contains('static') ) { + parseStaticInputs(); + return; + } + }; + + const onInputChange = function() { + updateWidgets(); + }; + + const createPreview = function(type, url) { + const cantPreview = + type !== 'image' || + dom.cl.has(targetRow, 'networkRealm') === false || + dom.attr(targetRow, 'data-status') === '1'; + + // Whether picker can be used + dom.cl.toggle( + qs$(dialog, '.picker'), + 'hide', + targetTabId < 0 || cantPreview + ); + + // Whether the resource can be previewed + if ( cantPreview ) { return; } + + const container = qs$(dialog, '.preview'); + dom.on(qs$(container, 'span'), 'click', ( ) => { + const preview = dom.create('img'); + dom.attr(preview, 'src', url); + container.replaceChild(preview, container.firstElementChild); + }, { once: true }); + + dom.cl.remove(container, 'hide'); + }; + + // https://github.com/gorhill/uBlock/issues/1511 + const shortenLongString = function(url, max) { + const urlLen = url.length; + if ( urlLen <= max ) { + return url; + } + const n = urlLen - max - 1; + const i = (urlLen - n) / 2 | 0; + return url.slice(0, i) + '…' + url.slice(i + n); + }; + + // Build list of candidate URLs + const createTargetURLs = function(url) { + const matches = reRFC3986.exec(url); + if ( matches === null ) { return []; } + if ( typeof matches[2] !== 'string' || matches[2].length === 0 ) { + return [ matches[1] ]; + } + // Shortest URL for a valid URL filtering rule + const urls = []; + const rootURL = matches[1] + matches[2]; + urls.unshift(rootURL); + const path = matches[3] || ''; + let pos = path.charAt(0) === '/' ? 1 : 0; + while ( pos < path.length ) { + pos = path.indexOf('/', pos); + if ( pos === -1 ) { + pos = path.length; + } else { + pos += 1; + } + urls.unshift(rootURL + path.slice(0, pos)); + } + const query = matches[4] || ''; + if ( query !== '' ) { + urls.unshift(rootURL + path + query); + } + return urls; + }; + + const filterFromTargetRow = function() { + return dom.text(targetRow.children[COLUMN_FILTER]); + }; + + const aliasURLFromID = function(id) { + if ( id === '' ) { return ''; } + for ( const entry of loggerEntries ) { + if ( entry.id !== id || entry.aliased ) { continue; } + const fields = entry.textContent.split('\t'); + return fields[COLUMN_URL] || ''; + } + return ''; + }; + + const toSummaryPaneFilterNode = async function(receiver, filter) { + receiver.children[COLUMN_FILTER].textContent = filter; + if ( dom.cl.has(targetRow, 'canLookup') === false ) { return; } + const isException = reIsExceptionFilter.test(filter); + let isExcepted = false; + if ( isException ) { + isExcepted = await messaging.send('loggerUI', { + what: 'hasInMemoryFilter', + filter: toExceptionFilter(filter, dom.cl.has(targetRow, 'extendedRealm')), + }); + } + if ( isException && isExcepted === false ) { return; } + dom.cl.toggle(receiver, 'exceptored', isExcepted); + receiver.children[2].style.visibility = ''; + }; + + const fillSummaryPaneFilterList = async function(rows) { + const rawFilter = targetRow.children[COLUMN_FILTER].textContent; + + const nodeFromFilter = function(filter, lists) { + const fragment = document.createDocumentFragment(); + const template = qs$('#filterFinderListEntry > span'); + for ( const list of lists ) { + const span = dom.clone(template); + let a = qs$(span, 'a:nth-of-type(1)'); + a.href += encodeURIComponent(list.assetKey); + a.append(i18n.patchUnicodeFlags(list.title)); + a = qs$(span, 'a:nth-of-type(2)'); + if ( list.supportURL ) { + dom.attr(a, 'href', list.supportURL); + } else { + a.style.display = 'none'; + } + if ( fragment.childElementCount !== 0 ) { + fragment.appendChild(document.createTextNode('\n')); + } + fragment.appendChild(span); + } + return fragment; + }; + + const handleResponse = function(response) { + if ( response instanceof Object === false ) { + response = {}; + } + let bestMatchFilter = ''; + for ( const filter in response ) { + if ( filter.length > bestMatchFilter.length ) { + bestMatchFilter = filter; + } + } + if ( + bestMatchFilter !== '' && + Array.isArray(response[bestMatchFilter]) + ) { + toSummaryPaneFilterNode(rows[0], bestMatchFilter); + rows[1].children[1].appendChild(nodeFromFilter( + bestMatchFilter, + response[bestMatchFilter] + )); + } + // https://github.com/gorhill/uBlock/issues/2179 + if ( rows[1].children[1].childElementCount === 0 ) { + i18n.safeTemplateToDOM( + 'loggerStaticFilteringFinderSentence2', + { filter: rawFilter }, + rows[1].children[1] + ); + } + }; + + if ( dom.cl.has(targetRow, 'networkRealm') ) { + const response = await messaging.send('loggerUI', { + what: 'listsFromNetFilter', + rawFilter: rawFilter, + }); + handleResponse(response); + } else if ( dom.cl.has(targetRow, 'extendedRealm') ) { + const response = await messaging.send('loggerUI', { + what: 'listsFromCosmeticFilter', + url: targetRow.children[COLUMN_URL].textContent, + rawFilter: rawFilter, + }); + handleResponse(response); + } + }; + + const fillSummaryPane = function() { + const rows = qsa$(dialog, '.pane.details > div'); + const tr = targetRow; + const trcl = tr.classList; + const trch = tr.children; + let text; + // Filter and context + text = filterFromTargetRow(); + if ( + (text !== '') && + (trcl.contains('extendedRealm') || trcl.contains('networkRealm')) + ) { + toSummaryPaneFilterNode(rows[0], text); + } else { + rows[0].style.display = 'none'; + } + // Rule + if ( + (text !== '') && + ( + trcl.contains('dynamicHost') || + trcl.contains('dynamicUrl') || + trcl.contains('switchRealm') + ) + ) { + rows[2].children[1].textContent = text; + } else { + rows[2].style.display = 'none'; + } + // Filter list + if ( trcl.contains('canLookup') ) { + fillSummaryPaneFilterList(rows); + } else { + rows[1].style.display = 'none'; + } + // Root and immediate contexts + const tabhn = dom.attr(tr, 'data-tabhn') || ''; + const dochn = dom.attr(tr, 'data-dochn') || ''; + if ( tabhn !== '' && tabhn !== dochn ) { + rows[3].children[1].textContent = tabhn; + } else { + rows[3].style.display = 'none'; + } + if ( dochn !== '' ) { + rows[4].children[1].textContent = dochn; + } else { + rows[4].style.display = 'none'; + } + // Partyness + text = dom.attr(tr, 'data-parties') || ''; + if ( text !== '' ) { + rows[5].children[1].textContent = `(${trch[COLUMN_PARTYNESS].textContent})\u2002${text}`; + } else { + rows[5].style.display = 'none'; + } + // Type + text = trch[COLUMN_TYPE].textContent; + if ( text !== '' ) { + rows[6].children[1].textContent = text; + } else { + rows[6].style.display = 'none'; + } + // URL + const canonicalURL = trch[COLUMN_URL].textContent; + if ( canonicalURL !== '' ) { + const attr = dom.attr(tr, 'data-status') || ''; + if ( attr !== '' ) { + dom.attr(rows[7], 'data-status', attr); + if ( tr.hasAttribute('data-modifier') ) { + dom.attr(rows[7], 'data-modifier', ''); + } + } + rows[7].children[1].appendChild(dom.clone(trch[COLUMN_URL])); + } else { + rows[7].style.display = 'none'; + } + // Alias URL + text = dom.attr(tr, 'data-aliasid'); + const aliasURL = text ? aliasURLFromID(text) : ''; + if ( aliasURL !== '' ) { + rows[8].children[1].textContent = + hostnameFromURI(aliasURL) + ' \u21d2\n\u2003' + + hostnameFromURI(canonicalURL); + rows[9].children[1].textContent = aliasURL; + } else { + rows[8].style.display = 'none'; + rows[9].style.display = 'none'; + } + }; + + // Fill dynamic URL filtering pane + const fillDynamicPane = function() { + if ( dom.cl.has(targetRow, 'extendedRealm') ) { return; } + + // https://github.com/uBlockOrigin/uBlock-issues/issues/662#issuecomment-509220702 + if ( targetType === 'doc' ) { return; } + + // https://github.com/gorhill/uBlock/issues/2469 + if ( targetURLs.length === 0 || reSchemeOnly.test(targetURLs[0]) ) { + return; + } + + // Fill context selector + let select = selectNode('select.dynamic.origin'); + fillOriginSelect(select, targetPageHostname, targetPageDomain); + const option = document.createElement('option'); + option.textContent = '*'; + dom.attr(option, 'value', '*'); + select.appendChild(option); + + // Fill type selector + select = selectNode('select.dynamic.type'); + select.options[0].textContent = targetType; + dom.attr(select.options[0], 'value', targetType); + select.selectedIndex = 0; + + // Fill entries + const menuEntryTemplate = qs$(dialog, '.dynamic .toolbar .entry'); + const tbody = qs$(dialog, '.dynamic .entries'); + for ( const targetURL of targetURLs ) { + const menuEntry = dom.clone(menuEntryTemplate); + dom.attr(menuEntry.children[0], 'data-url', targetURL); + menuEntry.children[1].textContent = shortenLongString(targetURL, 128); + tbody.appendChild(menuEntry); + } + + colorize(); + }; + + const fillOriginSelect = function(select, hostname, domain) { + const template = i18n$('loggerStaticFilteringSentencePartOrigin'); + let value = hostname; + for (;;) { + const option = document.createElement('option'); + dom.attr(option, 'value', value); + option.textContent = template.replace('{{origin}}', value); + select.appendChild(option); + if ( value === domain ) { break; } + const pos = value.indexOf('.'); + if ( pos === -1 ) { break; } + value = value.slice(pos + 1); + } + }; + + // Fill static filtering pane + const fillStaticPane = function() { + if ( dom.cl.has(targetRow, 'extendedRealm') ) { return; } + + const template = i18n$('loggerStaticFilteringSentence'); + const rePlaceholder = /\{\{[^}]+?\}\}/g; + const nodes = []; + let pos = 0; + for (;;) { + const match = rePlaceholder.exec(template); + if ( match === null ) { break; } + if ( pos !== match.index ) { + nodes.push(document.createTextNode(template.slice(pos, match.index))); + } + pos = rePlaceholder.lastIndex; + let select, option; + switch ( match[0] ) { + case '{{br}}': + nodes.push(document.createElement('br')); + break; + + case '{{action}}': + select = document.createElement('select'); + select.className = 'static action'; + option = document.createElement('option'); + dom.attr(option, 'value', ''); + option.textContent = i18n$('loggerStaticFilteringSentencePartBlock'); + select.appendChild(option); + option = document.createElement('option'); + dom.attr(option, 'value', '@@'); + option.textContent = i18n$('loggerStaticFilteringSentencePartAllow'); + select.appendChild(option); + nodes.push(select); + break; + + case '{{type}}': { + const filterType = staticFilterTypes[targetType] || targetType; + select = document.createElement('select'); + select.className = 'static type'; + option = document.createElement('option'); + dom.attr(option, 'value', filterType); + option.textContent = i18n$('loggerStaticFilteringSentencePartType').replace('{{type}}', filterType); + select.appendChild(option); + option = document.createElement('option'); + dom.attr(option, 'value', ''); + option.textContent = i18n$('loggerStaticFilteringSentencePartAnyType'); + select.appendChild(option); + nodes.push(select); + break; + } + case '{{url}}': + select = document.createElement('select'); + select.className = 'static url'; + for ( const targetURL of targetURLs ) { + const value = targetURL.replace(/^[a-z-]+:\/\//, ''); + option = document.createElement('option'); + dom.attr(option, 'value', value); + option.textContent = shortenLongString(value, 128); + select.appendChild(option); + } + nodes.push(select); + break; + + case '{{origin}}': + select = document.createElement('select'); + select.className = 'static origin'; + fillOriginSelect(select, targetFrameHostname, targetFrameDomain); + option = document.createElement('option'); + dom.attr(option, 'value', ''); + option.textContent = i18n$('loggerStaticFilteringSentencePartAnyOrigin'); + select.appendChild(option); + nodes.push(select); + break; + + case '{{importance}}': + select = document.createElement('select'); + select.className = 'static importance'; + option = document.createElement('option'); + dom.attr(option, 'value', ''); + option.textContent = i18n$('loggerStaticFilteringSentencePartNotImportant'); + select.appendChild(option); + option = document.createElement('option'); + dom.attr(option, 'value', 'important'); + option.textContent = i18n$('loggerStaticFilteringSentencePartImportant'); + select.appendChild(option); + nodes.push(select); + break; + + default: + break; + } + } + if ( pos < template.length ) { + nodes.push(document.createTextNode(template.slice(pos))); + } + const parent = qs$(dialog, 'div.panes > .static > div:first-of-type'); + for ( let i = 0; i < nodes.length; i++ ) { + parent.appendChild(nodes[i]); + } + parseStaticInputs(); + }; + + const moveDialog = ev => { + if ( ev.button !== 0 && ev.touches === undefined ) { return; } + const widget = qs$('#netInspector .entryTools'); + onStartMovingWidget(ev, widget, ( ) => { + vAPI.localStorage.setItem( + 'loggerUI.entryTools', + JSON.stringify({ + bottom: widget.style.bottom, + left: widget.style.left, + right: widget.style.right, + top: widget.style.top, + }) + ); + }); + }; + + const fillDialog = function(domains) { + dialog = dom.clone('#templates .netFilteringDialog'); + dom.cl.toggle( + dialog, + 'extendedRealm', + dom.cl.has(targetRow, 'extendedRealm') + ); + targetDomain = domains[0]; + targetPageDomain = domains[1]; + targetFrameDomain = domains[2]; + createPreview(targetType, targetURLs[0]); + fillSummaryPane(); + fillDynamicPane(); + fillStaticPane(); + dom.on(dialog, 'click', ev => { onClick(ev); }, true); + dom.on(dialog, 'change', onSelectChange, true); + dom.on(dialog, 'input', onInputChange, true); + const container = qs$('#netInspector .entryTools'); + if ( container.firstChild ) { + container.replaceChild(dialog, container.firstChild); + } else { + container.append(dialog); + } + const moveBand = qs$(dialog, '.moveBand'); + dom.on(moveBand, 'mousedown', moveDialog); + dom.on(moveBand, 'touchstart', moveDialog); + }; + + const toggleOn = async function(ev) { + targetRow = ev.target.closest('.canDetails'); + if ( targetRow === null ) { return; } + ev.stopPropagation(); + targetTabId = tabIdFromAttribute(targetRow); + targetType = targetRow.children[COLUMN_TYPE].textContent.trim() || ''; + targetURLs = createTargetURLs(targetRow.children[COLUMN_URL].textContent); + targetPageHostname = dom.attr(targetRow, 'data-tabhn') || ''; + targetFrameHostname = dom.attr(targetRow, 'data-dochn') || ''; + + // We need the root domain names for best user experience. + const domains = await messaging.send('loggerUI', { + what: 'getDomainNames', + targets: [ + targetURLs[0], + targetPageHostname, + targetFrameHostname + ], + }); + fillDialog(domains); + }; + + const toggleOff = function() { + const container = qs$('#netInspector .entryTools'); + if ( container.firstChild ) { + container.firstChild.remove(); + } + targetURLs = []; + targetRow = null; + dialog = null; + }; + + // Restore position of entry tools dialog + vAPI.localStorage.getItemAsync( + 'loggerUI.entryTools', + ).then(response => { + if ( typeof response !== 'string' ) { return; } + const settings = JSON.parse(response); + const widget = qs$('#netInspector .entryTools'); + widget.style.bottom = ''; + widget.style.left = settings.left || ''; + widget.style.right = settings.right || ''; + widget.style.top = settings.top || ''; + if ( /^-/.test(widget.style.top) ) { + widget.style.top = '0'; + } + }); + + dom.on( + '#netInspector', + 'click', + '.canDetails > span:not(:nth-of-type(4)):not(:nth-of-type(8))', + ev => { toggleOn(ev); } + ); + + dom.on( + '#netInspector', + 'click', + '.logEntry > div > span:nth-of-type(8) a', + ev => { + vAPI.messaging.send('codeViewer', { + what: 'gotoURL', + details: { + url: ev.target.getAttribute('href'), + select: true, + }, + }); + ev.preventDefault(); + ev.stopPropagation(); + } + ); +})(); + +/******************************************************************************/ +/******************************************************************************/ + +const rowFilterer = (( ) => { + const userFilters = []; + const builtinFilters = []; + + let masterFilterSwitch = true; + let filters = []; + + const parseInput = function() { + userFilters.length = 0; + + const rawParts = qs$('#filterInput > input').value.trim().split(/\s+/); + const n = rawParts.length; + const reStrs = []; + let not = false; + for ( let i = 0; i < n; i++ ) { + let rawPart = rawParts[i]; + if ( rawPart.charAt(0) === '!' ) { + if ( reStrs.length === 0 ) { + not = true; + } + rawPart = rawPart.slice(1); + } + let reStr = ''; + if ( rawPart.startsWith('/') && rawPart.endsWith('/') ) { + reStr = rawPart.slice(1, -1); + try { + new RegExp(reStr); + } catch(ex) { + reStr = ''; + } + } + if ( reStr === '' ) { + const hardBeg = rawPart.startsWith('|'); + if ( hardBeg ) { + rawPart = rawPart.slice(1); + } + const hardEnd = rawPart.endsWith('|'); + if ( hardEnd ) { + rawPart = rawPart.slice(0, -1); + } + // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions + reStr = rawPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // https://github.com/orgs/uBlockOrigin/teams/ublock-issues-volunteers/discussions/51 + // Be more flexible when interpreting leading/trailing pipes, + // as leading/trailing pipes are often used in static filters. + if ( hardBeg ) { + reStr = reStr !== '' ? '(?:^|\\s|\\|)' + reStr : '\\|'; + } + if ( hardEnd ) { + reStr += '(?:\\||\\s|$)'; + } + } + if ( reStr === '' ) { continue; } + reStrs.push(reStr); + if ( i < (n - 1) && rawParts[i + 1] === '||' ) { + i += 1; + continue; + } + reStr = reStrs.length === 1 ? reStrs[0] : reStrs.join('|'); + userFilters.push({ + re: new RegExp(reStr, 'i'), + r: !not + }); + reStrs.length = 0; + not = false; + } + filters = builtinFilters.concat(userFilters); + }; + + const filterOne = function(logEntry) { + if ( + logEntry.dead || + selectedTabId !== 0 && + ( + logEntry.tabId === undefined || + logEntry.tabId > 0 && logEntry.tabId !== selectedTabId + ) + ) { + return false; + } + + if ( masterFilterSwitch === false || filters.length === 0 ) { + return true; + } + + // Do not filter out tab load event, they help separate key sections + // of logger. + if ( logEntry.type === 'tabLoad' ) { return true; } + + for ( const f of filters ) { + if ( f.re.test(logEntry.textContent) !== f.r ) { return false; } + } + return true; + }; + + const filterAll = function() { + filteredLoggerEntries = []; + filteredLoggerEntryVoidedCount = 0; + for ( const entry of loggerEntries ) { + if ( filterOne(entry) === false ) { continue; } + filteredLoggerEntries.push(entry); + if ( entry.voided ) { + filteredLoggerEntryVoidedCount += 1; + } + } + viewPort.updateContent(0); + dom.cl.toggle('#filterButton', 'active', filters.length !== 0); + dom.cl.toggle('#clean', 'disabled', filteredLoggerEntryVoidedCount === 0); + dom.cl.toggle('#clear', 'disabled', filteredLoggerEntries.length === 0); + }; + + const onFilterChangedAsync = (( ) => { + const commit = ( ) => { + parseInput(); + filterAll(); + }; + const timer = vAPI.defer.create(commit); + return ( ) => { + timer.offon(750); + }; + })(); + + const onFilterButton = function() { + masterFilterSwitch = !masterFilterSwitch; + dom.cl.toggle('#netInspector', 'f', masterFilterSwitch); + filterAll(); + }; + + const onToggleExtras = function(ev) { + dom.cl.toggle(ev.target, 'expanded'); + }; + + const builtinFilterExpression = function() { + builtinFilters.length = 0; + const filtexElems = qsa$('#filterExprPicker [data-filtex]'); + const orExprs = []; + let not = false; + for ( const filtexElem of filtexElems ) { + const filtex = filtexElem.dataset.filtex; + const active = dom.cl.has(filtexElem, 'on'); + if ( filtex === '!' ) { + if ( orExprs.length !== 0 ) { + builtinFilters.push({ + re: new RegExp(orExprs.join('|')), + r: !not + }); + orExprs.length = 0; + } + not = active; + } else if ( active ) { + orExprs.push(filtex); + } + } + if ( orExprs.length !== 0 ) { + builtinFilters.push({ + re: new RegExp(orExprs.join('|')), + r: !not + }); + } + filters = builtinFilters.concat(userFilters); + dom.cl.toggle('#filterExprButton', 'active', builtinFilters.length !== 0); + filterAll(); + }; + + dom.on('#filterButton', 'click', onFilterButton); + dom.on('#filterInput > input', 'input', onFilterChangedAsync); + dom.on('#filterExprButton', 'click', onToggleExtras); + dom.on('#filterExprPicker', 'click', '[data-filtex]', ev => { + dom.cl.toggle(ev.target, 'on'); + builtinFilterExpression(); + }); + dom.on('#filterInput > input', 'drop', ev => { + const dropItem = item => { + if ( item.kind !== 'string' ) { return false; } + if ( item.type !== 'text/plain' ) { return false; } + item.getAsString(s => { + qs$('#filterInput > input').value = s; + parseInput(); + filterAll(); + }); + return true; + }; + for ( const item of ev.dataTransfer.items ) { + if ( dropItem(item) === false ) { continue; } + ev.preventDefault(); + break; + } + }); + + // https://github.com/gorhill/uBlock/issues/404 + // Ensure page state is in sync with the state of its various widgets. + parseInput(); + builtinFilterExpression(); + filterAll(); + + return { filterOne, filterAll }; +})(); + +/******************************************************************************/ + +// Discard logger entries to prevent undue memory usage growth. The criteria +// to discard are multiple and user configurable: +// +// - Max number of page load per distinct tab +// - Max number of entry per distinct tab +// - Max entry age + +const rowJanitor = (( ) => { + const tabIdToDiscard = new Set(); + const tabIdToLoadCountMap = new Map(); + const tabIdToEntryCountMap = new Map(); + + let rowIndex = 0; + + const discard = function(deadline) { + const opts = loggerSettings.discard; + const maxLoadCount = typeof opts.maxLoadCount === 'number' + ? opts.maxLoadCount + : 0; + const maxEntryCount = typeof opts.maxEntryCount === 'number' + ? opts.maxEntryCount + : 0; + const obsolete = typeof opts.maxAge === 'number' + ? Date.now() - opts.maxAge * 60000 + : 0; + + let i = rowIndex; + // TODO: below should not happen -- remove when confirmed. + if ( i >= loggerEntries.length ) { + i = 0; + } + + if ( i === 0 ) { + tabIdToDiscard.clear(); + tabIdToLoadCountMap.clear(); + tabIdToEntryCountMap.clear(); + } + + let idel = -1; + let bufferedTabId = 0; + let bufferedEntryCount = 0; + let modified = false; + + while ( i < loggerEntries.length ) { + + if ( i % 64 === 0 && deadline.timeRemaining() === 0 ) { break; } + + const entry = loggerEntries[i]; + const tabId = entry.tabId || 0; + + if ( entry.dead || tabIdToDiscard.has(tabId) ) { + if ( idel === -1 ) { idel = i; } + i += 1; + continue; + } + + if ( maxLoadCount !== 0 && entry.type === 'tabLoad' ) { + let count = (tabIdToLoadCountMap.get(tabId) || 0) + 1; + tabIdToLoadCountMap.set(tabId, count); + if ( count >= maxLoadCount ) { + tabIdToDiscard.add(tabId); + } + } + + if ( maxEntryCount !== 0 ) { + if ( bufferedTabId !== tabId ) { + if ( bufferedEntryCount !== 0 ) { + tabIdToEntryCountMap.set(bufferedTabId, bufferedEntryCount); + } + bufferedTabId = tabId; + bufferedEntryCount = tabIdToEntryCountMap.get(tabId) || 0; + } + bufferedEntryCount += 1; + if ( bufferedEntryCount >= maxEntryCount ) { + tabIdToDiscard.add(bufferedTabId); + } + } + + // Since entries in the logger are chronologically ordered, + // everything below obsolete is to be discarded. + if ( obsolete !== 0 && entry.tstamp <= obsolete ) { + if ( idel === -1 ) { idel = i; } + break; + } + + if ( idel !== -1 ) { + loggerEntries.copyWithin(idel, i); + loggerEntries.length -= i - idel; + idel = -1; + modified = true; + } + + i += 1; + } + + if ( idel !== -1 ) { + loggerEntries.length = idel; + modified = true; + } + + if ( i >= loggerEntries.length ) { i = 0; } + rowIndex = i; + + if ( rowIndex === 0 ) { + tabIdToDiscard.clear(); + tabIdToLoadCountMap.clear(); + tabIdToEntryCountMap.clear(); + } + + if ( modified === false ) { return; } + + rowFilterer.filterAll(); + }; + + const discardAsync = function(deadline) { + if ( deadline ) { + discard(deadline); + } + janitorTimer.onidle(1889); + }; + + const janitorTimer = vAPI.defer.create(discardAsync); + + // Clear voided entries from the logger's visible content. + // + // Voided entries should be visible only from the "All" option of the + // tab selector. + // + const clean = function() { + if ( filteredLoggerEntries.length === 0 ) { return; } + + let j = 0; + let targetEntry = filteredLoggerEntries[0]; + for ( const entry of loggerEntries ) { + if ( entry !== targetEntry ) { continue; } + if ( entry.voided ) { + entry.dead = true; + } + j += 1; + if ( j === filteredLoggerEntries.length ) { break; } + targetEntry = filteredLoggerEntries[j]; + } + rowFilterer.filterAll(); + }; + + // Clear the logger's visible content. + // + // "Unrelated" entries -- shown for convenience -- will be also cleared + // if and only if the filtered logger content is made entirely of unrelated + // entries. In effect, this means clicking a second time on the eraser will + // cause unrelated entries to also be cleared. + // + const clear = function() { + if ( filteredLoggerEntries.length === 0 ) { return; } + + let clearUnrelated = true; + if ( selectedTabId !== 0 ) { + for ( const entry of filteredLoggerEntries ) { + if ( entry.tabId === selectedTabId ) { + clearUnrelated = false; + break; + } + } + } + + let j = 0; + let targetEntry = filteredLoggerEntries[0]; + for ( const entry of loggerEntries ) { + if ( entry !== targetEntry ) { continue; } + if ( entry.tabId === selectedTabId || clearUnrelated ) { + entry.dead = true; + } + j += 1; + if ( j === filteredLoggerEntries.length ) { break; } + targetEntry = filteredLoggerEntries[j]; + } + rowFilterer.filterAll(); + }; + + discardAsync(); + + dom.on('#clean', 'click', clean); + dom.on('#clear', 'click', clear); + + return { + inserted: function(count) { + if ( rowIndex !== 0 ) { + rowIndex += count; + } + }, + }; +})(); + +/******************************************************************************/ + +const pauseNetInspector = function() { + netInspectorPaused = dom.cl.toggle('#netInspector', 'paused'); +}; + +/******************************************************************************/ + +const toggleVCompactView = function() { + dom.cl.toggle('#netInspector .vCompactToggler', 'vExpanded'); + viewPort.updateLayout(); +}; + +/******************************************************************************/ + +const popupManager = (( ) => { + let realTabId = 0; + let popup = null; + let popupObserver = null; + + const resizePopup = function() { + if ( popup === null ) { return; } + const popupBody = popup.contentWindow.document.body; + if ( popupBody.clientWidth !== 0 && popup.clientWidth !== popupBody.clientWidth ) { + popup.style.setProperty('width', popupBody.clientWidth + 'px'); + } + if ( popupBody.clientHeight !== 0 && popup.clientHeight !== popupBody.clientHeight ) { + popup.style.setProperty('height', popupBody.clientHeight + 'px'); + } + }; + + const onLoad = function() { + resizePopup(); + popupObserver.observe(popup.contentDocument.body, { + subtree: true, + attributes: true + }); + }; + + const setTabId = function(tabId) { + if ( popup === null ) { return; } + dom.attr(popup, 'src', `popup-fenix.html?portrait=1&tabId=${tabId}`); + }; + + const onTabIdChanged = function() { + const tabId = tabIdFromPageSelector(); + if ( tabId === 0 ) { return toggleOff(); } + realTabId = tabId; + setTabId(realTabId); + }; + + const toggleOn = function() { + const tabId = tabIdFromPageSelector(); + if ( tabId === 0 ) { return; } + realTabId = tabId; + + popup = qs$('#popupContainer'); + + dom.on(popup, 'load', onLoad); + popupObserver = new MutationObserver(resizePopup); + + const parent = qs$('#inspectors'); + const rect = parent.getBoundingClientRect(); + popup.style.setProperty('right', `${rect.right - parent.clientWidth}px`); + dom.cl.add(parent, 'popupOn'); + + dom.on(document, 'tabIdChanged', onTabIdChanged); + + setTabId(realTabId); + dom.cl.add('#showpopup', 'active'); + }; + + const toggleOff = function() { + dom.cl.remove('#showpopup', 'active'); + dom.off(document, 'tabIdChanged', onTabIdChanged); + dom.cl.remove('#inspectors', 'popupOn'); + dom.off(popup, 'load', onLoad); + popupObserver.disconnect(); + popupObserver = null; + dom.attr(popup, 'src', ''); + + realTabId = 0; + }; + + const api = { + get tabId() { return realTabId || 0; }, + toggleOff: function() { + if ( realTabId !== 0 ) { + toggleOff(); + } + } + }; + + dom.on('#showpopup', 'click', ( ) => { + void (realTabId === 0 ? toggleOn() : toggleOff()); + }); + + return api; +})(); + +/******************************************************************************/ + +// Filter hit stats' MVP ("minimum viable product") +// +const loggerStats = (( ) => { + const enabled = false; + const filterHits = new Map(); + let dialog; + let timer; + const makeRow = function() { + const div = document.createElement('div'); + div.appendChild(document.createElement('span')); + div.appendChild(document.createElement('span')); + return div; + }; + + const fillRow = function(div, entry) { + div.children[0].textContent = entry[1].toLocaleString(); + div.children[1].textContent = entry[0]; + }; + + const updateList = function() { + const sortedHits = Array.from(filterHits).sort((a, b) => { + return b[1] - a[1]; + }); + + const doc = document; + const parent = qs$(dialog, '.sortedEntries'); + let i = 0; + + // Reuse existing rows + for ( let iRow = 0; iRow < parent.childElementCount; iRow++ ) { + if ( i === sortedHits.length ) { break; } + fillRow(parent.children[iRow], sortedHits[i]); + i += 1; + } + + // Append new rows + if ( i < sortedHits.length ) { + const list = doc.createDocumentFragment(); + for ( ; i < sortedHits.length; i++ ) { + const div = makeRow(); + fillRow(div, sortedHits[i]); + list.appendChild(div); + } + parent.appendChild(list); + } + + // Remove extraneous rows + // [Should never happen at this point in this current + // bare-bone implementation] + }; + + const toggleOn = function() { + dialog = modalDialog.create( + '#loggerStatsDialog', + ( ) => { + dialog = undefined; + if ( timer !== undefined ) { + self.cancelIdleCallback(timer); + timer = undefined; + } + } + ); + updateList(); + modalDialog.show(); + }; + + dom.on('#loggerStats', 'click', toggleOn); + + return { + processFilter: function(filter) { + if ( enabled !== true ) { return; } + if ( filter.source !== 'static' && filter.source !== 'cosmetic' ) { + return; + } + filterHits.set(filter.raw, (filterHits.get(filter.raw) || 0) + 1); + if ( dialog === undefined || timer !== undefined ) { return; } + timer = self.requestIdleCallback( + ( ) => { + timer = undefined; + updateList(); + }, + { timeout: 2001 } + ); + } + }; +})(); + +/******************************************************************************/ + +(( ) => { + const lines = []; + const options = { + format: 'list', + encoding: 'markdown', + time: 'anonymous', + }; + let dialog; + + const collectLines = function() { + lines.length = 0; + let t0 = filteredLoggerEntries.length !== 0 + ? filteredLoggerEntries[filteredLoggerEntries.length - 1].tstamp + : 0; + for ( const entry of filteredLoggerEntries ) { + const text = entry.textContent; + const fields = []; + let i = 0; + let beg = text.indexOf('\t'); + if ( beg === 0 ) { continue; } + let timeField = text.slice(0, beg); + if ( options.time === 'anonymous' ) { + timeField = '+' + Math.round((entry.tstamp - t0) / 1000).toString(); + } + fields.push(timeField); + beg += 1; + while ( beg < text.length ) { + let end = text.indexOf('\t', beg); + if ( end === -1 ) { end = text.length; } + fields.push(text.slice(beg, end)); + beg = end + 1; + i += 1; + } + lines.push(fields); + } + }; + + const formatAsPlainTextTable = function() { + const outputAll = []; + for ( const fields of lines ) { + outputAll.push(fields.join('\t')); + } + outputAll.push(''); + return outputAll.join('\n'); + }; + + const formatAsMarkdownTable = function() { + const outputAll = []; + let fieldCount = 0; + for ( const fields of lines ) { + if ( fields.length <= 2 ) { continue; } + if ( fields.length > fieldCount ) { + fieldCount = fields.length; + } + const outputOne = []; + for ( let i = 0; i < fields.length; i++ ) { + const field = fields[i]; + let code = /\b(?:www\.|https?:\/\/)/.test(field) ? '`' : ''; + outputOne.push(` ${code}${field.replace(/\|/g, '\\|')}${code} `); + } + outputAll.push(outputOne.join('|')); + } + if ( fieldCount !== 0 ) { + outputAll.unshift( + `${' |'.repeat(fieldCount-1)} `, + `${':--- |'.repeat(fieldCount-1)}:--- ` + ); + } + return `<details><summary>Logger output</summary>\n\n|${outputAll.join('|\n|')}|\n</details>\n`; + }; + + const formatAsTable = function() { + if ( options.encoding === 'plain' ) { + return formatAsPlainTextTable(); + } + return formatAsMarkdownTable(); + }; + + const formatAsList = function() { + const outputAll = []; + for ( const fields of lines ) { + const outputOne = []; + for ( let i = 0; i < fields.length; i++ ) { + let str = fields[i]; + if ( str.length === 0 ) { continue; } + outputOne.push(str); + } + outputAll.push(outputOne.join('\n')); + } + let before, between, after; + if ( options.encoding === 'markdown' ) { + const code = '```'; + before = `<details><summary>Logger output</summary>\n\n${code}\n`; + between = `\n${code}\n${code}\n`; + after = `\n${code}\n</details>\n`; + } else { + before = ''; + between = '\n\n'; + after = '\n'; + } + return `${before}${outputAll.join(between)}${after}`; + }; + + const format = function() { + const output = qs$(dialog, '.output'); + if ( options.format === 'list' ) { + output.textContent = formatAsList(); + } else { + output.textContent = formatAsTable(); + } + }; + + const setRadioButton = function(group, value) { + if ( options.hasOwnProperty(group) === false ) { return; } + const groupEl = qs$(dialog, `[data-radio="${group}"]`); + const buttonEls = qsa$(groupEl, '[data-radio-item]'); + for ( const buttonEl of buttonEls ) { + dom.cl.toggle( + buttonEl, + 'on', + dom.attr(buttonEl, 'data-radio-item') === value + ); + } + options[group] = value; + }; + + const onOption = function(ev) { + const target = ev.target.closest('span[data-i18n]'); + if ( target === null ) { return; } + + // Copy to clipboard + if ( target.matches('.pushbutton') ) { + const textarea = qs$(dialog, 'textarea'); + textarea.focus(); + if ( textarea.selectionEnd === textarea.selectionStart ) { + textarea.select(); + } + document.execCommand('copy'); + ev.stopPropagation(); + return; + } + + // Radio buttons + const group = target.closest('[data-radio]'); + if ( group === null ) { return; } + if ( target.matches('span.on') ) { return; } + const item = target.closest('[data-radio-item]'); + if ( item === null ) { return; } + setRadioButton( + dom.attr(group, 'data-radio'), + dom.attr(item, 'data-radio-item') + ); + format(); + ev.stopPropagation(); + }; + + const toggleOn = function() { + dialog = modalDialog.create( + '#loggerExportDialog', + ( ) => { + dialog = undefined; + lines.length = 0; + } + ); + + setRadioButton('format', options.format); + setRadioButton('encoding', options.encoding); + + collectLines(); + format(); + + dom.on(qs$(dialog, '.options'), 'click', onOption, { capture: true }); + + modalDialog.show(); + }; + + dom.on('#loggerExport', 'click', toggleOn); +})(); + +/******************************************************************************/ + +// TODO: +// - Give some thoughts to: +// - an option to discard immediately filtered out new entries +// - max entry count _per load_ +// +const loggerSettings = (( ) => { + const settings = { + discard: { + maxAge: 240, // global + maxEntryCount: 2000, // per-tab + maxLoadCount: 20, // per-tab + }, + columns: [ true, true, true, true, true, true, true, true, true ], + linesPerEntry: 4, + }; + + vAPI.localStorage.getItemAsync('loggerSettings').then(value => { + try { + const stored = JSON.parse(value); + if ( typeof stored.discard.maxAge === 'number' ) { + settings.discard.maxAge = stored.discard.maxAge; + } + if ( typeof stored.discard.maxEntryCount === 'number' ) { + settings.discard.maxEntryCount = stored.discard.maxEntryCount; + } + if ( typeof stored.discard.maxLoadCount === 'number' ) { + settings.discard.maxLoadCount = stored.discard.maxLoadCount; + } + if ( typeof stored.linesPerEntry === 'number' ) { + settings.linesPerEntry = stored.linesPerEntry; + } + if ( Array.isArray(stored.columns) ) { + settings.columns = stored.columns; + } + } catch(ex) { + } + }); + + const valueFromInput = function(input, def) { + let value = parseInt(input.value, 10); + if ( isNaN(value) ) { value = def; } + const min = parseInt(dom.attr(input, 'min'), 10); + if ( isNaN(min) === false ) { + value = Math.max(value, min); + } + const max = parseInt(dom.attr(input, 'max'), 10); + if ( isNaN(max) === false ) { + value = Math.min(value, max); + } + return value; + }; + + const toggleOn = function() { + const dialog = modalDialog.create( + '#loggerSettingsDialog', + dialog => { + toggleOff(dialog); + } + ); + + // Number inputs + let inputs = qsa$(dialog, 'input[type="number"]'); + inputs[0].value = settings.discard.maxAge; + inputs[1].value = settings.discard.maxLoadCount; + inputs[2].value = settings.discard.maxEntryCount; + inputs[3].value = settings.linesPerEntry; + dom.on(inputs[3], 'input', ev => { + settings.linesPerEntry = valueFromInput(ev.target, 4); + viewPort.updateLayout(); + }); + + // Column checkboxs + const onColumnChanged = ev => { + const input = ev.target; + const i = parseInt(dom.attr(input, 'data-column'), 10); + settings.columns[i] = input.checked !== true; + viewPort.updateLayout(); + }; + inputs = qsa$(dialog, 'input[type="checkbox"][data-column]'); + for ( const input of inputs ) { + const i = parseInt(dom.attr(input, 'data-column'), 10); + input.checked = settings.columns[i] === false; + dom.on(input, 'change', onColumnChanged); + } + + modalDialog.show(); + }; + + const toggleOff = function(dialog) { + // Number inputs + let inputs = qsa$(dialog, 'input[type="number"]'); + settings.discard.maxAge = valueFromInput(inputs[0], 240); + settings.discard.maxLoadCount = valueFromInput(inputs[1], 25); + settings.discard.maxEntryCount = valueFromInput(inputs[2], 2000); + settings.linesPerEntry = valueFromInput(inputs[3], 4); + + // Column checkboxs + inputs = qsa$(dialog, 'input[type="checkbox"][data-column]'); + for ( const input of inputs ) { + const i = parseInt(dom.attr(input, 'data-column'), 10); + settings.columns[i] = input.checked !== true; + } + + vAPI.localStorage.setItem( + 'loggerSettings', + JSON.stringify(settings) + ); + + viewPort.updateLayout(); + }; + + dom.on('#loggerSettings', 'click', toggleOn); + + return settings; +})(); + +/******************************************************************************/ + +logger.resize = (function() { + let timer; + + const resize = function() { + const vrect = dom.body.getBoundingClientRect(); + for ( const elem of qsa$('.vscrollable') ) { + const crect = elem.getBoundingClientRect(); + const dh = crect.bottom - vrect.bottom; + if ( dh === 0 ) { continue; } + elem.style.height = Math.ceil(crect.height - dh) + 'px'; + } + }; + + const resizeAsync = function() { + if ( timer !== undefined ) { return; } + timer = self.requestAnimationFrame(( ) => { + timer = undefined; + resize(); + }); + }; + + resizeAsync(); + + dom.on(window, 'resize', resizeAsync, { passive: true }); + + return resizeAsync; +})(); + +/******************************************************************************/ + +const grabView = function() { + if ( logger.ownerId === undefined ) { + logger.ownerId = Date.now(); + } + readLogBuffer(); +}; + +const releaseView = function() { + if ( logger.ownerId === undefined ) { return; } + vAPI.messaging.send('loggerUI', { + what: 'releaseView', + ownerId: logger.ownerId, + }); + logger.ownerId = undefined; +}; + +dom.on(window, 'pagehide', releaseView); +dom.on(window, 'pageshow', grabView); +// https://bugzilla.mozilla.org/show_bug.cgi?id=1398625 +dom.on(window, 'beforeunload', releaseView); + +/******************************************************************************/ + +dom.on('#pageSelector', 'change', pageSelectorChanged); +dom.on('#netInspector .vCompactToggler', 'click', toggleVCompactView); +dom.on('#pause', 'click', pauseNetInspector); + +// https://github.com/gorhill/uBlock/issues/507 +// Ensure tab selector is in sync with URL hash +pageSelectorFromURLHash(); +dom.on(window, 'hashchange', pageSelectorFromURLHash); + +// Start to watch the current window geometry 2 seconds after the document +// is loaded, to be sure no spurious geometry changes will be triggered due +// to the window geometry pontentially not settling fast enough. +if ( self.location.search.includes('popup=1') ) { + dom.on(window, 'load', ( ) => { + vAPI.defer.once(2000).then(( ) => { + popupLoggerBox = { + x: self.screenX, + y: self.screenY, + w: self.outerWidth, + h: self.outerHeight, + }; + }); + }, { once: true }); +} + +/******************************************************************************/ |