From b9ff58a5c8a3afba3d6b4ccf1cd47d73170108da Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 24 Feb 2022 20:38:59 -0500 Subject: [PATCH 1/5] Switch to Manifest V3. --- manifest.json | 11 +- src/content.js | 168 +++++++++++-------- src/eventPage.js | 347 +++++++++++---------------------------- src/options/options.html | 2 + src/options/options.js | 116 +++++++------ src/shared.js | 104 ++++++++++++ src/utils.js | 52 ------ 7 files changed, 355 insertions(+), 445 deletions(-) create mode 100644 src/shared.js delete mode 100644 src/utils.js diff --git a/manifest.json b/manifest.json index 8f5f055..08bbb16 100644 --- a/manifest.json +++ b/manifest.json @@ -5,13 +5,12 @@ "options_ui": { "page": "src/options/options.html" }, - "permissions": ["activeTab", "contextMenus", "storage"], - "optional_permissions": ["", "tabs", "clipboardWrite"], + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "optional_permissions": ["tabs", "clipboardWrite"], "background": { - "scripts": ["src/utils.js", "src/matchPattern.js", "src/eventPage.js"], - "persistent": true + "service_worker": "src/eventPage.js" }, - "browser_action": { + "action": { "default_icon": { "19": "icons/0highlight19x19.png", "38": "icons/0highlight38x38.png" @@ -23,5 +22,5 @@ "48": "icons/48x48.png", "128": "icons/128x128.png" }, - "manifest_version": 2 + "manifest_version": 3 } diff --git a/src/content.js b/src/content.js index 6360c0f..d88b512 100644 --- a/src/content.js +++ b/src/content.js @@ -69,6 +69,24 @@ const getDocumentWindow = function() { return closure; }(); +/*********************************** + * Utilities + ***********************************/ + +// sets a timeout and ignores exceptions +const setTimeoutIgnore = function() { + const args = []; + for (let i = 0; i < arguments.length; i++) { + args.push(arguments[i]); + } + const fn = args[0]; + const rest = args.slice(1); + setTimeout.apply(null, [function() { + try { + fn(); + } catch(err) {} // ignore errors + }].concat(rest)); +}; /*********************************** * Node Highlighting Functionality @@ -1167,24 +1185,26 @@ const cth = function(highlightState, numHighlightStates) { return _tohighlight; }; -const updateHighlightState = function(highlightState, success) { - chrome.runtime.sendMessage( - { - 'message': 'updateHighlightState', - 'highlight': highlightState, - 'success': success - }); -}; +let curHighlight = 0; +let curSuccess = true; -// callback takes two args: a number indicating highlight state, and -// boolean for success -const getHighlightState = function(callback) { - const message = {'message': 'getHighlightState'}; - chrome.runtime.sendMessage(message, function(response) { - const curHighlight = response['curHighlight']; - const curSuccess = response['curSuccess']; - callback(curHighlight, curSuccess); - }); +const updateHighlightState = function(highlightState, success, callback) { + // null represents 'unknown' + // true should always clobber false (for iframes) + success = (typeof success) === 'undefined' ? null : success; + + // have to check for false. for null we don't want to set to zero. + if (success === false) + highlightState = 0; + + curHighlight = highlightState; + curSuccess = success; + + chrome.runtime.sendMessage({ + 'message': 'updateIcon', + 'highlight': highlightState, + 'success': success + }, callback); }; // useful for debugging sentence boundary detection @@ -1275,63 +1295,64 @@ const highlight = function() { const closure = function(highlightState, options, params, delay) { count += 1; const id = count; - // Even with a delay of 0, an asynchronous call is useful so the icon update is not blocked. - UTILS.setTimeoutIgnore(function() { + if (highlightState === null) + highlightState = (curHighlight + 1) % params['numHighlightStates']; + setTimeoutIgnore(function() { // Only process the request if it's the most recent. if (count !== id) return; const time = (new Date()).getTime(); - updateHighlightState(highlightState, null); // loading - let success = false; - // A try/catch/finally block is used so that a thrown error won't leave the extension - // in a loading state (with the icon indicating so). - try { - removeHighlightAllDocs(); - const scoredCandsToHighlight = []; - if (highlightState > 0) - scoredCandsToHighlight.push(...cth(highlightState, params['numHighlightStates'])); - trimSpaces(scoredCandsToHighlight); - // have to loop backwards since splitting text nodes - for (let i = scoredCandsToHighlight.length - 1; i >= 0; i--) { - let highlightColor = options['highlight_color']; - if (options['tinted_highlights']) { - const importance = scoredCandsToHighlight[i].importance; - // XXX: Ad-hoc formula can be improved. - highlightColor = tintColor(highlightColor, 1.0 - Math.pow(1 / importance, 1.6)); + // update highlight state to null (loading) + updateHighlightState(highlightState, null, function() { + let success = false; + // A try/catch/finally block is used so that a thrown error won't leave the extension + // in a loading state (with the icon indicating so). + try { + removeHighlightAllDocs(); + const scoredCandsToHighlight = []; + if (highlightState > 0) + scoredCandsToHighlight.push(...cth(highlightState, params['numHighlightStates'])); + trimSpaces(scoredCandsToHighlight); + // have to loop backwards since splitting text nodes + for (let i = scoredCandsToHighlight.length - 1; i >= 0; i--) { + let highlightColor = options['highlight_color']; + if (options['tinted_highlights']) { + const importance = scoredCandsToHighlight[i].importance; + // XXX: Ad-hoc formula can be improved. + highlightColor = tintColor(highlightColor, 1.0 - Math.pow(1 / importance, 1.6)); + } + const colorSpec = new ColorSpec( + highlightColor, options['text_color'], options['link_color']); + const candidate = scoredCandsToHighlight[i].candidate; + const c = CYCLE_COLORS ? getNextColor() : colorSpec; + candidate.highlight(c); } - const colorSpec = new ColorSpec( - highlightColor, options['text_color'], options['link_color']); - const candidate = scoredCandsToHighlight[i].candidate; - const c = CYCLE_COLORS ? getNextColor() : colorSpec; - candidate.highlight(c); - } - success = highlightState === 0 || scoredCandsToHighlight.length > 0; - highlightedText = scoredCandsToHighlight.map(function(c) {return c.candidate.text}).join('\n\n'); - } catch (err) { - removeHighlightAllDocs(); - highlightedText = ''; - } finally { - // Before updating highlight state, wait until at least 0.5 seconds has elapsed - // since this function started. This prevents jumpiness of the loading icon. - const state_delay = Math.max(0, 500 - ((new Date()).getTime() - time)); - UTILS.setTimeoutIgnore(function() { - if (count === id) { - updateHighlightState(highlightState, success); - // if we don't have success, turn off icon in 2 seconds - if (!success) { - const turnoffdelay = 2000; - UTILS.setTimeoutIgnore(function() { - getHighlightState(function(curHighlight, curSuccess) { + success = highlightState === 0 || scoredCandsToHighlight.length > 0; + highlightedText = scoredCandsToHighlight.map(function(c) {return c.candidate.text}).join('\n\n'); + } catch (err) { + removeHighlightAllDocs(); + highlightedText = ''; + } finally { + // Before updating highlight state, wait until at least 0.5 seconds has elapsed + // since this function started. This prevents jumpiness of the loading icon. + const state_delay = Math.max(0, 500 - ((new Date()).getTime() - time)); + setTimeoutIgnore(function() { + if (count === id) { + updateHighlightState(highlightState, success); + // if we don't have success, turn off icon in 2 seconds + if (!success) { + const turnoffdelay = 2000; + setTimeoutIgnore(function() { if (curHighlight === 0 && !curSuccess && count === id) { updateHighlightState(0, true); } - }); - }, turnoffdelay); + }, turnoffdelay); + } } - } - }, state_delay); - } + }, state_delay); + } + }); }, delay); }; return closure; @@ -1360,15 +1381,16 @@ chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { // scripts on Firefox. alert('There is no highlighted text.\nUse Auto Highlight prior to copying.'); } else { - // Use execCommand('copy') from the background page, as opposed to using - // navigator.clipboard.writeText from here. This avoids 1) "DOMException: - // Document is not focused" when the developer tools are open and active, - // and 2) the absence of the writeText method when a remote page is not - // served over HTTPS. - chrome.runtime.sendMessage({ - 'message': 'copyText', - 'text': highlightedText - }); + // Use execCommand('copy') as opposed to using navigator.clipboard.writeText + // from here. This avoids 1) "DOMException: Document is not focused" when the + // developer tools are open and active, and 2) the absence of the writeText + // method when a remote page is not served over HTTPS. + const textarea = document.createElement('textarea'); + document.body.append(textarea); + textarea.textContent = highlightedText; + textarea.select(); + document.execCommand('copy'); + textarea.parentNode.removeChild(textarea); } } else if (method === 'ping') { // response is sent below diff --git a/src/eventPage.js b/src/eventPage.js index 97c9b78..0c9a1b0 100644 --- a/src/eventPage.js +++ b/src/eventPage.js @@ -1,121 +1,13 @@ // TODO: Use consistent variable naming (camel case or underscores, not both) -// WARN: For functions that are called from the options page, proper scope is -// necessary (e.g., using a function declaration beginning with a 'function', -// or using a function expression beginning with 'var', but not a function -// expression beginning with 'let' or 'const'). - -const USER_AGENT = navigator.userAgent.toLowerCase(); -const MOBILE = USER_AGENT.indexOf('android') > -1 && USER_AGENT.indexOf('firefox') > -1; -const IS_FIREFOX = chrome.runtime.getURL('').startsWith('moz-extension://'); - -// total number of highlight states (min 2, max 4). -let NUM_HIGHLIGHT_STATES = 4; -// Firefox for mobile doesn't show a browserAction icon, so only use two highlight -// states (on and off). -if (MOBILE) - NUM_HIGHLIGHT_STATES = 2; +importScripts('matchPattern.js', 'shared.js'); // ***************************** // * Utilities and Options // ***************************** -// This is called from options.js (see scope warning above). -function getNumHighlightStates() { - return NUM_HIGHLIGHT_STATES; -} - -// This is called from options.js (see scope warning above). -function getVersion() { - return chrome.runtime.getManifest().version; -} - -// This is called from options.js (see scope warning above). -// Takes an optional scope, which can be null to refer to all. -// When no scope is specified, the container dictionary is returned. -function getPermissions(scope) { - const permissions = { - 'autonomous_highlights': { - permissions: ['tabs'], - origins: [''] - }, - 'global_highlighting': { - permissions: ['tabs'], - origins: [''] - }, - 'copy_highlights': { - permissions: ['clipboardWrite'], - origins: [] - } - }; - if (scope === null) { - const _permissions = new Set(); - const origins = new Set(); - for (const [key, value] of Object.entries(permissions)) { - value.permissions.forEach(x => _permissions.add(x)); - value.origins.forEach(x => origins.add(x)); - } - const result = { - permissions: Array.from(_permissions), - origins: Array.from(origins) - }; - return result; - } else if (scope === undefined) { - return permissions; - } else { - return permissions[scope]; - } -} - -// This is called from options.js (see scope warning above). -// Saves options (asynchronously). -function saveOptions(options, callback=function() {}) { - // Deep copy so this function is not destructive. - options = JSON.parse(JSON.stringify(options)); - // Disable autonomous highlighting if its required permissions were - // removed. - chrome.permissions.contains( - getPermissions('autonomous_highlights'), - function(result) { - if (!result) - options.autonomous_highlights = false; - chrome.storage.local.get(['options'], function(storage) { - const json = JSON.stringify(storage.options); - // Don't save if there are no changes (to prevent 'storage' event listeners - // from responding when they don't need to). - // XXX: The comparison will fail if the keys are in different order. - if (JSON.stringify(storage.options) !== JSON.stringify(options)) { - chrome.storage.local.set({options: options}, callback); - } else { - callback(); - } - }); - }); -} - -// This is called from options.js (see scope warning above). -function defaultOptions() { - const options = Object.create(null); - const yellow = '#FFFF00'; - const black = '#000000'; - const red = '#FF0000'; - options['highlight_color'] = yellow; - options['text_color'] = black; - options['link_color'] = red; - options['tinted_highlights'] = false; - options['autonomous_highlights'] = false; - options['autonomous_delay'] = 0; - options['autonomous_state'] = Math.min(2, NUM_HIGHLIGHT_STATES - 1); - // Enable the blocklist by default, so that it's ready in case - // autonomous_highlights is enabled (which is disabled by default). - options['autonomous_blocklist'] = true; - options['autonomous_blocklist_items'] = []; - options['autonomous_blocklist_exceptions'] = []; - return options; -} - -// Validate options -const validateOptions = function() { +(function() { + // Validate options chrome.storage.local.get({options: {}}, function(result) { let opts = result.options; if (!opts) { @@ -149,60 +41,18 @@ const validateOptions = function() { saveOptions(opts); }); -}; - -(function() { - if ('options' in localStorage) { - // If there are localStorage options, transfer them to chrome.storage.local. - // TODO: This can eventually be removed, at which point the validateOptions() - // function should be deleted, and its code moved here. - let opts = localStorage['options']; - localStorage.clear(); - chrome.storage.local.set({options: JSON.parse(opts)}, function() { - validateOptions(); - }); - } else { - validateOptions(); - } })(); // ***************************** // * Core // ***************************** -// a highlightState is a list with highlight state and success state -// this is used to manage highlight state, particularly for keeping -// icon in sync, and telling content.js what state to change to -const tabIdToHighlightState = new Map(); - -chrome.tabs.onRemoved.addListener(function(tabId, removeInfo) { - // don't have to check if tabId in map. delete will still work, but - // will return false - tabIdToHighlightState.delete(tabId); -}); - -// This is called from options.js (see scope warning above). -function highlightStateToIconId(state) { - return state + (state > 0 ? 4 - NUM_HIGHLIGHT_STATES : 0); -} - -// updates highlight state in tabIdToHighlightState, and also used to // show the correct highlight icon -const updateHighlightState = function(tabId, highlight, success) { - // null represents 'unknown' - // true should always clobber false (for iframes) - success = (typeof success) === 'undefined' ? null : success; - - // have to check for false. for null we don't want to set to zero. - if (success === false) - highlight = 0; - - tabIdToHighlightState.set(tabId, {highlight: highlight, success: success}); - +const updateIcon = function(tabId, highlight, success) { const setIcon = function(iconId) { - const path19 = 'icons/' + iconId + 'highlight19x19.png'; - const path38 = 'icons/' + iconId + 'highlight38x38.png'; - chrome.browserAction.setIcon({ + const path19 = '../icons/' + iconId + 'highlight19x19.png'; + const path38 = '../icons/' + iconId + 'highlight38x38.png'; + chrome.action.setIcon({ path: { '19': path19, '38': path38 @@ -220,105 +70,73 @@ const updateHighlightState = function(tabId, highlight, success) { setIcon(iconId); }; -chrome.runtime.onMessage.addListener(function(request, sender, response) { - const proceed = request && sender && sender.tab; - if (!proceed) - return; - const message = request.message; - const tabId = sender.tab.id; - if (message === 'updateHighlightState') { - updateHighlightState(tabId, request.highlight, request.success); - } else if (message === 'getHighlightState') { - const highlightState = tabIdToHighlightState.get(tabId); - response({ - 'curHighlight': highlightState.highlight, - 'curSuccess': highlightState.success}); - } else if (message === 'getParams') { - response({'numHighlightStates': NUM_HIGHLIGHT_STATES}); - } else if (message === 'copyText') { - const textarea = document.createElement('textarea'); - document.body.append(textarea); - textarea.textContent = request.text; - textarea.select(); - document.execCommand('copy'); - textarea.parentNode.removeChild(textarea); - } - // NOTE: if you're going to call response asynchronously, - // be sure to return true from this function. - // http://stackoverflow.com/questions/20077487/ - // chrome-extension-message-passing-response-not-sent -}); - // Injects Auto Highlight if it hasn't been injected yet, and runs the specified callback. -const injectThenRun = function(tabId, showError, runAt='document_idle', callback=function() {}) { +const injectThenRun = function(tabId, runAt='document_idle', callback=function() {}) { let fn = callback; // First check if the current page is supported by trying to inject no-op code. // (e.g., https://chrome.google.com/webstore, https://addons.mozilla.org/en-US/firefox/, // chrome://extensions/, and other pages do not support extensions). - chrome.tabs.executeScript( - tabId, - {code: '(function(){})();'}, - function() { - if (chrome.runtime.lastError) { - if (showError) { - // alert() doesn't work from Firefox background pages. A try/catch block is - // not sufficient to prevent the "Browser Console" window that pops up with - // the following message when using alert(): - // > "The Web Console logging API (console.log, console.info, console.warn, - // > console.error) has been disabled by a script on this page." - if (!IS_FIREFOX) - alert('Auto Highlight is not supported on this page.'); - } - return; - } - chrome.tabs.sendMessage( - tabId, - {method: 'ping'}, - {}, - function(resp) { - // On Firefox, in some cases just checking for lastError is not - // sufficient. - if (chrome.runtime.lastError || !resp) { - const scripts = [ - 'src/lib/readabilitySAX/readabilitySAX.js', - 'src/lib/Porter-Stemmer/PorterStemmer1980.js', - 'src/nlp.js', - 'src/utils.js', - 'src/content.js', - 'src/style.css' - ]; - for (let i = scripts.length - 1; i >= 0; --i) { - let script = scripts[i]; - let fn_ = fn; - fn = function() { - let inject_ = function(id, options, callback) {callback()}; - if (script.endsWith('.css')) { - inject_ = chrome.tabs.insertCSS; - } else if (script.endsWith('.js')) { - inject_ = chrome.tabs.executeScript; - } - // Only inject into the top-level frame. More permissions than activeTab - // would be required for iframes of different origins. - // https://stackoverflow.com/questions/59166046/using-tabs-executescript-in-iframe - // The same permissions used for global highlighting seem to suffice. - inject_(tabId, {file: script, allFrames: false, runAt: runAt}, fn_); - } - } + chrome.scripting.executeScript({ + target: {tabId: tabId}, + func: () => { + (function () {})(); + } + }, (results) => { + if (chrome.runtime.lastError) { + // Earlier versions used an option to injectThenRun to specify whether an error + // message should be shown here using alert(): + // Auto Highlight is not supported on this page. + // This was avoided on Firefox, since it led to an error popup, and also avoided + // for non-user actions (e.g., autonomous highlights). + // However, with manifest v3's service workers, showing a message with alert() + // is not possible. + return; + } + chrome.tabs.sendMessage( + tabId, + {method: 'ping'}, + {}, + function(resp) { + // On Firefox, in some cases just checking for lastError is not + // sufficient. + if (chrome.runtime.lastError || !resp) { + fn = () => { + // Only inject into the top-level frame. More permissions than activeTab + // would be required for iframes of different origins. + // https://stackoverflow.com/questions/59166046/using-tabs-executescript-in-iframe + // The same permissions used for global highlighting seem to suffice. + const styles = ['src/style.css']; + chrome.scripting.insertCSS({ + target: {tabId: tabId, allFrames: false}, + files: styles + }, () => { + const scripts = [ + 'src/lib/readabilitySAX/readabilitySAX.js', + 'src/lib/Porter-Stemmer/PorterStemmer1980.js', + 'src/nlp.js', + 'src/content.js' + ]; + chrome.scripting.executeScript({ + target: {tabId: tabId, allFrames: false}, + files: scripts + }, () => { + callback(); + }); + }); } - fn(); - }); - }); + } + fn(); + }); + }); }; // setting state to null results in automatically incrementing the state. -const highlight = function(tabId, showError, state=null, runAt='document_idle', delay=0) { +const highlight = function(tabId, state=null, runAt='document_idle', delay=0) { if (state !== null && (state < 0 || state >= NUM_HIGHLIGHT_STATES)) { console.error(`invalid state: ${state}`); return; } const sendHighlightMessage = function() { - if (state === null) - state = (tabIdToHighlightState.get(tabId).highlight + 1) % NUM_HIGHLIGHT_STATES; chrome.tabs.sendMessage( tabId, { @@ -327,7 +145,7 @@ const highlight = function(tabId, showError, state=null, runAt='document_idle', delay: delay }); }; - injectThenRun(tabId, showError, runAt, sendHighlightMessage); + injectThenRun(tabId, runAt, sendHighlightMessage); }; // runAt: 'document_end' is used for manually triggered highlighting, so that @@ -366,22 +184,21 @@ chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) { return; } highlight( - tab.id, false, options.autonomous_state, 'document_idle', options.autonomous_delay); + tab.id, options.autonomous_state, 'document_idle', options.autonomous_delay); } }); }); -// This is called from options.js (see scope warning above). -function highlightAll(state) { +const highlightAll = function(state) { chrome.tabs.query({}, function(tabs) { for (let i = 0; i < tabs.length; ++i) { - highlight(tabs[i].id, false, state, 'document_end'); + highlight(tabs[i].id, state, 'document_end'); } }); -} +}; -chrome.browserAction.onClicked.addListener(function(tab) { - highlight(tab.id, true, null, 'document_end'); +chrome.action.onClicked.addListener(function(tab) { + highlight(tab.id, null, 'document_end'); }); chrome.permissions.onRemoved.addListener(function() { @@ -391,11 +208,31 @@ chrome.permissions.onRemoved.addListener(function() { }); }); +chrome.runtime.onMessage.addListener(function(request, sender, response) { + const message = request.message; + if (message === 'updateIcon') { + updateIcon(sender.tab.id, request.highlight, request.success); + } else if (message === 'getParams') { + response({'numHighlightStates': NUM_HIGHLIGHT_STATES}); + } else if (message === 'highlightAll') { + highlightAll(request.state); + // Respond so that a callback can be executed without "Unchecked runtime.lastError". + response(true); + } + // NOTE: if you're going to call response asynchronously, + // be sure to return true from this function. + // http://stackoverflow.com/questions/20077487/ + // chrome-extension-message-passing-response-not-sent +}); + // ***************************** // * Context Menu // ***************************** -{ +// Context menus are removed then re-created when the service worker resumes. +// This avoids the following error on each context menu creation: +// Unchecked runtime.lastError: Cannot create item with duplicate id _______ +chrome.contextMenus.removeAll(function() { // As of 2019/9/18, Chrome does not support icons. const icons_supported = IS_FIREFOX; const black_square = String.fromCodePoint('0x25FC'); @@ -415,7 +252,7 @@ chrome.permissions.onRemoved.addListener(function() { 4: {0: 'None', 1: 'Low', 2: 'High', 3: 'Max'} }; - const contexts = ['page', 'browser_action']; + const contexts = ['page', 'action']; for (const context of contexts) { // Add highlighting items. let highlight_menu_id = 'highlight_' + context; @@ -603,7 +440,7 @@ chrome.permissions.onRemoved.addListener(function() { const id = info.menuItemId; if (id.match(highlight_re) !== null) { const level = parseInt(id.slice('highlight_'.length).split('_')[0]); - highlight(tab.id, true, level, 'document_end'); + highlight(tab.id, level, 'document_end'); } else if (id.match(global_re) !== null) { const level = parseInt(id.slice('global_'.length).split('_')[0]); chrome.permissions.request( @@ -626,7 +463,7 @@ chrome.permissions.onRemoved.addListener(function() { if (granted) { // Inject Auto Highlight prior to sending the message so that there is always // a handler to process the message, even prior to the initial highlight request. - injectThenRun(tab.id, true, 'document_idle', function() { + injectThenRun(tab.id, 'document_idle', function() { chrome.tabs.sendMessage(tab.id, {method: 'copyHighlights'}); }); } @@ -662,4 +499,4 @@ chrome.permissions.onRemoved.addListener(function() { throw new Error('Unhandled menu ID: ' + id); } }); -} +}); diff --git a/src/options/options.html b/src/options/options.html index 39b3fe9..6537f25 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -233,6 +233,8 @@

Blocklist Exceptions

+ + diff --git a/src/options/options.js b/src/options/options.js index 0be3329..df52469 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -11,12 +11,10 @@ const statusMessage = function(message, time) { }, time); }; -const backgroundPage = chrome.extension.getBackgroundPage(); +const numHighlightStates = NUM_HIGHLIGHT_STATES; -const numHighlightStates = backgroundPage.getNumHighlightStates(); - -const autonomousHighlightsPermissions = backgroundPage.getPermissions('autonomous_highlights'); -const globalHighlightingPermissions = backgroundPage.getPermissions('global_highlighting'); +const autonomousHighlightsPermissions = getPermissions('autonomous_highlights'); +const globalHighlightingPermissions = getPermissions('global_highlighting'); const highlightColorInput = document.getElementById('highlight-color'); const textColorInput = document.getElementById('text-color'); @@ -49,7 +47,7 @@ const revokeButton = document.getElementById('revoke-permissions'); const versionElement = document.getElementById('version'); -versionElement.innerText = backgroundPage.getVersion(); +versionElement.innerText = chrome.runtime.getManifest().version; /*********************************** * Views @@ -73,7 +71,7 @@ showView('main-view'); ***********************************/ revokeButton.addEventListener('click', function() { - chrome.permissions.remove(backgroundPage.getPermissions(null)); + chrome.permissions.remove(getPermissions(null)); }); /*********************************** @@ -119,7 +117,7 @@ const populateBlocklistTable = function(opts) { remove_td.appendChild(remove_span); remove_td.addEventListener('click', function() { opts[key].splice(i, 1); - backgroundPage.saveOptions(opts); + saveOptions(opts); }); tr.appendChild(remove_td); } @@ -171,7 +169,7 @@ const populateBlocklistTable = function(opts) { const message = 'Please enter a match pattern.'; try { // Make sure we can create the match pattern and run matches(). - new backgroundPage.MatchPattern(data).matches('http://www.dannyadam.com'); + new MatchPattern(data).matches('http://www.dannyadam.com'); } catch (err) { autonomousBlocklistNewInput.setCustomValidity(message); } @@ -225,7 +223,7 @@ const populateBlocklistTable = function(opts) { const opts = storage.options; const key = 'autonomous_blocklist_' + list_source; opts[key].push(item); - backgroundPage.saveOptions(opts); + saveOptions(opts); }); }; @@ -265,8 +263,8 @@ const setAutonomousHighlights = function(value, active=false, callback=null) { const setRevokeButtonState = function() { // Disables the revoke button, and then enables it if any of the relevant // permissions are currently granted. - const permission_items = Object.keys(backgroundPage.getPermissions()).map( - key => backgroundPage.getPermissions(key)); + const permission_items = Object.keys(getPermissions()).map( + key => getPermissions(key)); revokeButton.disabled = true; let fn = function() {}; for (const item of permission_items) { @@ -313,36 +311,12 @@ for (let i = 1; i < numHighlightStates; ++i) { const img = document.createElement('img'); label.appendChild(img); - const iconName = backgroundPage.highlightStateToIconId(i) + 'highlight'; + const iconName = highlightStateToIconId(i) + 'highlight'; img.src = '../../icons/' + iconName + '38x38.png'; img.height = 19; img.width = 19; } -// Saves options (asynchronously). -const saveOptions = function() { - const options = Object.create(null); - options['highlight_color'] = highlightColorInput.value; - options['text_color'] = textColorInput.value; - options['link_color'] = linkColorInput.value; - options['tinted_highlights'] = tintedHighlightsInput.checked; - options['autonomous_highlights'] = autonomousHighlightsInput.checked; - options['autonomous_delay'] = parseInt(autonomousDelayInput.value); - options['autonomous_state'] = parseInt( - autonomousStateInputs.querySelector('input:checked').value); - options['autonomous_blocklist'] = autonomousBlocklistInput.checked; - // The values on the blocklist (and exceptions) do not get pulled from the - // DOM prior to saving (which is what's done above for the other options. - // This is because blocklist items are handled differently than the - // other form inputs, getting saved directly when they're added. - chrome.storage.local.get(['options'], (storage) => { - const existing_opts = storage.options; - options['autonomous_blocklist_items'] = existing_opts.autonomous_blocklist_items; - options['autonomous_blocklist_exceptions'] = existing_opts.autonomous_blocklist_exceptions; - backgroundPage.saveOptions(options); - }); -}; - // Loads options (asynchronously). const loadOptions = function(opts) { // onchange doesn't fire when setting 'checked' and other values with javascript, @@ -384,9 +358,9 @@ document.addEventListener('DOMContentLoaded', function() { // Load default options. document.getElementById('defaults').addEventListener('click', () => { - const defaults = backgroundPage.defaultOptions(); + const defaults = defaultOptions(); // this will trigger updates to the input settings - backgroundPage.saveOptions(defaults, function() { + saveOptions(defaults, function() { statusMessage('Defaults Loaded', 1200); }); }); @@ -399,7 +373,7 @@ document.addEventListener('DOMContentLoaded', function() { permissions, function() { // this will trigger updates to the input settings - backgroundPage.saveOptions(initOpts, () => { + saveOptions(initOpts, () => { statusMessage('Options Reverted', 1200); }); }); @@ -418,30 +392,54 @@ if (numHighlightStates < 3) { // save options and synchronize form on any user input (function() { + // Saves form options (asynchronously). + const saveFormOptions = function() { + const options = Object.create(null); + options['highlight_color'] = highlightColorInput.value; + options['text_color'] = textColorInput.value; + options['link_color'] = linkColorInput.value; + options['tinted_highlights'] = tintedHighlightsInput.checked; + options['autonomous_highlights'] = autonomousHighlightsInput.checked; + options['autonomous_delay'] = parseInt(autonomousDelayInput.value); + options['autonomous_state'] = parseInt( + autonomousStateInputs.querySelector('input:checked').value); + options['autonomous_blocklist'] = autonomousBlocklistInput.checked; + // The values on the blocklist (and exceptions) do not get pulled from the + // DOM prior to saving (which is what's done above for the other options. + // This is because blocklist items are handled differently than the + // other form inputs, getting saved directly when they're added. + chrome.storage.local.get(['options'], (storage) => { + const existing_opts = storage.options; + options['autonomous_blocklist_items'] = existing_opts.autonomous_blocklist_items; + options['autonomous_blocklist_exceptions'] = existing_opts.autonomous_blocklist_exceptions; + saveOptions(options); + }); + }; + // For color inputs, 'input' events are triggered during selection, while 'change' // events are triggered after closing the dialog. for (const type of ['change', 'input']) { - highlightColorInput.addEventListener(type, saveOptions); - textColorInput.addEventListener(type, saveOptions); - linkColorInput.addEventListener(type, saveOptions); + highlightColorInput.addEventListener(type, saveFormOptions); + textColorInput.addEventListener(type, saveFormOptions); + linkColorInput.addEventListener(type, saveFormOptions); } - tintedHighlightsInput.addEventListener('change', saveOptions); + tintedHighlightsInput.addEventListener('change', saveFormOptions); autonomousHighlightsInput.addEventListener('change', function() { - setAutonomousHighlights(autonomousHighlightsInput.checked, true, saveOptions); + setAutonomousHighlights(autonomousHighlightsInput.checked, true, saveFormOptions); }); - autonomousDelayInput.addEventListener('change', saveOptions); + autonomousDelayInput.addEventListener('change', saveFormOptions); // For range inputs, 'input' events are triggered while dragging, while 'change' // events are triggered after the end of a sliding action. autonomousDelayInput.addEventListener('input', function() { showAutonomousDelay(); - saveOptions(); + saveFormOptions(); }); for (const input of autonomousStateInputs.querySelectorAll('input')) { - input.addEventListener('change', saveOptions); + input.addEventListener('change', saveFormOptions); } autonomousBlocklistInput.addEventListener('change', function() { syncBlocklistButtons(); - saveOptions(); + saveFormOptions(); }); })(); @@ -451,7 +449,7 @@ if (numHighlightStates < 3) { { const iconSrc = function(i) { - const iconName = backgroundPage.highlightStateToIconId(i) + 'highlight'; + const iconName = highlightStateToIconId(i) + 'highlight'; return '../../icons/' + iconName + '38x38.png'; }; @@ -463,7 +461,7 @@ if (numHighlightStates < 3) { img.className = 'global-highlight-icon'; img.src = iconSrc(i); img.addEventListener('click', function() { - // Have to put call to chrome.permissions.request in here, not backgroundPage.highlightAll, + // Have to put call to chrome.permissions.request in here, not highlightAll, // to avoid "This function must be called during a user gesture" error. chrome.permissions.request( globalHighlightingPermissions, @@ -475,19 +473,19 @@ if (numHighlightStates < 3) { for (let j = 0; j < numHighlightStates; ++j) { icons[j].src = iconSrc(j); } - backgroundPage.highlightAll(i); img.src = '../../icons/_highlight38x38.png'; - setTimeout(function() { - // Only restore icon if there weren't subsequent clicks (in case the same icon - // is clicked multiple times). - if (id !== count) return; - img.src = iconSrc(i); - }, 1000); + chrome.runtime.sendMessage({message: 'highlightAll', state: i}, () => { + setTimeout(function() { + // Only restore icon if there weren't subsequent clicks (in case the same icon + // is clicked multiple times). + if (id !== count) return; + img.src = iconSrc(i); + }, 1000); + }); }); }); globalHighlightIcons.appendChild(img); } - } /*********************************** diff --git a/src/shared.js b/src/shared.js new file mode 100644 index 0000000..5b120d9 --- /dev/null +++ b/src/shared.js @@ -0,0 +1,104 @@ +// The code in here is imported by eventPage.js and options.js. +// It was originally defined within eventPage.js, and called in options.js +// through chrome.extension.getBackgroundPage(). This was no longer possible +// after switching to Manifest V3. Implementing the functions here was deemed +// preferable to keeping them in eventPage.js and making them accessible through +// messages, since the asynchronous nature would complicate the code in options.js. + +const USER_AGENT = navigator.userAgent.toLowerCase(); +const MOBILE = USER_AGENT.indexOf('android') > -1 && USER_AGENT.indexOf('firefox') > -1; +const IS_FIREFOX = chrome.runtime.getURL('').startsWith('moz-extension://'); + +// total number of highlight states (min 2, max 4). +let NUM_HIGHLIGHT_STATES = 4; +// Firefox for mobile doesn't show a browserAction icon, so only use two highlight +// states (on and off). +if (MOBILE) + NUM_HIGHLIGHT_STATES = 2; + +// Takes an optional scope, which can be null to refer to all. +// When no scope is specified, the container dictionary is returned. +const getPermissions = function(scope) { + const permissions = { + 'autonomous_highlights': { + permissions: ['tabs'], + origins: [''] + }, + 'global_highlighting': { + permissions: ['tabs'], + origins: [''] + }, + 'copy_highlights': { + permissions: ['clipboardWrite'], + origins: [] + } + }; + if (scope === null) { + const _permissions = new Set(); + const origins = new Set(); + for (const [key, value] of Object.entries(permissions)) { + value.permissions.forEach(x => _permissions.add(x)); + value.origins.forEach(x => origins.add(x)); + } + const result = { + permissions: Array.from(_permissions), + origins: Array.from(origins) + }; + return result; + } else if (scope === undefined) { + return permissions; + } else { + return permissions[scope]; + } +}; + +// Saves options (asynchronously). +const saveOptions = function(options, callback=function() {}) { + // Deep copy so this function is not destructive. + options = JSON.parse(JSON.stringify(options)); + // Disable autonomous highlighting if its required permissions were + // removed. + chrome.permissions.contains( + getPermissions('autonomous_highlights'), + function(result) { + if (!result) + options.autonomous_highlights = false; + chrome.storage.local.get(['options'], function(storage) { + const json = JSON.stringify(storage.options); + // Don't save if there are no changes (to prevent 'storage' event listeners + // from responding when they don't need to). + // XXX: The comparison will fail if the keys are in different order. + if (JSON.stringify(storage.options) !== JSON.stringify(options)) { + chrome.storage.local.set({options: options}, callback); + } else { + callback(); + } + }); + }); +}; + +const defaultOptions = function() { + const options = Object.create(null); + const yellow = '#FFFF00'; + const black = '#000000'; + const red = '#FF0000'; + options['highlight_color'] = yellow; + options['text_color'] = black; + options['link_color'] = red; + options['tinted_highlights'] = false; + options['autonomous_highlights'] = false; + options['autonomous_delay'] = 0; + options['autonomous_state'] = Math.min(2, NUM_HIGHLIGHT_STATES - 1); + // Enable the blocklist by default, so that it's ready in case + // autonomous_highlights is enabled (which is disabled by default). + options['autonomous_blocklist'] = true; + options['autonomous_blocklist_items'] = []; + options['autonomous_blocklist_exceptions'] = []; + return options; +}; + +// This is called from options.js (see scope warning above). +const highlightStateToIconId = function(state) { + return state + (state > 0 ? 4 - NUM_HIGHLIGHT_STATES : 0); +}; + diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 8a53a51..0000000 --- a/src/utils.js +++ /dev/null @@ -1,52 +0,0 @@ -// module pattern to keep things organized -this.UTILS = (function() { - const me = Object.create(null); - - me.hasOwnProperty = function(obj, key) { - return Object.prototype.hasOwnProperty.call(obj, key); - }; - - me.isNumericType = function(x) { - return (typeof x === 'number'); - }; - - // safeSetInterval kill an interval timer if there is an error. - // this is useful for intervals that communicate with the background - // page, since we can lose communication if the extension reloads. - // Syntax: safeSetInterval(function,milliseconds,param1,param2,...) - me.safeSetInterval = function() { - // arguments is not an Array. It's array-like. Let's create one - const args = []; - for (let i = 0; i < arguments.length; i++) { - args.push(arguments[i]); - } - - const fn = args[0]; - const rest = args.slice(1); - const timerId = setInterval.apply(null, [function() { - try { - fn(); - } catch(err) { - clearInterval(timerId); // stop executing timer - } - }].concat(rest)); - return timerId; - }; - - // sets a timeout and ignores exceptions - me.setTimeoutIgnore = function() { - const args = []; - for (let i = 0; i < arguments.length; i++) { - args.push(arguments[i]); - } - const fn = args[0]; - const rest = args.slice(1); - setTimeout.apply(null, [function() { - try { - fn(); - } catch(err) {} // ignore errors - }].concat(rest)); - }; - - return me; -}()); From c225bfe4933b78728b32678ee5888fe402cef124 Mon Sep 17 00:00:00 2001 From: Dan Date: Sun, 27 Feb 2022 15:04:11 -0500 Subject: [PATCH 2/5] Respond to message. This way, callbacks can be executed without "Unchecked runtime.lastError". --- src/eventPage.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/eventPage.js b/src/eventPage.js index 0c9a1b0..2cdc92a 100644 --- a/src/eventPage.js +++ b/src/eventPage.js @@ -210,15 +210,16 @@ chrome.permissions.onRemoved.addListener(function() { chrome.runtime.onMessage.addListener(function(request, sender, response) { const message = request.message; + let result = true; if (message === 'updateIcon') { updateIcon(sender.tab.id, request.highlight, request.success); } else if (message === 'getParams') { - response({'numHighlightStates': NUM_HIGHLIGHT_STATES}); + result = {'numHighlightStates': NUM_HIGHLIGHT_STATES}; } else if (message === 'highlightAll') { highlightAll(request.state); - // Respond so that a callback can be executed without "Unchecked runtime.lastError". - response(true); } + // Respond so that a callback can be executed without "Unchecked runtime.lastError". + response(result); // NOTE: if you're going to call response asynchronously, // be sure to return true from this function. // http://stackoverflow.com/questions/20077487/ From eedb48218580aa3007086af41daec0240e157561 Mon Sep 17 00:00:00 2001 From: Dan Date: Sun, 3 Apr 2022 15:48:41 -0400 Subject: [PATCH 3/5] Specify optional_host_permissions. --- manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/manifest.json b/manifest.json index 08bbb16..900e5fb 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,7 @@ }, "permissions": ["activeTab", "contextMenus", "scripting", "storage"], "optional_permissions": ["tabs", "clipboardWrite"], + "optional_host_permissions": [""], "background": { "service_worker": "src/eventPage.js" }, From b004942e1226916bd8156745810cf85f649bf9a1 Mon Sep 17 00:00:00 2001 From: Dan Date: Sun, 17 Mar 2024 23:20:12 -0400 Subject: [PATCH 4/5] Use a separate manifest.json for Chrome and Firefox --- .gitignore | 4 ++- manifest.json => manifest_chrome.json | 0 manifest_firefox.json | 26 ++++++++++++++++++ src/eventPage.js | 6 ++++- zip.sh | 39 ++++++++++++++++----------- 5 files changed, 58 insertions(+), 17 deletions(-) rename manifest.json => manifest_chrome.json (100%) create mode 100644 manifest_firefox.json diff --git a/.gitignore b/.gitignore index ca6b818..e0bd29c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store Thumbs.db -/archive.zip +/archive_chrome.zip +/archive_firefox.zip +/manifest.json diff --git a/manifest.json b/manifest_chrome.json similarity index 100% rename from manifest.json rename to manifest_chrome.json diff --git a/manifest_firefox.json b/manifest_firefox.json new file mode 100644 index 0000000..e297ca9 --- /dev/null +++ b/manifest_firefox.json @@ -0,0 +1,26 @@ +{ + "name": "Auto Highlight", + "version": "3.5.0", + "description": "*Auto Highlight* automatically highlights the important content on article pages.", + "options_ui": { + "page": "src/options/options.html" + }, + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "optional_permissions": ["tabs", "clipboardWrite", ""], + "background": { + "scripts": ["src/matchPattern.js", "src/shared.js", "src/eventPage.js"] + }, + "action": { + "default_icon": { + "19": "icons/0highlight19x19.png", + "38": "icons/0highlight38x38.png" + }, + "default_title": "Toggle highlighting" + }, + "icons": { + "16": "icons/16x16.png", + "48": "icons/48x48.png", + "128": "icons/128x128.png" + }, + "manifest_version": 3 +} diff --git a/src/eventPage.js b/src/eventPage.js index a5f0d8e..dd166c8 100644 --- a/src/eventPage.js +++ b/src/eventPage.js @@ -1,6 +1,10 @@ // TODO: Use consistent variable naming (camel case or underscores, not both) -importScripts('matchPattern.js', 'shared.js'); +// If we're running as a service worker (Chrome), import dependencies. For +// Firefox, they've already been loaded as background scripts. +if (self.WorkerGlobalScope !== undefined) { + importScripts('matchPattern.js', 'shared.js'); +} // ***************************** // * Utilities and Options diff --git a/zip.sh b/zip.sh index bd528d2..a7d0204 100755 --- a/zip.sh +++ b/zip.sh @@ -1,22 +1,31 @@ #!/usr/bin/env bash -# run this from the package directory +scriptdir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "${scriptdir}" -# easier to see args in an array than a string -ARGS=() -ARGS+=("icons/") -ARGS+=("src/") -ARGS+=("manifest.json") +gen_archive() { + browser="${1}" -# exclude -ARGS+=("-x") -ARGS+=("*.DS_Store") -ARGS+=("*Thumbs.db") + # easier to see args in an array than a string + args=() + args+=("icons/") + args+=("src/") + args+=("manifest_${browser}.json") -archive="archive.zip" + # exclude + args+=("-x") + args+=("*.DS_Store") + args+=("*Thumbs.db") -if [ -f "${archive}" ]; then - rm "${archive}" -fi + archive="archive_${browser}.zip" -zip -r "${archive}" "${ARGS[@]}" + if [ -f "${archive}" ]; then + rm "${archive}" + fi + + zip -r "${archive}" "${args[@]}" + printf "@ manifest_${browser}.json\n@=manifest.json\n" | zipnote -w "${archive}" +} + +gen_archive chrome +gen_archive firefox From 62f25867be4496315a40f7850c569e5ec3e8c655 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 18 Mar 2024 00:16:16 -0400 Subject: [PATCH 5/5] Update version --- manifest_chrome.json | 2 +- manifest_firefox.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest_chrome.json b/manifest_chrome.json index dbf59de..68bbd83 100644 --- a/manifest_chrome.json +++ b/manifest_chrome.json @@ -1,6 +1,6 @@ { "name": "Auto Highlight", - "version": "3.5.0", + "version": "3.6.0", "description": "*Auto Highlight* automatically highlights the important content on article pages.", "options_ui": { "page": "src/options/options.html" diff --git a/manifest_firefox.json b/manifest_firefox.json index e297ca9..f8425e6 100644 --- a/manifest_firefox.json +++ b/manifest_firefox.json @@ -1,6 +1,6 @@ { "name": "Auto Highlight", - "version": "3.5.0", + "version": "3.6.0", "description": "*Auto Highlight* automatically highlights the important content on article pages.", "options_ui": { "page": "src/options/options.html"