From 9bcad432cfd32439024bd235837bf4b19b640699 Mon Sep 17 00:00:00 2001 From: gorhill Date: Mon, 6 Oct 2014 14:02:44 -0400 Subject: [PATCH] dynamic filtering --- css/popup.css | 110 +++++++++++++++++++- js/background.js | 2 + js/messaging-handlers.js | 37 +++++-- js/net-filtering.js | 214 +++++++++++++++++++++++++++++++++++++-- js/pagestore.js | 8 ++ js/popup.js | 74 +++++++++++++- js/storage.js | 3 +- js/traffic.js | 7 +- js/ublock.js | 15 ++- js/xal.js | 8 ++ popup.html | 22 ++++ 11 files changed, 476 insertions(+), 24 deletions(-) diff --git a/css/popup.css b/css/popup.css index d5e16c165..2ef52c730 100644 --- a/css/popup.css +++ b/css/popup.css @@ -5,7 +5,7 @@ body { font: 13px sans-serif; background-color: white; direction: __MSG_@@bidi_dir__; - min-width: 160px; + min-width: 180px; } h1,h2,h3,h4 { margin: 0; @@ -58,6 +58,7 @@ p { } #total-blocked { margin-top: 4px; + margin-bottom: 8px; font-size: 14px; } #stats { @@ -76,3 +77,110 @@ p { .tool:hover { color: #444; } + +#dynamicFilteringToggler { + margin: 0; + border: 0; + padding: 0; + width: 100vw; + text-align: center; + cursor: pointer; + } +#dynamicFilteringToggler::after { + content: '\f107'; + } +#dynamicFilteringToggler.on::after { + content: '\f106'; + } +#dynamicFilteringContainer { + margin: 0; + border: 0; + padding: 0; + display: none; + } +#dynamicFilteringToggler.on + #dynamicFilteringContainer { + display: initial; + } +.dynamicFiltering { + margin: 0; + border: 0; + padding: 0; + height: 2.75em; + position: relative; + } +.dynamicFiltering.local { + border-bottom: 1px solid white; + } +.dynamicFiltering.global { + height: 1.25em; + } +.dynamicFiltering > div { + margin: 0; + border: 1px solid #eee; + padding: 0; + box-sizing: border-box; + background-color: #eee; + position: absolute; + cursor: pointer; + } +.dynamicFiltering > div:not(:first-child) { + border-left: 1px solid white !important; + } +.dynamicFiltering > div:nth-of-type(1) { + left: 0; + width: 7vw; + height: 100%; + } +.dynamicFiltering > div:nth-of-type(2) { + left: 7vw; + width: 18vw; + height: 100%; + } +.dynamicFiltering > div:nth-of-type(3) { + left: 25vw; + width: 25vw; + height: 100%; + } +.dynamicFiltering > div:nth-of-type(4) { + left: 50vw; + width: 25vw; + height: 100%; + } +.dynamicFiltering > div:nth-of-type(5) { + left: 75vw; + width: 25vw; + height: 100%; + } +.dynamicFiltering > div:nth-of-type(6) { + left: 0; + width: 50vw; + } +.dynamicFiltering > div:nth-of-type(7) { + left: 50vw; + width: 50vw; + } +.dynamicFiltering > div.label { + margin: 0; + border: 0; + padding: 0; + pointer-events: none; + color: black; + opacity: 0.2; + font: 12px monospace; + text-align: center; + top: 50%; + transform: translateY(-50%); + background-color: transparent; + } +.dynamicFiltering > div.blocked { + border-color: #fdd; + background-color: #fdd; + } +.dynamicFiltering > div.ownFilter { + border-color: #bbb; + background-color: #bbb; + } +.dynamicFiltering > div.blocked.ownFilter { + border-color: #eaa; + background-color: #eaa; + } diff --git a/js/background.js b/js/background.js index a33d68ef6..a0528fb77 100644 --- a/js/background.js +++ b/js/background.js @@ -55,6 +55,8 @@ return { autoUpdate: true, collapseBlocked: true, contextMenuEnabled: true, + dynamicFilteringSelfie: '', + dynamicFilteringEnabled: false, experimentalEnabled: false, externalLists: defaultExternalLists, logRequests: false, diff --git a/js/messaging-handlers.js b/js/messaging-handlers.js index e207bb4fe..9d557ec57 100644 --- a/js/messaging-handlers.js +++ b/js/messaging-handlers.js @@ -34,6 +34,18 @@ var µb = µBlock; /******************************************************************************/ +var getDynamicFilterResults = function(scope) { + return [ + µb.netFilteringEngine.matchDynamicFilters(scope, 'inline-script', true), + µb.netFilteringEngine.matchDynamicFilters(scope, 'script', true), + µb.netFilteringEngine.matchDynamicFilters(scope, 'script', false), + µb.netFilteringEngine.matchDynamicFilters(scope, 'sub_frame', true), + µb.netFilteringEngine.matchDynamicFilters(scope, 'sub_frame', false) + ]; +}; + +/******************************************************************************/ + var getStats = function(request) { var r = { globalBlockedRequestCount: µb.localSettings.blockedRequestCount, @@ -44,7 +56,11 @@ var getStats = function(request) { pageAllowedRequestCount: 0, netFilteringSwitch: false, cosmeticFilteringSwitch: false, - logRequests: µb.userSettings.logRequests + logRequests: µb.userSettings.logRequests, + dynamicFilteringEnabled: µb.userSettings.dynamicFilteringEnabled, + dynamicFilterResults: { + '/': getDynamicFilterResults('*') + } }; var pageStore = µb.pageStoreFromTabId(request.tabId); if ( pageStore ) { @@ -53,6 +69,7 @@ var getStats = function(request) { r.pageBlockedRequestCount = pageStore.perLoadBlockedRequestCount; r.pageAllowedRequestCount = pageStore.perLoadAllowedRequestCount; r.netFilteringSwitch = pageStore.getNetFilteringSwitch(); + r.dynamicFilterResults['.'] = getDynamicFilterResults(r.pageHostname); } return r; }; @@ -70,6 +87,13 @@ var onMessage = function(request, sender, callback) { var response; switch ( request.what ) { + case 'gotoPick': + // Picker launched from popup: clear context menu args + µb.contextMenuClientX = -1; + µb.contextMenuClientY = -1; + µb.elementPickerExec(request.tabId); + break; + case 'stats': response = getStats(request); break; @@ -83,11 +107,12 @@ var onMessage = function(request, sender, callback) { µb.updateBadgeAsync(request.tabId); break; - case 'gotoPick': - // Picker launched from popup: clear context menu args - µb.contextMenuClientX = -1; - µb.contextMenuClientY = -1; - µb.elementPickerExec(request.tabId); + case 'toggleDynamicFilter': + µb.toggleDynamicFilter(request); + response = { '/': getDynamicFilterResults('*') }; + if ( request.pageHostname ) { + response['.'] = getDynamicFilterResults(request.pageHostname); + } break; default: diff --git a/js/net-filtering.js b/js/net-filtering.js index 1d3587a77..3d9666844 100644 --- a/js/net-filtering.js +++ b/js/net-filtering.js @@ -66,18 +66,14 @@ var typeNameToTypeValue = { }; const BlockAnyTypeAnyParty = BlockAction | AnyType | AnyParty; -const BlockAnyType1stParty = BlockAction | AnyType | FirstParty; -const BlockAnyType3rdParty = BlockAction | AnyType | ThirdParty; const BlockAnyType = BlockAction | AnyType; const BlockAnyParty = BlockAction | AnyParty; const AllowAnyTypeAnyParty = AllowAction | AnyType | AnyParty; -const AllowAnyType1stParty = AllowAction | AnyType | FirstParty; -const AllowAnyType3rdParty = AllowAction | AnyType | ThirdParty; const AllowAnyType = AllowAction | AnyType; const AllowAnyParty = AllowAction | AnyParty; -var pageHostname = ''; +var pageHostname = ''; // short-lived register var reIgnoreEmpty = /^\s+$/; var reIgnoreComment = /^\[|^!/; @@ -842,7 +838,7 @@ FilterManyWildcardsHostname.fromSelfie = function(s) { var FilterBucket = function(a, b) { this.promoted = 0; this.vip = 16; - this.f = null; + this.f = null; // short-lived register this.filters = []; if ( a !== undefined ) { this.filters[0] = a; @@ -1107,7 +1103,7 @@ FilterParser.prototype.parseOptType = function(raw, not) { // `popup` is a special type, it cannot be set for filters intended // for real net request types. The test is safe since there is no // such thing as a filter using `~popup`. - if ( typeNameToTypeValue[k] > typeNameToTypeValue['other'] ) { + if ( typeNameToTypeValue[k] > typeNameToTypeValue.other ) { continue; } this.types.push(typeNameToTypeValue[k]); @@ -1296,6 +1292,7 @@ FilterContainer.prototype.reset = function() { this.duplicates = Object.create(null); this.blockedAnyPartyHostnames.reset(); this.blocked3rdPartyHostnames.reset(); + this.dynamicFilters = {}; this.filterParser.reset(); }; @@ -1633,6 +1630,190 @@ FilterContainer.prototype.addToCategory = function(category, tokenKey, filter) { /******************************************************************************/ +// Dynamic filters + +// Bits: +// 0: inline script blocked +// 1: inline script allowed +// 2: first-party script blocked +// 3: first-party script allowed +// 4: third-party script blocked +// 5: third-party script allowed +// 6: first-party frame blocked +// 7: first-party frame allowed +// 8: third-party frame blocked +// 9: third-party frame allowed +// +// I chose separate bits for blocked/unblocked as I want to have an "undefined" +// state, which will be used to inherit from wider-scoped filters. +// +// undefined: 0x00 +// blocked: 0x01 +// allowed: 0x02 +// unused: 0x03 + +FilterContainer.prototype.dynamicFilterBitOffsets = {}; +FilterContainer.prototype.dynamicFilterBitOffsets[FirstParty | typeNameToTypeValue['inline-script']] = 0; +FilterContainer.prototype.dynamicFilterBitOffsets[FirstParty | typeNameToTypeValue.script] = 2; +FilterContainer.prototype.dynamicFilterBitOffsets[ThirdParty | typeNameToTypeValue.script] = 4; +FilterContainer.prototype.dynamicFilterBitOffsets[FirstParty | typeNameToTypeValue.sub_frame] = 6; +FilterContainer.prototype.dynamicFilterBitOffsets[ThirdParty | typeNameToTypeValue.sub_frame] = 8; +FilterContainer.prototype.dynamicFiltersMagicId = 'numrebvoacir'; + +/******************************************************************************/ + +FilterContainer.prototype.dynamicFilterSet = function(hostname, requestType, firstParty, value) { + if ( typeNameToTypeValue.hasOwnProperty(requestType) === false ) { + return false; + } + var party = firstParty ? FirstParty : ThirdParty; + var categoryKey = party | typeNameToTypeValue[requestType]; + if ( this.dynamicFilterBitOffsets.hasOwnProperty(categoryKey) === false ) { + return false; + } + var bitOffset = this.dynamicFilterBitOffsets[categoryKey]; + var oldFilter = this.dynamicFilters[hostname] || 0; + var newFilter = (oldFilter & ~(0x0003 << bitOffset)) | (value << bitOffset); + if ( newFilter === oldFilter ) { + return false; + } + if ( newFilter === 0 ) { + delete this.dynamicFilters[hostname]; + } else { + this.dynamicFilters[hostname] = newFilter; + } + return true; +}; + +/******************************************************************************/ + +FilterContainer.prototype.dynamicFilterBlock = function(hostname, requestType, firstParty) { + if ( typeof hostname !== 'string' || hostname === '' ) { + return false; + } + var result = this.matchDynamicFilters(hostname, requestType, firstParty); + if ( result !== '' && result.slice(0, 2) !== '@@' ) { + return false; + } + this.dynamicFilterSet(hostname, requestType, firstParty, 0x00); + result = this.matchDynamicFilters(hostname, requestType, firstParty); + if ( result !== '' && result.slice(0, 2) !== '@@' ) { + return true; + } + this.dynamicFilterSet(hostname, requestType, firstParty, 0x01); + return true; +}; + +/******************************************************************************/ + +FilterContainer.prototype.dynamicFilterUnblock = function(hostname, requestType, firstParty) { + if ( typeof hostname !== 'string' || hostname === '' ) { + return false; + } + var result = this.matchDynamicFilters(hostname, requestType, firstParty); + if ( result === '' || result.slice(0, 2) === '@@' ) { + return false; + } + this.dynamicFilterSet(hostname, requestType, firstParty, 0x00); + result = this.matchDynamicFilters(hostname, requestType, firstParty); + if ( result === '' || result.slice(0, 2) === '@@' ) { + return true; + } + this.dynamicFilterSet(hostname, requestType, firstParty, 0x02); + return true; +}; + +/******************************************************************************/ + +FilterContainer.prototype.dynamicFilterStateToType = { + 0x0001: '', + 0x0002: '@@', + 0x0004: '', + 0x0008: '@@', + 0x0010: '', + 0x0020: '@@', + 0x0040: '', + 0x0080: '@@', + 0x0100: '', + 0x0200: '@@' +}; + +FilterContainer.prototype.dynamicFilterStateToOption = { + 0x0001: '$inline-script,important', + 0x0002: '$inline-script', + 0x0004: '$~third-party,script,important', + 0x0008: '$~third-party,script', + 0x0010: '$third-party,script,important', + 0x0020: '$third-party,script', + 0x0040: '$~third-party,subdocument,important', + 0x0080: '$~third-party,subdocument', + 0x0100: '$third-party,subdocument,important', + 0x0200: '$third-party,subdocument' +}; + +FilterContainer.prototype.matchDynamicFilters = function(hostname, requestType, firstParty) { + var party = firstParty ? FirstParty : ThirdParty; + if ( typeNameToTypeValue.hasOwnProperty(requestType) === false ) { + return ''; + } + var categoryKey = party | typeNameToTypeValue[requestType]; + if ( this.dynamicFilterBitOffsets.hasOwnProperty(categoryKey) === false ) { + return ''; + } + var bitOffset = this.dynamicFilterBitOffsets[categoryKey]; + var bitMask = 0x0003 << bitOffset; + var bitState, pos; + for ( ;; ) { + if ( this.dynamicFilters.hasOwnProperty(hostname) !== false ) { + bitState = this.dynamicFilters[hostname] & bitMask; + if ( bitState !== 0 ) { + if ( hostname !== '*' ) { + hostname = '||' + hostname + '^'; + } + return this.dynamicFilterStateToType[bitState] + + hostname + + this.dynamicFilterStateToOption[bitState]; + } + } + pos = hostname.indexOf('.'); + if ( pos === -1 ) { + if ( hostname === '*' ) { + return ''; + } + hostname = '*'; + } else { + hostname = hostname.slice(pos + 1); + } + } + // unreachable +}; + +/******************************************************************************/ + + +FilterContainer.prototype.selfieFromDynamicFilters = function() { + var bin = { + magicId: this.dynamicFiltersMagicId, + filters: this.dynamicFilters + }; + return JSON.stringify(bin); +}; + +/******************************************************************************/ + +FilterContainer.prototype.dynamicFiltersFromSelfie = function(selfie) { + if ( selfie === '' ) { + return; + } + var bin = JSON.parse(selfie); + if ( bin.magicId !== this.dynamicFiltersMagicId ) { + return; + } + this.dynamicFilters = bin.filters; +}; + +/******************************************************************************/ + // Since the addition of the `important` evaluation, this means it is now // likely that the url will have to be scanned more than once. So this is // to ensure we do it once only, and reuse results. @@ -1766,6 +1947,14 @@ FilterContainer.prototype.matchStringExactType = function(pageDetails, requestUR var party = requestHostname.slice(-pageDomain.length) === pageDomain ? FirstParty : ThirdParty; + + // Evaluate dynamic filters first. "Block" dynamic filters are always + // "important", they override everything else. + var bf = this.matchDynamicFilters(requestHostname, requestType, party === FirstParty); + if ( bf !== '' && bf.slice(0, 2) !== '@@' ) { + return bf; + } + var type = typeNameToTypeValue[requestType]; var categories = this.categories; var buckets = this.buckets; @@ -1783,7 +1972,7 @@ FilterContainer.prototype.matchStringExactType = function(pageDetails, requestUR // Test against important block filters buckets[2] = categories[this.makeCategoryKey(BlockAnyParty | Important | type)]; buckets[3] = categories[this.makeCategoryKey(BlockAction | Important | type | party)]; - var bf = this.matchTokens(url); + bf = this.matchTokens(url); if ( bf !== false ) { return bf.toString(); } @@ -1851,6 +2040,13 @@ FilterContainer.prototype.matchString = function(pageDetails, requestURL, reques } } + // Evaluate dynamic filters first. "Block" dynamic filters are always + // "important", they override everything else. + var bf = this.matchDynamicFilters(requestHostname, requestType, party === FirstParty); + if ( bf !== '' && bf.slice(0, 2) !== '@@' ) { + return bf; + } + // This will be used by hostname-based filters pageHostname = pageDetails.pageHostname || ''; @@ -1870,7 +2066,7 @@ FilterContainer.prototype.matchString = function(pageDetails, requestURL, reques buckets[1] = categories[this.makeCategoryKey(BlockAnyType | Important | party)]; buckets[2] = categories[this.makeCategoryKey(BlockAnyParty | Important | type)]; buckets[3] = categories[this.makeCategoryKey(BlockAction | Important | type | party)]; - var bf = this.matchTokens(url); + bf = this.matchTokens(url); if ( bf !== false ) { return bf.toString() + '$important'; } diff --git a/js/pagestore.js b/js/pagestore.js index 401d78fd8..fc8c36bd7 100644 --- a/js/pagestore.js +++ b/js/pagestore.js @@ -425,6 +425,14 @@ PageStore.prototype.setRequestFlags = function(requestURL, targetBits, valueBits /******************************************************************************/ +PageStore.prototype.recordResult = function(requestType, requestURL, result) { + if ( collapsibleRequestTypes.indexOf(requestType) !== -1 || µb.userSettings.logRequests ) { + this.netFilteringCache.add(requestURL, result, requestType, 0); + } +}; + +/******************************************************************************/ + // false: not blocked // true: blocked diff --git a/js/popup.js b/js/popup.js index d985ff810..427e7d6ed 100644 --- a/js/popup.js +++ b/js/popup.js @@ -29,6 +29,7 @@ /******************************************************************************/ var stats; +var reResultParser = /^(@@)?(\*|\|\|([^$^]+)\^)\$(.+)$/; /******************************************************************************/ @@ -47,6 +48,34 @@ var formatNumber = function(count) { /******************************************************************************/ +var syncDynamicFilter = function(scope, i, result) { + var el = uDom('[data-scope="' + scope + '"] > div:nth-of-type(' + i + ')'); + var matches = reResultParser.exec(result) || []; + var blocked = matches.length !== 0 && matches[1] !== '@@'; + el.toggleClass('blocked', blocked); + var ownFilter = matches[3] !== undefined && matches[3] === stats.pageHostname; + el.toggleClass('ownFilter', ownFilter); +}; + +/******************************************************************************/ + +var syncAllDynamicFilters = function() { + var scopes = ['.', '/']; + var scope, results, i; + while ( scope = scopes.pop() ) { + if ( stats.dynamicFilterResults.hasOwnProperty(scope) === false ) { + continue; + } + results = stats.dynamicFilterResults[scope]; + i = 5; + while ( i-- ) { + syncDynamicFilter(scope, i + 1, results[i]); + } + } +}; + +/******************************************************************************/ + var renderStats = function() { if ( !stats ) { return; @@ -102,12 +131,12 @@ var renderStats = function() { '%' ); } - uDom('#total-blocked').html(html.join('')); - uDom('#switch .fa').toggleClass( - 'off', - stats.pageURL === '' || !stats.netFilteringSwitch - ); + syncAllDynamicFilters(); + + uDom('#total-blocked').html(html.join('')); + uDom('#switch .fa').toggleClass('off', stats.pageURL === '' || !stats.netFilteringSwitch); + uDom('#dynamicFilteringToggler').toggleClass('on', stats.dynamicFilteringEnabled); }; /******************************************************************************/ @@ -185,11 +214,46 @@ var renderHeader = function() { /******************************************************************************/ +var onDynamicFilterClicked = function(ev) { + var elScope = uDom(ev.currentTarget); + var scope = elScope.attr('data-scope') === '/' ? '*' : stats.pageHostname; + var elFilter = uDom(ev.target); + var onDynamicFilterChanged = function(details) { + stats.dynamicFilterResults = details; + syncAllDynamicFilters(); + }; + messaging.ask({ + what: 'toggleDynamicFilter', + hostname: scope, + requestType: elFilter.attr('data-type'), + firstParty: elFilter.attr('data-first-party') !== null, + block: elFilter.hasClassName('blocked') === false, + pageHostname: stats.pageHostname + }, onDynamicFilterChanged); + +}; + +/******************************************************************************/ + +var toggleDynamicFiltering = function() { + var el = uDom('#dynamicFilteringToggler'); + el.toggleClass('on'); + messaging.tell({ + what: 'userSettings', + name: 'dynamicFilteringEnabled', + value: el.hasClassName('on') + }); +}; + +/******************************************************************************/ + var installEventHandlers = function() { uDom('h1,h2,h3,h4').on('click', gotoDashboard); uDom('#switch .fa').on('click', toggleNetFilteringSwitch); uDom('#gotoLog').on('click', gotoStats); uDom('#gotoPick').on('click', gotoPick); + uDom('#dynamicFilteringToggler').on('click', toggleDynamicFiltering); + uDom('.dynamicFiltering').on('click', 'div', onDynamicFilterClicked); }; /******************************************************************************/ diff --git a/js/storage.js b/js/storage.js index 260027d4a..c6b69de4e 100644 --- a/js/storage.js +++ b/js/storage.js @@ -661,13 +661,14 @@ µb.loadWhitelist(onWhitelistReady); return; } - µb.assets.autoUpdate = µb.userSettings.autoUpdate; µb.loadPublicSuffixList(onPSLReady); }; // User settings are in memory var onUserSettingsReady = function(settings) { + µb.assets.autoUpdate = settings.autoUpdate; µb.contextMenu.toggle(settings.contextMenuEnabled); + µb.netFilteringEngine.dynamicFiltersFromSelfie(settings.dynamicFilteringSelfie); µb.fromSelfie(onSelfieReady); µb.mirrors.toggle(settings.experimentalEnabled); }; diff --git a/js/traffic.js b/js/traffic.js index ce8e0ee06..36e9dbcc4 100644 --- a/js/traffic.js +++ b/js/traffic.js @@ -320,6 +320,11 @@ var onHeadersReceived = function(details) { return; } + // Record request + if ( result !== '' ) { + pageStore.recordResult('script', details.url, result); + } + // Blocked pageStore.perLoadBlockedRequestCount++; µb.localSettings.blockedRequestCount++; @@ -327,7 +332,7 @@ var onHeadersReceived = function(details) { details.responseHeaders.push({ 'name': 'Content-Security-Policy', - 'value': "script-src *" + 'value': "script-src 'unsafe-eval' *" }); return { 'responseHeaders': details.responseHeaders }; diff --git a/js/ublock.js b/js/ublock.js index 47eb7edea..4a74c0b3a 100644 --- a/js/ublock.js +++ b/js/ublock.js @@ -238,7 +238,7 @@ return type; } var ext = path.slice(pos) + '.'; - if ( '.eot.ttf.otf.svg.woff.woff2.'.indexOf(ext) !== -1 ) { + if ( '.css.eot.ttf.otf.svg.woff.woff2.'.indexOf(ext) !== -1 ) { return 'stylesheet'; } if ( '.ico.png.gif.jpg.jpeg.'.indexOf(ext) !== -1 ) { @@ -255,3 +255,16 @@ }; /******************************************************************************/ + +µBlock.toggleDynamicFilter = function(details) { + var changed = false; + if ( details.block ) { + changed = this.netFilteringEngine.dynamicFilterBlock(details.hostname, details.requestType, details.firstParty); + } else { + changed = this.netFilteringEngine.dynamicFilterUnblock(details.hostname, details.requestType, details.firstParty); + } + if ( changed ) { + this.userSettings.dynamicFilteringSelfie = this.netFilteringEngine.selfieFromDynamicFilters(); + this.XAL.keyValSetOne('dynamicFilteringSelfie', this.userSettings.dynamicFilteringSelfie); + } +}; diff --git a/js/xal.js b/js/xal.js index 2efb07b7f..1a1ef1837 100644 --- a/js/xal.js +++ b/js/xal.js @@ -59,6 +59,14 @@ exports.injectScript = function(id, details) { /******************************************************************************/ +exports.keyValSetOne = function(key, val) { + var bin = {}; + bin[key] = val; + chrome.storage.local.set(bin); +}; + +/******************************************************************************/ + return exports; /******************************************************************************/ diff --git a/popup.html b/popup.html index b15e3a806..9ea4b5977 100644 --- a/popup.html +++ b/popup.html @@ -24,6 +24,28 @@

?

+
+ +
+
+
+
+
+
+
+
<script>
+
<iframe>
+
+
+
+
+
+
+
+
<script>
+
<iframe>
+
+