diff --git a/README.md b/README.md index c0f059a13..5f1524ad4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [](https://github.com/Robbendebiene/Gesturefy/blob/master/LICENSE) -# esturefy +# esturefy #### [](https://addons.mozilla.org/firefox/addon/gesturefy/) Navigate, operate, and browse faster with mouse gestures! A customizable Firefox mouse gesture add-on with a variety of different commands. diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index bb45aecb1..1dc316e45 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -37,9 +37,9 @@ "message": "Extras", "description": "Extras" }, - "navigationExclusions": { - "message": "Exclusions", - "description": "Exclusions" + "navigationData": { + "message": "Data", + "description": "Data" }, "navigationAbout": { "message": "About", @@ -289,44 +289,106 @@ "description": "The minimal amount the mouse wheel must be scrolled until the gesture is triggered." }, - "exclusionsLabelInformation": { - "message": "Information:", - "description": "Information:" + "dataLabelFileBackup": { + "message": "Backup to file", + "description": "Backup to file" }, - "exclusionsTextInformation": { - "message": "Add URL match patterns to disable Gesturefy for certain websites. The match pattern allows \"*\" wildcards and must be in the form of ://.", - "description": "Add URL match patterns to disable Gesturefy for certain websites. The match pattern allows \"*\" wildcards and must be in the form of ://." + "dataLabelFileRestore": { + "message": "Restore from file", + "description": "Restore from file" }, - "exclusionsPlaceholderURL": { - "message": "Enter URL match pattern", - "description": "Enter URL match pattern" + "dataLabelCloudBackup": { + "message": "Backup to cloud", + "description": "Backup to cloud" }, - "exclusionsAddButton": { - "message": "Add", - "description": "Add" + "dataLabelCloudRestore": { + "message": "Restore from cloud", + "description": "Restore from cloud" + }, + "dataLabelResetConfig": { + "message": "Reset configuration", + "description": "Reset configuration" + }, + + "dataDescriptionFileBackup": { + "message": "Stores the settings and gestures in a config file.", + "description": "Stores the settings and gestures in a config file." + }, + "dataDescriptionFileRestore": { + "message": "Loads a previously stored config file.", + "description": "Loads a previously stored config file." + }, + "dataDescriptionCloudBackup": { + "message": "Stores the settings and gestures in the cloud of the browser account.", + "description": "Stores the settings and gestures in the cloud of the browser account." }, - "exclusionsNotificationAlreadyExists": { - "message": "URL pattern already exists.", - "description": "URL pattern already exists." + "dataDescriptionCloudRestore": { + "message": "Loads previously uploaded settings and gestures from the cloud of the browser account.", + "description": "Loads previously uploaded settings and gestures from the cloud of the browser account." }, - "exclusionsHintNoEntries": { - "message": "No entries available", - "description": "No entries available" + "dataDescriptionResetConfig": { + "message": "Resets all settings and gestures to their defaults.", + "description": "Resets all settings and gestures to their defaults." }, - "aboutBackup": { + "dataBackup": { "message": "Backup", "description": "Backup" }, - "aboutRestore": { + "dataRestore": { "message": "Restore", "description": "Restore" }, - "aboutReset": { + "dataCloudUpload": { + "message": "Upload", + "description": "Upload" + }, + "dataCloudDownload": { + "message": "Download", + "description": "Download" + }, + "dataReset": { "message": "Reset", "description": "Reset" }, + "dataRestoreNotificationNoConfigFile": { + "message": "The selected file is not a valid Gesturefy config file.", + "description": "The selected file is not a valid Gesturefy config file." + }, + "dataRestoreNotificationWrongFile": { + "message": "The selected file does not match the required file type.", + "description": "The selected file does not match the required file type." + }, + "dataRestoreNotificationConfirm": { + "message": "All settings and gestures will be replaced by the selected config file.", + "description": "All settings and gestures will be replaced by the selected config file." + }, + "dataRestoreNotificationSuccess": { + "message": "The selected configuration has been successfully restored.", + "description": "The selected configuration has been successfully restored." + }, + "dataUploadNotificationConfirm": { + "message": "Your existing configuration in the cloud will be overridden by the current configuration.", + "description": "Your existing configuration in the cloud will be overridden by the current configuration." + }, + "dataUploadNotificationSuccess": { + "message": "The current configuration has been successfully saved to the cloud storage. It will be available to other devices once the data is synced by the browser.", + "description": "The current configuration has been successfully saved to the cloud storage. It will be available to other devices once the data is synced by the browser." + }, + "dataDownloadNotificationConfirm": { + "message": "All settings and gestures will be replaced with the configuration from the cloud.", + "description": "All settings and gestures will be replaced with the configuration from the cloud." + }, + "dataDownloadNotificationSuccess": { + "message": "The configuration from the cloud has been successfully restored.", + "description": "The configuration from the cloud has been successfully restored." + }, + "dataResetNotificationConfirm": { + "message": "All settings and gestures will be reset. This cannot be undone!", + "description": "All settings and gestures will be reset. This cannot be undone!" + }, + "aboutLicense": { "message": "License:", "description": "License:" @@ -348,27 +410,6 @@ "description": "Add-on page" }, - "aboutRestoreNotificationNoConfigFile": { - "message": "The selected file is not a valid Gesturefy config file.", - "description": "The selected file is not a valid Gesturefy config file." - }, - "aboutRestoreNotificationWrongFile": { - "message": "The selected file does not match the required file type.", - "description": "The selected file does not match the required file type." - }, - "aboutRestoreNotificationConfirm": { - "message": "All settings including gestures will be replaced by the selected config file.", - "description": "All settings including gestures will be replaced by the selected config file." - }, - "aboutRestoreNotificationSuccess": { - "message": "The selected config has been successfully restored.", - "description": "The selected config has been successfully restored." - }, - "aboutResetNotificationConfirm": { - "message": "All settings including gestures will be reset. This cannot be undone!", - "description": "All settings including gestures will be reset. This cannot be undone!" - }, - "commandLabelToggleBookmark": { "message": "Toggle bookmark", "description": "Toggle bookmark" @@ -1699,5 +1740,52 @@ "welcomePageLowerSectionLearnMoreLink": { "message": "Learn More", "description": "Learn More" + }, + + "popupProhibitedPageWarning": { + "message": "Add-ons are prohibited on this page", + "description": "Add-ons are prohibited on this page" + }, + "popupMissingPermissionButton": { + "message": "Gesturefy requires additional permissions", + "description": "Gesturefy requires additional permissions" + }, + "popupMissingPermissionButtonTooltip": { + "message": "Click to request necessary permissions and enable Gesturefy.", + "description": "Click to request necessary permissions and enable Gesturefy." + }, + "popupExclusionsToggleButton": { + "message": "Allow gestures on $DOMAIN$", + "description": "Allow gestures on $DOMAIN$", + "placeholders": { + "domain" : { + "content" : "$1", + "example" : "www.example.com" + } + } + }, + "popupExclusionsToggleButtonOffTooltip": { + "message": "Click to enable Gesturefy on $DOMAIN$", + "description": "Click to enable Gesturefy on $DOMAIN$", + "placeholders": { + "domain" : { + "content" : "$1", + "example" : "www.example.com" + } + } + }, + "popupExclusionsToggleButtonOnTooltip": { + "message": "Click to disable Gesturefy on $DOMAIN$", + "description": "Click to disable Gesturefy on $DOMAIN$", + "placeholders": { + "domain" : { + "content" : "$1", + "example" : "www.example.com" + } + } + }, + "popupOpenSettingsButton": { + "message": "Open settings", + "description": "Open settings" } -} +} \ No newline at end of file diff --git a/src/core/background.mjs b/src/core/background.mjs index d53721b21..d2ccbd3d2 100644 --- a/src/core/background.mjs +++ b/src/core/background.mjs @@ -1,11 +1,17 @@ -import { displayNotification } from "/core/utils/commons.mjs"; +import { displayNotification, getActiveTab } from "/core/utils/commons.mjs"; -import ConfigManager from "/core/helpers/config-manager.mjs"; +import ConfigManager from "/core/services/config-manager.mjs"; import Gesture from "/core/models/gesture.mjs"; import Command from "/core/models/command.mjs"; +import DefaultConfig from "/resources/configs/defaults.mjs"; + +import ExclusionService from "/core/services/exclusion-service.mjs"; + +import HostPermissionService from "/core/services/host-permission-service.mjs"; + import { getClosestGestureByPattern } from "/core/utils/matching-algorithms.mjs"; import "/core/helpers/message-router.mjs"; @@ -13,10 +19,15 @@ import "/core/helpers/message-router.mjs"; // temporary data migration import "/core/migration.mjs"; -const Config = new ConfigManager("local", browser.runtime.getURL("resources/json/defaults.json")); - Config.autoUpdate = true; - Config.loaded.then(updateVariablesOnConfigChange); - Config.addEventListener("change", updateVariablesOnConfigChange); +const Config = new ConfigManager({ + defaults: DefaultConfig, + autoUpdate: true +}); +Config.loaded.then(updateVariablesOnConfigChange); +Config.addEventListener("change", updateVariablesOnConfigChange); + +const Exclusions = new ExclusionService(); +const HostPermissions = new HostPermissionService(); const MouseGestures = new Set(); @@ -36,7 +47,7 @@ function updateVariablesOnConfigChange () { RockerGestureRight = new Command(Config.get("Settings.Rocker.rightMouseClick")); WheelGestureUp = new Command(Config.get("Settings.Wheel.wheelUp")); WheelGestureDown = new Command(Config.get("Settings.Wheel.wheelDown")); - } +} /** @@ -147,12 +158,34 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { /** - * Handle browser action click - * Open Gesturefy options page + * Listen for tab, permission and exclusion changes + * Set the browser action icon to enabled or disabled state **/ -browser.browserAction.onClicked.addListener(() => { - browser.runtime.openOptionsPage(); -}); +browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (tab.active) { + handleBrowserActionIcon(); + } +}, { properties: ["url", "status"] }); +browser.tabs.onActivated.addListener(handleBrowserActionIcon); +HostPermissions.addEventListener("change", handleBrowserActionIcon); +Exclusions.loaded.then(handleBrowserActionIcon); +Exclusions.addEventListener("change", handleBrowserActionIcon); +// on initial run +handleBrowserActionIcon(); + +async function handleBrowserActionIcon() { + const activeTab = await getActiveTab(); + const hasPermission = + activeTab.url != null && + Exclusions.isEnabledFor(activeTab.url) && + (await HostPermissions.hasTabPermission(activeTab)); + + browser.action.setIcon({ + path: hasPermission + ? "/resources/img/icon.svg" + : "/resources/img/icon_deactivated.svg" + }); +} /** diff --git a/src/core/bundle/background.html b/src/core/bundle/background.html deleted file mode 100644 index b72df55e6..000000000 --- a/src/core/bundle/background.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Gesturefy - - - - - diff --git a/src/core/bundle/content.bundle.js b/src/core/bundle/content.bundle.js index 87cbb94b5..0737a2328 100644 --- a/src/core/bundle/content.bundle.js +++ b/src/core/bundle/content.bundle.js @@ -1,10 +1,9 @@ 'use strict'; /** - * get JSON file as object from url - * returns a promise which is fulfilled with the json object as a parameter + * get HTML file as fragment from url + * returns a promise which is fulfilled with the fragment * otherwise it's rejected - * request url needs permissions in the addon manifest **/ @@ -46,6 +45,20 @@ function toSingleButton (pressedButton) { } +/** + * check if string is an url + **/ +function isURL (string) { + try { + new URL(string); + } + catch (e) { + return false; + } + return true; +} + + /** * returns the closest html parent element that matches the conditions of the provided test function or null **/ @@ -337,34 +350,27 @@ class ConfigManager { * For the first parameter either "local" or "sync" is allowed. * An URL to a JSON formatted file can be passed optionally. The containing properties will be treated as the defaults. **/ - constructor (storageArea, defaultsURL) { + constructor ({ + storageArea = "local", + defaults = {}, + autoUpdate = false + }) { if (storageArea !== "local" && storageArea !== "sync") { - throw "The first argument must be a storage area in form of a string containing either local or sync."; + throw "storageArea must either \"local\" or \"sync\"."; } - if (typeof defaultsURL !== "string" && defaultsURL !== undefined) { - throw "The second argument must be an URL to a JSON file."; + if (!isObject(defaults)) { + throw "defaults must be an object."; } this._storageArea = storageArea; // empty object as default value so the config doesn't have to be loaded this._storage = {}; - this._defaults = {}; - - const fetchResources = [ browser.storage[this._storageArea].get() ]; - if (typeof defaultsURL === "string") { - const defaultsObject = new Promise((resolve, reject) => { - fetch(defaultsURL, {mode:'same-origin'}) - .then(res => res.json()) - .then(obj => resolve(obj), err => reject(err)); - }); - fetchResources.push( defaultsObject ); - } - // load resources - this._loaded = Promise.all(fetchResources); - // store resources when loaded - this._loaded.then((values) => { - if (values[0]) this._storage = values[0]; - if (values[1]) this._defaults = values[1]; + this._defaults = defaults; + + this._loaded = browser.storage[this._storageArea].get(); + // store config when loaded + this._loaded.then((value) => { + if (value) this._storage = value; }); // holds all custom event callbacks @@ -372,7 +378,7 @@ class ConfigManager { 'change': new Set() }; // defines if the storage should be automatically loaded und updated on storage changes - this._autoUpdate = false; + this._autoUpdate = autoUpdate; // setup on storage change handler browser.storage.onChanged.addListener((changes, areaName) => { if (areaName === this._storageArea) { @@ -574,6 +580,496 @@ class ConfigManager { } } +var DefaultConfig = Object.freeze({ + "Settings": { + "Gesture": { + "mouseButton": 2, + "suppressionKey": "", + "distanceThreshold": 10, + "deviationTolerance": 0.15, + "matchingAlgorithm": "combined", + "Timeout": { + "active": false, + "duration": 1 + }, + "Trace": { + "display": true, + "Style": { + "strokeStyle": "#00aaa0cc", + "lineWidth": 10, + "lineGrowth": true + } + }, + "Command": { + "display": true, + "Style": { + "fontColor": "#ffffffff", + "backgroundColor": "#00000080", + "fontSize": "7vh", + "horizontalPosition": 50, + "verticalPosition": 40 + } + } + }, + "Rocker": { + "active": false, + "leftMouseClick": { + "name": "PageBack" + }, + "rightMouseClick": { + "name": "PageForth" + } + }, + "Wheel": { + "active": false, + "mouseButton": 1, + "wheelSensitivity": 30, + "wheelUp": { + "name": "FocusRightTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + }, + "wheelDown": { + "name": "FocusLeftTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + } + }, + "General": { + "updateNotification": true, + "theme": "light" + } + }, + "Gestures": [ + { + "pattern": [ + [ + -37, + -25 + ], + [ + -88, + -11 + ], + [ + -50, + 17 + ], + [ + -63, + 62 + ], + [ + -22, + 68 + ], + [ + 4, + 50 + ], + [ + 33, + 49 + ], + [ + 84, + 43 + ], + [ + 105, + -4 + ], + [ + 46, + -24 + ], + [ + 22, + -27 + ], + [ + 8, + -23 + ], + [ + -4, + -44 + ], + [ + -16, + -17 + ], + [ + -56, + -17 + ], + [ + -77, + 8 + ] + ], + "command": { + "name": "OpenAddonSettings" + } + }, + { + "pattern": [ + [ + -1, + -1 + ] + ], + "command": { + "name": "FocusLeftTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + } + }, + { + "pattern": [ + [ + 1, + -1 + ] + ], + "command": { + "name": "FocusRightTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + } + }, + { + "pattern": [ + [ + 0, + 1 + ] + ], + "command": { + "name": "ScrollBottom", + "settings": { + "duration": 100 + } + } + }, + { + "pattern": [ + [ + 0, + -1 + ] + ], + "command": { + "name": "ScrollTop", + "settings": { + "duration": 100 + } + } + }, + { + "pattern": [ + [ + 1, + 0 + ] + ], + "command": { + "name": "PageForth" + } + }, + { + "pattern": [ + [ + -1, + 0 + ] + ], + "command": { + "name": "PageBack" + } + }, + { + "pattern": [ + [ + -145, + -16 + ], + [ + -82, + 21 + ], + [ + -77, + 67 + ], + [ + -31, + 60 + ], + [ + -2, + 96 + ], + [ + 25, + 55 + ], + [ + 53, + 42 + ], + [ + 192, + 7 + ], + [ + 75, + -14 + ] + ], + "command": { + "name": "ReloadTab", + "settings": { + "cache": true + } + } + }, + { + "pattern": [ + [ + 300, + -10 + ], + [ + -300, + -20 + ] + ], + "command": { + "name": "CloseTab", + "settings": { + "nextFocus": "default", + "closePinned": true + } + } + }, + { + "pattern": [ + [ + 21, + 300 + ], + [ + 17, + -300 + ] + ], + "command": { + "name": "NewTab", + "settings": { + "position": "default", + "focus": true + } + } + } + ], + "Exclusions": [] +}); + +/** + * Abstract class that can be used to implement basic event listener functionality. + **/ +class BaseEventListener { + /** + * Requires an array of event specifiers as strings that can later be used to call and register events. + **/ + constructor (events) { + // holds all custom event callbacks + this._events = new Map( + events.map((e) => [e, new Set()]) + ); + } + + /** + * Adds an event listener. + * Requires an event specifier as a string and a callback method. + **/ + addEventListener (event, callback) { + this._validateEventParameter(event); + this._validateCallbackParameter(callback); + this._events.get(event).add(callback); + } + + /** + * Checks if an event listener exists. + * Requires an event specifier as a string and a callback method. + **/ + hasEventListener (event, callback) { + this._validateEventParameter(event); + this._validateCallbackParameter(callback); + this._events.get(event).has(callback); + } + + /** + * Removes an event listener. + * Requires an event specifier as a string and a callback method. + **/ + removeEventListener (event, callback) { + this._validateEventParameter(event); + this._validateCallbackParameter(callback); + this._events.get(event).delete(callback); + } + + /** + * Remove all event listeners for the given event. + **/ + clearEventListeners(event) { + this._validateEventParameter(event); + this._events.get(event).clear(); + } + + /** + * Validate event parameter. + **/ + _validateEventParameter (event) { + if (!this._events.has(event)) { + throw "The first argument is not a valid event."; + } + } + + /** + * Validate callback parameter. + **/ + _validateCallbackParameter (callback) { + if (typeof callback !== "function") { + throw "The second argument must be a function."; + } + } +} + +/** + * Service for adding and removing exclusions. + * + * Provides synchronous methods for adding, removing and checking globs/match patterns. + * This will also automatically update the underlying storage and update itself whenever the underlying storage changes. + **/ +class ExclusionService extends BaseEventListener { + constructor () { + // set available event specifiers + super(['change']); + // empty array as default value so the config doesn't have to be loaded + this._exclusions = []; + // setup on storage change handler + this._listener = this._storageChangeHandler.bind(this); + browser.storage.onChanged.addListener(this._listener); + // load initial storage data + this._loaded = browser.storage.local.get('Exclusions'); + // store exclusions when loaded + this._loaded.then((value) => { + const exclusions = value['Exclusions']; + if (Array.isArray(exclusions) && this._exclusions.length === 0) { + this._exclusions = exclusions; + } + }); + } + + _storageChangeHandler(changes, areaName) { + if (areaName === 'local' && changes.hasOwnProperty('Exclusions')) { + const newExclusions = changes['Exclusions'].newValue; + const oldExclusions = changes['Exclusions'].oldValue; + // check for any changes + if (newExclusions?.length !== oldExclusions?.length || + newExclusions.some((val, i) => val !== oldExclusions[i]) + ) { + this._exclusions = newExclusions; + // execute event callbacks + this._events.get('change').forEach((callback) => callback(newExclusions)); + } + } + } + + /** + * Promise that resolves when the initial data from the storage is loaded. + **/ + get loaded () { + return this._loaded; + } + + isEnabledFor(url) { + return !this.isDisabledFor(url); + } + + isDisabledFor(url) { + return this._exclusions.some( + (glob) => this._globToRegex(glob).test(url) + ); + } + + /** + * Removes all exclusions that match the given URL + **/ + enableFor(url) { + if (!isURL(url)) { + return; + } + const tailoredExclusions = this._exclusions.filter( + (glob) => !this._globToRegex(glob).test(url) + ); + if (tailoredExclusions.length < this._exclusions.length) { + this._exclusions = tailoredExclusions; + return browser.storage.local.set({'Exclusions': this._exclusions}); + } + } + + /** + * Adds an exclusion for the domain of the given URL if there isn't a matching one already. + **/ + disableFor(url) { + if (!isURL(url) || this.isDisabledFor(url)) { + return; + } + const urlObj = new URL(url); + let globPattern; + if (urlObj.protocol === 'file:') { + globPattern = urlObj.href; + } + else { + globPattern = `*://${urlObj.hostname}/*`; + } + this._exclusions.push(globPattern); + return browser.storage.local.set({'Exclusions': this._exclusions}); + } + + /** + * Cleanup service resources and dependencies + **/ + dispose() { + browser.storage.onChanged.removeListener(this._listener); + } + + /** + * Converts a glob/url pattern to a RegExp. + **/ + _globToRegex(glob) { + // match special regex characters + const pattern = glob.replace( + /[-[\]{}()*+?.,\\^$|#\s]/g, + // replace * with .* -> matches anything 0 or more times, else escape character + (match) => match === '*' ? '.*' : '\\'+match, + ); + // ^ matches beginning of input and $ matches ending of input + return new RegExp('^'+pattern+'$'); + } +} + // global static variables const LEFT_MOUSE_BUTTON$2 = 1; @@ -2169,11 +2665,20 @@ window.getClosestElement = getClosestElement; const IS_EMBEDDED_FRAME = isEmbeddedFrame(); -const Config = new ConfigManager("local", browser.runtime.getURL("resources/json/defaults.json")); - Config.autoUpdate = true; - Config.loaded.then(main); +const Exclusions = new ExclusionService(); + Exclusions.addEventListener("change", main); + +const Config = new ConfigManager({ + defaults: DefaultConfig, + autoUpdate: true + }); Config.addEventListener("change", main); +Promise.all([ + Config.loaded, + Exclusions.loaded +]).then(main); + // re-run main function if event listeners got removed // this is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1726978 ListenerObserver.onDetach.addListener(main); @@ -2396,9 +2901,7 @@ function handleRockerAndWheelEvents (subject, event) { * Applies the user config to the particular controller or interface * Enables or disables the appropriate controller **/ -async function main () { - await Config.loaded; - +function main () { // apply hidden settings if (Config.has("Settings.Gesture.patternDifferenceThreshold")) { patternConstructor.differenceThreshold = Config.get("Settings.Gesture.patternDifferenceThreshold"); @@ -2428,8 +2931,7 @@ async function main () { PopupCommandView.theme = Config.get("Settings.General.theme"); - // check if current url is not listed in the exclusions - if (!Config.get("Exclusions").some(matchesCurrentURL)) { + if (Exclusions.isEnabledFor(window.location.href)) { // enable mouse gesture controller MouseGestureController.enable(); @@ -2449,25 +2951,9 @@ async function main () { WheelGestureController.disable(); } } - // if url is excluded disable everything else { MouseGestureController.disable(); RockerGestureController.disable(); WheelGestureController.disable(); } } - - -/** - * checks if the given url is a subset of the current url or equal - * NOTE: window.location.href is returning the frame URL for frames and not the tab URL - **/ -function matchesCurrentURL (urlPattern) { - // match special regex characters - const pattern = urlPattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, (match) => { - // replace * with .* -> matches anything 0 or more times, else escape character - return match === '*' ? '.*' : '\\'+match; - }); - // ^ matches beginning of input and $ matches ending of input - return new RegExp('^'+pattern+'$').test(window.location.href); -} diff --git a/src/core/commands.mjs b/src/core/commands.mjs index 3b4b84390..2e7d7deed 100644 --- a/src/core/commands.mjs +++ b/src/core/commands.mjs @@ -205,20 +205,21 @@ export async function ReloadTab (sender, data) { export async function StopLoading (sender, data) { - // returns the ready state of each frame as an array - const stopLoadingResults = await browser.tabs.executeScript(sender.tab.id, { - code: `{ + // returns the ready state in a result object of each frame as an array + const stopLoadingResults = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + allFrames: true + }, + injectImmediately: true, + func: () => { const readyState = document.readyState; window.stop(); - readyState; - }`, - runAt: 'document_start', - matchAboutBlank: true, - allFrames: true + return readyState; + } }); - // if at least one frame was not finished loading - if (stopLoadingResults.some(readyState => readyState !== "complete")) { + if (stopLoadingResults.some(result => result.result !== "complete")) { // confirm success return true; } @@ -227,11 +228,14 @@ export async function StopLoading (sender, data) { export async function ReloadFrame (sender, data) { if (sender.frameId) { - await browser.tabs.executeScript(sender.tab.id, { - code: `window.location.reload(${Boolean(this.getSetting("cache"))})`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: sender.frameId + await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId ] + }, + injectImmediately: true, + func: (bypassCache) => window.location.reload(bypassCache), + args: [ Boolean(this.getSetting("cache")) ] }); // confirm success return true; @@ -394,37 +398,51 @@ export async function ToggleReaderMode (sender, data) { export async function ScrollTop (sender, data) { + // content script code + function contentScrollTop (duration, scrollMain) { + let scrollableElement, canScrollUp; + if (scrollMain) { + scrollableElement = document.scrollingElement; + canScrollUp = isScrollableY(scrollableElement) && scrollableElement.scrollTop > 0; + } + else { + scrollableElement = getClosestElement(TARGET, isScrollableY); + canScrollUp = scrollableElement && scrollableElement.scrollTop > 0; + } + + if (canScrollUp) { + scrollToY(0, duration, scrollableElement); + } + return [!!scrollableElement, canScrollUp]; + } + // returns true if there exists a scrollable element in the injected frame // which can be scrolled upwards else false - let [[hasScrollableElement, canScrollUp]] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = getClosestElement(TARGET, isScrollableY); - const canScrollUp = scrollableElement && scrollableElement.scrollTop > 0; - if (canScrollUp) { - scrollToY(0, ${Number(this.getSetting("duration"))}, scrollableElement); - } - [!!scrollableElement, canScrollUp]; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: sender.frameId ?? 0 + let [{result: [hasScrollableElement, canScrollUp]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId ?? 0 ] + }, + injectImmediately: true, + func: contentScrollTop, + args: [ + Number(this.getSetting("duration")) + ] }); - // if there was no scrollable element and the gesture was triggered from a frame // try scrolling the main scrollbar of the main frame if (!hasScrollableElement && sender.frameId !== 0) { - [canScrollUp] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = document.scrollingElement; - const canScrollUp = isScrollableY(scrollableElement) && scrollableElement.scrollTop > 0; - if (canScrollUp) { - scrollToY(0, ${Number(this.getSetting("duration"))}, scrollableElement); - } - canScrollUp; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: 0 + [{result: [hasScrollableElement, canScrollUp]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ 0 ] + }, + injectImmediately: true, + func: contentScrollTop, + args: [ + Number(this.getSetting("duration")), + true + ] }); } // confirm success/failure @@ -433,47 +451,57 @@ export async function ScrollTop (sender, data) { export async function ScrollBottom (sender, data) { + // content script code + function contentScrollBottom (duration, scrollMain) { + let scrollableElement, canScrollDown; + if (scrollMain) { + scrollableElement = document.scrollingElement; + canScrollDown = isScrollableY(scrollableElement) && + scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; + } + else { + scrollableElement = getClosestElement(TARGET, isScrollableY); + canScrollDown = scrollableElement && + scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; + } + + if (canScrollDown) { + scrollToY( + scrollableElement.scrollHeight - scrollableElement.clientHeight, + duration, + scrollableElement + ); + } + return [!!scrollableElement, canScrollDown]; + } + // returns true if there exists a scrollable element in the injected frame // which can be scrolled downwards else false - let [[hasScrollableElement, canScrollDown]] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = getClosestElement(TARGET, isScrollableY); - const canScrollDown = scrollableElement && - scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; - if (canScrollDown) { - scrollToY( - scrollableElement.scrollHeight - scrollableElement.clientHeight, - ${Number(this.getSetting("duration"))}, - scrollableElement - ); - } - [!!scrollableElement, canScrollDown]; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: sender.frameId ?? 0 + let [{result: [hasScrollableElement, canScrollDown]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId ?? 0 ] + }, + injectImmediately: true, + func: contentScrollBottom, + args: [ + Number(this.getSetting("duration")) + ] }); - // if there was no scrollable element and the gesture was triggered from a frame // try scrolling the main scrollbar of the main frame if (!hasScrollableElement && sender.frameId !== 0) { - [canScrollDown] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = document.scrollingElement; - const canScrollDown = isScrollableY(scrollableElement) && - scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; - if (canScrollDown) { - scrollToY( - scrollableElement.scrollHeight - scrollableElement.clientHeight, - ${Number(this.getSetting("duration"))}, - scrollableElement - ); - } - canScrollDown; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: 0 + [{result: [hasScrollableElement, canScrollDown]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ 0 ] + }, + injectImmediately: true, + func: contentScrollBottom, + args: [ + Number(this.getSetting("duration")), + true + ] }); } // confirm success/failure @@ -482,47 +510,59 @@ export async function ScrollBottom (sender, data) { export async function ScrollPageUp (sender, data) { + // content script code + function contentScrollUp (duration, scrollRatio, scrollMain) { + let scrollableElement, canScrollUp; + if (scrollMain) { + scrollableElement = document.scrollingElement; + canScrollUp = isScrollableY(scrollableElement) && scrollableElement.scrollTop > 0; + } + else { + scrollableElement = getClosestElement(TARGET, isScrollableY); + canScrollUp = scrollableElement && scrollableElement.scrollTop > 0; + } + + if (canScrollUp) { + scrollToY( + scrollableElement.scrollTop - scrollableElement.clientHeight * scrollRatio, + duration, + scrollableElement + ); + } + return [!!scrollableElement, canScrollUp]; + } + const scrollRatio = Number(this.getSetting("scrollProportion")) / 100; // returns true if there exists a scrollable element in the injected frame // which can be scrolled upwards else false - let [[hasScrollableElement, canScrollUp]] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = getClosestElement(TARGET, isScrollableY); - const canScrollUp = scrollableElement && scrollableElement.scrollTop > 0; - if (canScrollUp) { - scrollToY( - scrollableElement.scrollTop - scrollableElement.clientHeight * ${scrollRatio}, - ${Number(this.getSetting("duration"))}, - scrollableElement - ); - } - [!!scrollableElement, canScrollUp]; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: sender.frameId ?? 0 + let [{result: [hasScrollableElement, canScrollUp]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId ?? 0 ] + }, + injectImmediately: true, + func: contentScrollUp, + args: [ + Number(this.getSetting("duration")), + scrollRatio + ] }); - // if there was no scrollable element and the gesture was triggered from a frame // try scrolling the main scrollbar of the main frame if (!hasScrollableElement && sender.frameId !== 0) { - [canScrollUp] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = document.scrollingElement; - const canScrollUp = isScrollableY(scrollableElement) && scrollableElement.scrollTop > 0; - if (canScrollUp) { - scrollToY( - scrollableElement.scrollTop - scrollableElement.clientHeight * ${scrollRatio}, - ${Number(this.getSetting("duration"))}, - scrollableElement - ); - } - canScrollUp; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: 0 + [{result: [hasScrollableElement, canScrollUp]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ 0 ] + }, + injectImmediately: true, + func: contentScrollUp, + args: [ + Number(this.getSetting("duration")), + scrollRatio, + true + ] }); } // confirm success/failure @@ -531,49 +571,61 @@ export async function ScrollPageUp (sender, data) { export async function ScrollPageDown (sender, data) { + // content script code + function contentScrollDown (duration, scrollRatio, scrollMain) { + let scrollableElement, canScrollDown; + if (scrollMain) { + scrollableElement = document.scrollingElement; + canScrollDown = isScrollableY(scrollableElement) && + scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; + } + else { + scrollableElement = getClosestElement(TARGET, isScrollableY); + canScrollDown = scrollableElement && + scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; + } + + if (canScrollDown) { + scrollToY( + scrollableElement.scrollTop + scrollableElement.clientHeight * scrollRatio, + duration, + scrollableElement + ); + } + return [!!scrollableElement, canScrollUp]; + } + const scrollRatio = Number(this.getSetting("scrollProportion")) / 100; // returns true if there exists a scrollable element in the injected frame - // which can be scrolled downwards else false - let [[hasScrollableElement, canScrollDown]] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = getClosestElement(TARGET, isScrollableY); - const canScrollDown = scrollableElement && - scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; - if (canScrollDown) { - scrollToY( - scrollableElement.scrollTop + scrollableElement.clientHeight * ${scrollRatio}, - ${Number(this.getSetting("duration"))}, - scrollableElement - ); - } - [!!scrollableElement, canScrollDown]; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: sender.frameId ?? 0 + // which can be scrolled upwards else false + let [{result: [hasScrollableElement, canScrollDown]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId ?? 0 ] + }, + injectImmediately: true, + func: contentScrollDown, + args: [ + Number(this.getSetting("duration")), + scrollRatio + ] }); - // if there was no scrollable element and the gesture was triggered from a frame // try scrolling the main scrollbar of the main frame if (!hasScrollableElement && sender.frameId !== 0) { - [canScrollDown] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const scrollableElement = document.scrollingElement; - const canScrollDown = isScrollableY(scrollableElement) && - scrollableElement.scrollTop < scrollableElement.scrollHeight - scrollableElement.clientHeight; - if (canScrollDown) { - scrollToY( - scrollableElement.scrollTop + scrollableElement.clientHeight * ${scrollRatio}, - ${Number(this.getSetting("duration"))}, - scrollableElement - ); - } - canScrollDown; - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: 0 + [{result: [hasScrollableElement, canScrollDown]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ 0 ] + }, + injectImmediately: true, + func: contentScrollDown, + args: [ + Number(this.getSetting("duration")), + scrollRatio, + true + ] }); } // confirm success/failure @@ -1700,11 +1752,13 @@ export async function OpenURLFromClipboardInNewPrivateWindow (sender, data) { export async function PasteClipboard (sender, data) { - await browser.tabs.executeScript(sender.tab.id, { - code: 'document.execCommand("paste")', - runAt: 'document_start', - matchAboutBlank: true, - frameId: sender.frameId ?? 0 + await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId ?? 0 ] + }, + injectImmediately: true, + func: () => document.execCommand("paste") }); // confirm success return true; @@ -1712,11 +1766,16 @@ export async function PasteClipboard (sender, data) { export async function InsertCustomText (sender, data) { - const text = this.getSetting('text'); - - const [result] = await browser.tabs.executeScript(sender.tab.id, { - code: `{ - const insertionText = '${text}'; + const [{result: result}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId ?? 0 ] + }, + injectImmediately: true, + args: [ + this.getSetting('text') + ], + func: (insertionText) => { const target = document.activeElement; if (Number.isInteger(target.selectionStart) && !target.disabled && !target.readOnly) { const newSelection = target.selectionStart + insertionText.length; @@ -1726,22 +1785,16 @@ export async function InsertCustomText (sender, data) { target.value.substring(target.selectionEnd); target.selectionStart = newSelection; target.selectionEnd = newSelection; - true; + return true; } else if (target.isContentEditable) { const range = window.getSelection().getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(insertionText)); range.collapse(); - true; - } - else { - false; + return true; } - }`, - runAt: 'document_start', - matchAboutBlank: true, - frameId: sender.frameId ?? 0 + } }); // confirm success return result; @@ -1899,41 +1952,47 @@ export async function SaveImage (sender, data) { // add referer header, because some websites modify the image if the referer is missing // get referrer from content script - const documentValues = (await browser.tabs.executeScript(sender.tab.id, { - code: "({ referrer: document.referrer, url: window.location.href })", - runAt: "document_start", - matchAboutBlank: true, - frameId: sender.frameId ?? 0 - }))[0]; + const [{result: [ documentReferer, documentUrl ]}] = await browser.scripting.executeScript({ + target: { + tabId: sender.tab.id, + frameIds: [ sender.frameId || 0 ] + }, + injectImmediately: true, + func: () => [ document.referrer, window.location.href ] + }); // if the image is embedded in a website use the url of that website as the referer - if (data.target.src !== documentValues.url) { + if (data.target.src !== documentUrl) { // emulate no-referrer-when-downgrade // The origin, path, and querystring of the URL are sent as a referrer when the protocol security level stays the same (HTTP→HTTP, HTTPS→HTTPS) // or improves (HTTP→HTTPS), but isn't sent to less secure destinations (HTTPS→HTTP). - if (!(new URL(documentValues.url).protocol === "https:" && imageURLObject.protocol === "http:")) { - queryOptions.headers = [ { name: "Referer", value: documentValues.url.split("#")[0] } ]; + if (!(new URL(documentUrl).protocol === "https:" && imageURLObject.protocol === "http:")) { + queryOptions.headers = [ { name: "Referer", value: documentUrl.split("#")[0] } ]; } } // if the image is not embedded, but a referrer is set use the referrer - else if (documentValues.referrer) { - queryOptions.headers = [ { name: "Referer", value: documentValues.referrer } ]; + else if (documentReferer) { + queryOptions.headers = [ { name: "Referer", value: documentReferer } ]; } - // download image - const downloadId = await browser.downloads.download(queryOptions); - + let downloadId; // if data url then assume a blob file was created and clear its url if (imageURLObject.protocol === "data:") { // catch error and free the blob for gc if (browser.runtime.lastError) URL.revokeObjectURL(queryOptions.url); else browser.downloads.onChanged.addListener(function clearURL(downloadDelta) { - if (downloadId === downloadDelta.id && downloadDelta.state.current === "complete") { + if ( + downloadId === downloadDelta.id && + (downloadDelta.state.current === "complete" || downloadDelta.state.current === "interrupted") + ) { URL.revokeObjectURL(queryOptions.url); browser.downloads.onChanged.removeListener(clearURL); } }); } + // download image + downloadId = await browser.downloads.download(queryOptions); + // confirm success return true; } @@ -2271,7 +2330,7 @@ export async function ExecuteUserScript (sender, data) { case "sourceFrame": default: - messageOptions.frameId = sender.frameId || 0; + messageOptions.frameId = sender.frameId ?? 0; break; } diff --git a/src/core/content.mjs b/src/core/content.mjs index 0acb21350..2edae72ed 100644 --- a/src/core/content.mjs +++ b/src/core/content.mjs @@ -2,7 +2,11 @@ import { isEmbeddedFrame, isEditableInput, isScrollableY, scrollToY, getClosestE import GestureContextData, { MouseData } from "/core/models/gesture-context-data.mjs"; -import ConfigManager from "/core/helpers/config-manager.mjs"; +import ConfigManager from "/core/services/config-manager.mjs"; + +import DefaultConfig from "/resources/configs/defaults.mjs"; + +import ExclusionService from "/core/services/exclusion-service.mjs"; import MouseGestureController from "/core/controllers/mouse-gesture-controller.mjs"; @@ -32,11 +36,20 @@ window.getClosestElement = getClosestElement; const IS_EMBEDDED_FRAME = isEmbeddedFrame(); -const Config = new ConfigManager("local", browser.runtime.getURL("resources/json/defaults.json")); - Config.autoUpdate = true; - Config.loaded.then(main); +const Exclusions = new ExclusionService(); + Exclusions.addEventListener("change", main); + +const Config = new ConfigManager({ + defaults: DefaultConfig, + autoUpdate: true + }); Config.addEventListener("change", main); +Promise.all([ + Config.loaded, + Exclusions.loaded +]).then(main); + // re-run main function if event listeners got removed // this is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1726978 ListenerObserver.onDetach.addListener(main); @@ -259,9 +272,7 @@ function handleRockerAndWheelEvents (subject, event) { * Applies the user config to the particular controller or interface * Enables or disables the appropriate controller **/ -async function main () { - await Config.loaded; - +function main () { // apply hidden settings if (Config.has("Settings.Gesture.patternDifferenceThreshold")) { patternConstructor.differenceThreshold = Config.get("Settings.Gesture.patternDifferenceThreshold"); @@ -291,8 +302,7 @@ async function main () { PopupCommandView.theme = Config.get("Settings.General.theme"); - // check if current url is not listed in the exclusions - if (!Config.get("Exclusions").some(matchesCurrentURL)) { + if (Exclusions.isEnabledFor(window.location.href)) { // enable mouse gesture controller MouseGestureController.enable(); @@ -312,25 +322,9 @@ async function main () { WheelGestureController.disable(); } } - // if url is excluded disable everything else { MouseGestureController.disable(); RockerGestureController.disable(); WheelGestureController.disable(); } } - - -/** - * checks if the given url is a subset of the current url or equal - * NOTE: window.location.href is returning the frame URL for frames and not the tab URL - **/ -function matchesCurrentURL (urlPattern) { - // match special regex characters - const pattern = urlPattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, (match) => { - // replace * with .* -> matches anything 0 or more times, else escape character - return match === '*' ? '.*' : '\\'+match; - }); - // ^ matches beginning of input and $ matches ending of input - return new RegExp('^'+pattern+'$').test(window.location.href); -} diff --git a/src/core/migration.mjs b/src/core/migration.mjs index 6e2027bb7..4184a1649 100644 --- a/src/core/migration.mjs +++ b/src/core/migration.mjs @@ -1,4 +1,4 @@ -import ConfigManager from "/core/helpers/config-manager.mjs"; +import ConfigManager from "/core/services/config-manager.mjs"; browser.runtime.onInstalled.addListener(async (details) => { diff --git a/src/core/services/base-event-listener.mjs b/src/core/services/base-event-listener.mjs new file mode 100644 index 000000000..620a55be8 --- /dev/null +++ b/src/core/services/base-event-listener.mjs @@ -0,0 +1,70 @@ +/** + * Abstract class that can be used to implement basic event listener functionality. + **/ +export default class BaseEventListener { + /** + * Requires an array of event specifiers as strings that can later be used to call and register events. + **/ + constructor (events) { + // holds all custom event callbacks + this._events = new Map( + events.map((e) => [e, new Set()]) + ); + } + + /** + * Adds an event listener. + * Requires an event specifier as a string and a callback method. + **/ + addEventListener (event, callback) { + this._validateEventParameter(event); + this._validateCallbackParameter(callback); + this._events.get(event).add(callback); + } + + /** + * Checks if an event listener exists. + * Requires an event specifier as a string and a callback method. + **/ + hasEventListener (event, callback) { + this._validateEventParameter(event); + this._validateCallbackParameter(callback); + this._events.get(event).has(callback); + } + + /** + * Removes an event listener. + * Requires an event specifier as a string and a callback method. + **/ + removeEventListener (event, callback) { + this._validateEventParameter(event); + this._validateCallbackParameter(callback); + this._events.get(event).delete(callback); + } + + /** + * Remove all event listeners for the given event. + **/ + clearEventListeners(event) { + this._validateEventParameter(event); + this._events.get(event).clear(); + } + + /** + * Validate event parameter. + **/ + _validateEventParameter (event) { + if (!this._events.has(event)) { + throw "The first argument is not a valid event."; + } + } + + /** + * Validate callback parameter. + **/ + _validateCallbackParameter (callback) { + if (typeof callback !== "function") { + throw "The second argument must be a function."; + } + } +} diff --git a/src/core/helpers/config-manager.mjs b/src/core/services/config-manager.mjs similarity index 89% rename from src/core/helpers/config-manager.mjs rename to src/core/services/config-manager.mjs index d6dc7a9c3..9e21f227a 100644 --- a/src/core/helpers/config-manager.mjs +++ b/src/core/services/config-manager.mjs @@ -13,34 +13,27 @@ export default class ConfigManager { * For the first parameter either "local" or "sync" is allowed. * An URL to a JSON formatted file can be passed optionally. The containing properties will be treated as the defaults. **/ - constructor (storageArea, defaultsURL) { + constructor ({ + storageArea = "local", + defaults = {}, + autoUpdate = false + }) { if (storageArea !== "local" && storageArea !== "sync") { - throw "The first argument must be a storage area in form of a string containing either local or sync."; + throw "storageArea must either \"local\" or \"sync\"."; } - if (typeof defaultsURL !== "string" && defaultsURL !== undefined) { - throw "The second argument must be an URL to a JSON file."; + if (!isObject(defaults)) { + throw "defaults must be an object."; } this._storageArea = storageArea; // empty object as default value so the config doesn't have to be loaded this._storage = {}; - this._defaults = {}; - - const fetchResources = [ browser.storage[this._storageArea].get() ]; - if (typeof defaultsURL === "string") { - const defaultsObject = new Promise((resolve, reject) => { - fetch(defaultsURL, {mode:'same-origin'}) - .then(res => res.json()) - .then(obj => resolve(obj), err => reject(err)); - }); - fetchResources.push( defaultsObject ); - } - // load resources - this._loaded = Promise.all(fetchResources); - // store resources when loaded - this._loaded.then((values) => { - if (values[0]) this._storage = values[0]; - if (values[1]) this._defaults = values[1]; + this._defaults = defaults; + + this._loaded = browser.storage[this._storageArea].get(); + // store config when loaded + this._loaded.then((value) => { + if (value) this._storage = value; }); // holds all custom event callbacks @@ -48,7 +41,7 @@ export default class ConfigManager { 'change': new Set() }; // defines if the storage should be automatically loaded und updated on storage changes - this._autoUpdate = false; + this._autoUpdate = autoUpdate; // setup on storage change handler browser.storage.onChanged.addListener((changes, areaName) => { if (areaName === this._storageArea) { diff --git a/src/core/services/exclusion-service.mjs b/src/core/services/exclusion-service.mjs new file mode 100644 index 000000000..79336cf3e --- /dev/null +++ b/src/core/services/exclusion-service.mjs @@ -0,0 +1,118 @@ +import { isURL } from "/core/utils/commons.mjs"; + +import BaseEventListener from "/core/services/base-event-listener.mjs"; + +/** + * Service for adding and removing exclusions. + * + * Provides synchronous methods for adding, removing and checking globs/match patterns. + * This will also automatically update the underlying storage and update itself whenever the underlying storage changes. + **/ +export default class ExclusionService extends BaseEventListener { + constructor () { + // set available event specifiers + super(['change']); + // empty array as default value so the config doesn't have to be loaded + this._exclusions = []; + // setup on storage change handler + this._listener = this._storageChangeHandler.bind(this); + browser.storage.onChanged.addListener(this._listener); + // load initial storage data + this._loaded = browser.storage.local.get('Exclusions'); + // store exclusions when loaded + this._loaded.then((value) => { + const exclusions = value['Exclusions']; + if (Array.isArray(exclusions) && this._exclusions.length === 0) { + this._exclusions = exclusions; + } + }); + } + + _storageChangeHandler(changes, areaName) { + if (areaName === 'local' && changes.hasOwnProperty('Exclusions')) { + const newExclusions = changes['Exclusions'].newValue; + const oldExclusions = changes['Exclusions'].oldValue; + // check for any changes + if (newExclusions?.length !== oldExclusions?.length || + newExclusions.some((val, i) => val !== oldExclusions[i]) + ) { + this._exclusions = newExclusions; + // execute event callbacks + this._events.get('change').forEach((callback) => callback(newExclusions)); + } + } + } + + /** + * Promise that resolves when the initial data from the storage is loaded. + **/ + get loaded () { + return this._loaded; + } + + isEnabledFor(url) { + return !this.isDisabledFor(url); + } + + isDisabledFor(url) { + return this._exclusions.some( + (glob) => this._globToRegex(glob).test(url) + ); + } + + /** + * Removes all exclusions that match the given URL + **/ + enableFor(url) { + if (!isURL(url)) { + return; + } + const tailoredExclusions = this._exclusions.filter( + (glob) => !this._globToRegex(glob).test(url) + ); + if (tailoredExclusions.length < this._exclusions.length) { + this._exclusions = tailoredExclusions; + return browser.storage.local.set({'Exclusions': this._exclusions}); + } + } + + /** + * Adds an exclusion for the domain of the given URL if there isn't a matching one already. + **/ + disableFor(url) { + if (!isURL(url) || this.isDisabledFor(url)) { + return; + } + const urlObj = new URL(url); + let globPattern; + if (urlObj.protocol === 'file:') { + globPattern = urlObj.href; + } + else { + globPattern = `*://${urlObj.hostname}/*` + } + this._exclusions.push(globPattern); + return browser.storage.local.set({'Exclusions': this._exclusions}); + } + + /** + * Cleanup service resources and dependencies + **/ + dispose() { + browser.storage.onChanged.removeListener(this._listener); + } + + /** + * Converts a glob/url pattern to a RegExp. + **/ + _globToRegex(glob) { + // match special regex characters + const pattern = glob.replace( + /[-[\]{}()*+?.,\\^$|#\s]/g, + // replace * with .* -> matches anything 0 or more times, else escape character + (match) => match === '*' ? '.*' : '\\'+match, + ); + // ^ matches beginning of input and $ matches ending of input + return new RegExp('^'+pattern+'$'); + } +} diff --git a/src/core/services/host-permission-service.mjs b/src/core/services/host-permission-service.mjs new file mode 100644 index 000000000..7373e17f3 --- /dev/null +++ b/src/core/services/host-permission-service.mjs @@ -0,0 +1,74 @@ +import BaseEventListener from "/core/services/base-event-listener.mjs"; + +/** + * Service for checking and requesting host permissions. + **/ +export default class HostPermissionService extends BaseEventListener { + + constructor () { + // set available event specifiers + super(['change']); + // register change listeners + this._listener = this._permissionChangeHandler.bind(this); + browser.permissions.onAdded.addListener(this._listener); + browser.permissions.onRemoved.addListener(this._listener); + } + + _permissionChangeHandler(permissions) { + if (permissions?.origins.length > 0) { + this._events.get('change').forEach((callback) => callback(permissions.origins)); + } + } + + /** + * Check if add-on was granted global host permissions. + * Returns a Promise with true/false. + **/ + hasGlobalPermission() { + return browser.permissions.contains({ + origins: [''] + }); + } + + /** + * Request global host permissions. + * Returns a Promise with true if the permissions got granted, otherwise false. + **/ + requestGlobalPermission() { + return browser.permissions.request({ + origins: [''] + }); + } + + /** + * Check whether the add-on is allowed to run in the given tab. + * If the add-on is restricted this will return false, otherwise true. + * + * The add-on might be restricted due to missing host permissions or because the tab holds a privileged URL. + **/ + async hasTabPermission(tabOrId) { + // see: https://discourse.mozilla.org/t/detect-whether-extension-has-host-permission-for-active-tab/120501/2 + const tab = Number.isInteger(tabOrId) + ? await browser.tabs.get(tabOrId) + : tabOrId; + try { + return tab.url + ? await browser.permissions.contains({ + origins: [tab.url] + }) + : false; + } + // catch error that occurs for special urls like about: + catch { + return false; + } + } + + /** + * Cleanup service resources and dependencies + **/ + dispose() { + browser.permissions.onAdded.removeListener(this._listener); + browser.permissions.onRemoved.removeListener(this._listener); + } +} diff --git a/src/core/utils/commons.mjs b/src/core/utils/commons.mjs index 9e3cce94f..8ce9cc1d3 100644 --- a/src/core/utils/commons.mjs +++ b/src/core/utils/commons.mjs @@ -1,29 +1,7 @@ /** - * get JSON file as object from url - * returns a promise which is fulfilled with the json object as a parameter + * get HTML file as fragment from url + * returns a promise which is fulfilled with the fragment * otherwise it's rejected - * request url needs permissions in the addon manifest - **/ -export function fetchJSONAsObject (url) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.overrideMimeType("application/json"); - xhr.responseType = "json"; - xhr.timeout = 4000; - xhr.onerror = reject; - xhr.ontimeout = reject; - xhr.onload = () => resolve(xhr.response); - xhr.open('GET', url, true); - xhr.send(); - }); -} - - -/** - * get JSON file as object from url - * returns a promise which is fulfilled with the json object as a parameter - * otherwise it's rejected - * request url needs permissions in the addon manifest **/ export function fetchHTMLAsFragment (url) { return new Promise((resolve, reject) => { @@ -206,7 +184,7 @@ export function displayNotification (title, message, link) { // create notification const createNotification = browser.notifications.create({ "type": "basic", - "iconUrl": "../resources/img/iconx48.png", + "iconUrl": "../resources/img/icon.svg", "title": title, "message": message }); @@ -226,6 +204,17 @@ export function displayNotification (title, message, link) { } +/** + * returns the active tab of the currently active window + **/ +export async function getActiveTab() { + return (await browser.tabs.query({ + active: true, + currentWindow: true, + }))[0]; +} + + /** * returns the closest html parent element that matches the conditions of the provided test function or null **/ diff --git a/src/manifest.json b/src/manifest.json index c72994906..c1b0050ee 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,6 +1,6 @@ { - "manifest_version": 2, - "applications": { + "manifest_version": 3, + "browser_specific_settings": { "gecko": { "id": "{506e023c-7f2b-40a3-8066-bc5deb40aebe}", "strict_min_version": "128.0" @@ -12,14 +12,17 @@ "author": "Robbendebiene", "homepage_url": "https://github.com/Robbendebiene/Gesturefy", "icons": { - "96": "resources/img/iconx96.png", - "48": "resources/img/iconx48.png", - "32": "resources/img/iconx32.png" + "96": "resources/img/icon.svg", + "48": "resources/img/icon.svg", + "32": "resources/img/icon.svg" }, "default_locale": "en", + "host_permissions": [ + "" + ], "permissions": [ - "", "storage", + "scripting", "notifications", "browserSettings" ], @@ -49,22 +52,30 @@ } ], "background": { - "page": "core/bundle/background.html" + "scripts": ["core/background.mjs"], + "type": "module" }, "options_ui": { "page": "views/options/index.html", "open_in_tab": true }, - "browser_action": { + "action": { "default_icon": { - "96": "resources/img/iconx96.png", - "48": "resources/img/iconx48.png", - "32": "resources/img/iconx32.png" + "96": "resources/img/icon.svg", + "48": "resources/img/icon.svg", + "32": "resources/img/icon.svg" }, - "default_title": "__MSG_commandLabelOpenAddonSettings__" + "default_title": "Gesturefy", + "default_popup": "views/popup/index.html", + "default_area": "navbar" }, "web_accessible_resources": [ - "resources/fonts/NunitoSans-Regular.woff", - "core/views/popup-command-view/popup-command-view.html" + { + "resources": [ + "resources/fonts/NunitoSans-Regular.woff", + "core/views/popup-command-view/popup-command-view.html" + ], + "matches": ["*://*/*"] + } ] } diff --git a/src/resources/json/commands.json b/src/resources/configs/commands.mjs similarity index 99% rename from src/resources/json/commands.json rename to src/resources/configs/commands.mjs index 523bf8798..380fd86d3 100644 --- a/src/resources/json/commands.json +++ b/src/resources/configs/commands.mjs @@ -1,4 +1,4 @@ -[ +export default Object.freeze([ { "command": "DuplicateTab", "settings": { @@ -566,4 +566,4 @@ "permissions": ["browsingData"], "group": "advanced" } -] +]); diff --git a/src/resources/json/defaults.json b/src/resources/configs/defaults.mjs similarity index 93% rename from src/resources/json/defaults.json rename to src/resources/configs/defaults.mjs index 9e7aceaa7..7e1de0c81 100644 --- a/src/resources/json/defaults.json +++ b/src/resources/configs/defaults.mjs @@ -1,303 +1,303 @@ -{ - "Settings": { - "Gesture": { - "mouseButton": 2, - "suppressionKey": "", - "distanceThreshold": 10, - "deviationTolerance": 0.15, - "matchingAlgorithm": "combined", - "Timeout": { - "active": false, - "duration": 1 - }, - "Trace": { - "display": true, - "Style": { - "strokeStyle": "#00aaa0cc", - "lineWidth": 10, - "lineGrowth": true - } - }, - "Command": { - "display": true, - "Style": { - "fontColor": "#ffffffff", - "backgroundColor": "#00000080", - "fontSize": "7vh", - "horizontalPosition": 50, - "verticalPosition": 40 - } - } - }, - "Rocker": { - "active": false, - "leftMouseClick": { - "name": "PageBack" - }, - "rightMouseClick": { - "name": "PageForth" - } - }, - "Wheel": { - "active": false, - "mouseButton": 1, - "wheelSensitivity": 30, - "wheelUp": { - "name": "FocusRightTab", - "settings": { - "cycling": true, - "excludeDiscarded": false - } - }, - "wheelDown": { - "name": "FocusLeftTab", - "settings": { - "cycling": true, - "excludeDiscarded": false - } - } - }, - "General": { - "updateNotification": true, - "theme": "light" - } - }, - "Gestures": [ - { - "pattern": [ - [ - -37, - -25 - ], - [ - -88, - -11 - ], - [ - -50, - 17 - ], - [ - -63, - 62 - ], - [ - -22, - 68 - ], - [ - 4, - 50 - ], - [ - 33, - 49 - ], - [ - 84, - 43 - ], - [ - 105, - -4 - ], - [ - 46, - -24 - ], - [ - 22, - -27 - ], - [ - 8, - -23 - ], - [ - -4, - -44 - ], - [ - -16, - -17 - ], - [ - -56, - -17 - ], - [ - -77, - 8 - ] - ], - "command": { - "name": "OpenAddonSettings" - } - }, - { - "pattern": [ - [ - -1, - -1 - ] - ], - "command": { - "name": "FocusLeftTab", - "settings": { - "cycling": true, - "excludeDiscarded": false - } - } - }, - { - "pattern": [ - [ - 1, - -1 - ] - ], - "command": { - "name": "FocusRightTab", - "settings": { - "cycling": true, - "excludeDiscarded": false - } - } - }, - { - "pattern": [ - [ - 0, - 1 - ] - ], - "command": { - "name": "ScrollBottom", - "settings": { - "duration": 100 - } - } - }, - { - "pattern": [ - [ - 0, - -1 - ] - ], - "command": { - "name": "ScrollTop", - "settings": { - "duration": 100 - } - } - }, - { - "pattern": [ - [ - 1, - 0 - ] - ], - "command": { - "name": "PageForth" - } - }, - { - "pattern": [ - [ - -1, - 0 - ] - ], - "command": { - "name": "PageBack" - } - }, - { - "pattern": [ - [ - -145, - -16 - ], - [ - -82, - 21 - ], - [ - -77, - 67 - ], - [ - -31, - 60 - ], - [ - -2, - 96 - ], - [ - 25, - 55 - ], - [ - 53, - 42 - ], - [ - 192, - 7 - ], - [ - 75, - -14 - ] - ], - "command": { - "name": "ReloadTab", - "settings": { - "cache": true - } - } - }, - { - "pattern": [ - [ - 300, - -10 - ], - [ - -300, - -20 - ] - ], - "command": { - "name": "CloseTab", - "settings": { - "nextFocus": "default", - "closePinned": true - } - } - }, - { - "pattern": [ - [ - 21, - 300 - ], - [ - 17, - -300 - ] - ], - "command": { - "name": "NewTab", - "settings": { - "position": "default", - "focus": true - } - } - } - ], - "Exclusions": [] -} +export default Object.freeze({ + "Settings": { + "Gesture": { + "mouseButton": 2, + "suppressionKey": "", + "distanceThreshold": 10, + "deviationTolerance": 0.15, + "matchingAlgorithm": "combined", + "Timeout": { + "active": false, + "duration": 1 + }, + "Trace": { + "display": true, + "Style": { + "strokeStyle": "#00aaa0cc", + "lineWidth": 10, + "lineGrowth": true + } + }, + "Command": { + "display": true, + "Style": { + "fontColor": "#ffffffff", + "backgroundColor": "#00000080", + "fontSize": "7vh", + "horizontalPosition": 50, + "verticalPosition": 40 + } + } + }, + "Rocker": { + "active": false, + "leftMouseClick": { + "name": "PageBack" + }, + "rightMouseClick": { + "name": "PageForth" + } + }, + "Wheel": { + "active": false, + "mouseButton": 1, + "wheelSensitivity": 30, + "wheelUp": { + "name": "FocusRightTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + }, + "wheelDown": { + "name": "FocusLeftTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + } + }, + "General": { + "updateNotification": true, + "theme": "light" + } + }, + "Gestures": [ + { + "pattern": [ + [ + -37, + -25 + ], + [ + -88, + -11 + ], + [ + -50, + 17 + ], + [ + -63, + 62 + ], + [ + -22, + 68 + ], + [ + 4, + 50 + ], + [ + 33, + 49 + ], + [ + 84, + 43 + ], + [ + 105, + -4 + ], + [ + 46, + -24 + ], + [ + 22, + -27 + ], + [ + 8, + -23 + ], + [ + -4, + -44 + ], + [ + -16, + -17 + ], + [ + -56, + -17 + ], + [ + -77, + 8 + ] + ], + "command": { + "name": "OpenAddonSettings" + } + }, + { + "pattern": [ + [ + -1, + -1 + ] + ], + "command": { + "name": "FocusLeftTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + } + }, + { + "pattern": [ + [ + 1, + -1 + ] + ], + "command": { + "name": "FocusRightTab", + "settings": { + "cycling": true, + "excludeDiscarded": false + } + } + }, + { + "pattern": [ + [ + 0, + 1 + ] + ], + "command": { + "name": "ScrollBottom", + "settings": { + "duration": 100 + } + } + }, + { + "pattern": [ + [ + 0, + -1 + ] + ], + "command": { + "name": "ScrollTop", + "settings": { + "duration": 100 + } + } + }, + { + "pattern": [ + [ + 1, + 0 + ] + ], + "command": { + "name": "PageForth" + } + }, + { + "pattern": [ + [ + -1, + 0 + ] + ], + "command": { + "name": "PageBack" + } + }, + { + "pattern": [ + [ + -145, + -16 + ], + [ + -82, + 21 + ], + [ + -77, + 67 + ], + [ + -31, + 60 + ], + [ + -2, + 96 + ], + [ + 25, + 55 + ], + [ + 53, + 42 + ], + [ + 192, + 7 + ], + [ + 75, + -14 + ] + ], + "command": { + "name": "ReloadTab", + "settings": { + "cache": true + } + } + }, + { + "pattern": [ + [ + 300, + -10 + ], + [ + -300, + -20 + ] + ], + "command": { + "name": "CloseTab", + "settings": { + "nextFocus": "default", + "closePinned": true + } + } + }, + { + "pattern": [ + [ + 21, + 300 + ], + [ + 17, + -300 + ] + ], + "command": { + "name": "NewTab", + "settings": { + "position": "default", + "focus": true + } + } + } + ], + "Exclusions": [] +}); diff --git a/src/resources/fonts/icons.woff b/src/resources/fonts/icons.woff index 8ec59ff8d..c156c1e0d 100644 Binary files a/src/resources/fonts/icons.woff and b/src/resources/fonts/icons.woff differ diff --git a/src/resources/img/icon.svg b/src/resources/img/icon.svg new file mode 100644 index 000000000..55d86e4dd --- /dev/null +++ b/src/resources/img/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/resources/img/icon_deactivated.svg b/src/resources/img/icon_deactivated.svg new file mode 100644 index 000000000..7ea68e6c8 --- /dev/null +++ b/src/resources/img/icon_deactivated.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/resources/img/iconx32.png b/src/resources/img/iconx32.png deleted file mode 100644 index d6587b4e0..000000000 Binary files a/src/resources/img/iconx32.png and /dev/null differ diff --git a/src/resources/img/iconx48.png b/src/resources/img/iconx48.png deleted file mode 100644 index 79d30348c..000000000 Binary files a/src/resources/img/iconx48.png and /dev/null differ diff --git a/src/resources/img/iconx96.png b/src/resources/img/iconx96.png deleted file mode 100644 index f469a84e7..000000000 Binary files a/src/resources/img/iconx96.png and /dev/null differ diff --git a/src/views/installation/index.html b/src/views/installation/index.html index adcae0ca9..49f91499f 100644 --- a/src/views/installation/index.html +++ b/src/views/installation/index.html @@ -3,7 +3,7 @@ Gesturefy - Welcome - + @@ -15,7 +15,7 @@
- +

@@ -108,7 +108,7 @@
- +

diff --git a/src/views/options/components/command-multi-select/main.mjs b/src/views/options/components/command-multi-select/main.mjs index 13d8ebf7b..dce99f4ce 100644 --- a/src/views/options/components/command-multi-select/main.mjs +++ b/src/views/options/components/command-multi-select/main.mjs @@ -1,16 +1,17 @@ import { SortableMultiSelect } from "/views/options/components/sortable-multi-select/main.mjs"; -import { fetchJSONAsObject, fetchHTMLAsFragment } from "/core/utils/commons.mjs"; +import { fetchHTMLAsFragment } from "/core/utils/commons.mjs"; import Command from "/core/models/command.mjs"; +import CommandDefinitions from "/resources/configs/commands.mjs"; + // getter for module path const MODULE_DIR = (() => { const urlPath = new URL(import.meta.url).pathname; return urlPath.slice(0, urlPath.lastIndexOf("/") + 1); })(); -const COMMAND_ITEMS = fetchJSONAsObject(browser.runtime.getURL("/resources/json/commands.json")); const COMMAND_SETTING_TEMPLATES = fetchHTMLAsFragment(browser.runtime.getURL("/views/options/fragments/command-setting-templates.inc")); /** @@ -32,15 +33,13 @@ class CommandMultiSelect extends SortableMultiSelect { this.shadowRoot.append(settingsConainer); // build/fill command selection list - COMMAND_ITEMS.then((commandItems) => { - const commandMultiSelectItems = commandItems.map((commandItem) => { - const commandMultiSelectItem = document.createElement("sortable-multi-select-item"); - commandMultiSelectItem.dataset.command = commandItem.command; - commandMultiSelectItem.textContent = browser.i18n.getMessage(`commandLabel${commandItem.command}`); - return commandMultiSelectItem; - }); - this.append(...commandMultiSelectItems); + const commandMultiSelectItems = CommandDefinitions.map((commandItem) => { + const commandMultiSelectItem = document.createElement("sortable-multi-select-item"); + commandMultiSelectItem.dataset.command = commandItem.command; + commandMultiSelectItem.textContent = browser.i18n.getMessage(`commandLabel${commandItem.command}`); + return commandMultiSelectItem; }); + this.append(...commandMultiSelectItems); this._commandSettingsRelation = new WeakMap(); } @@ -229,7 +228,7 @@ class CommandMultiSelect extends SortableMultiSelect { if (commandSelectItem) { // get command object - const commandObject = (await COMMAND_ITEMS).find((element) => { + const commandObject = CommandDefinitions.find((element) => { return element.command === commandSelectItem.dataset.command; }); diff --git a/src/views/options/components/command-select/main.mjs b/src/views/options/components/command-select/main.mjs index b3e946407..574fc3876 100644 --- a/src/views/options/components/command-select/main.mjs +++ b/src/views/options/components/command-select/main.mjs @@ -1,14 +1,15 @@ -import { fetchJSONAsObject, fetchHTMLAsFragment } from "/core/utils/commons.mjs"; +import { fetchHTMLAsFragment } from "/core/utils/commons.mjs"; import Command from "/core/models/command.mjs"; +import CommandDefinitions from "/resources/configs/commands.mjs"; + // getter for module path const MODULE_DIR = (() => { const urlPath = new URL(import.meta.url).pathname; return urlPath.slice(0, urlPath.lastIndexOf("/") + 1); })(); -const COMMAND_ITEMS = fetchJSONAsObject(browser.runtime.getURL("/resources/json/commands.json")); const COMMAND_SETTING_TEMPLATES = fetchHTMLAsFragment(browser.runtime.getURL("/views/options/fragments/command-setting-templates.inc")); /** @@ -185,7 +186,7 @@ class CommandSelect extends HTMLElement { // build command list const groups = new Map(); - for (let commandItem of await COMMAND_ITEMS) { + for (let commandItem of CommandDefinitions) { const item = document.createElement("li"); item.classList.add("cb-command-item"); item.dataset.command = commandItem.command; @@ -491,7 +492,7 @@ class CommandSelect extends HTMLElement { commandItemInfo.style.removeProperty("height"); // get command item - const commandItem = (await COMMAND_ITEMS).find((element) => { + const commandItem = CommandDefinitions.find((element) => { return element.command === event.currentTarget.dataset.command; }); diff --git a/src/views/options/data-management.mjs b/src/views/options/data-management.mjs deleted file mode 100644 index 1ce87a95f..000000000 --- a/src/views/options/data-management.mjs +++ /dev/null @@ -1,157 +0,0 @@ -import { fetchJSONAsObject } from "/core/utils/commons.mjs"; - -import { ContentLoaded, Config } from "/views/options/main.mjs"; - -ContentLoaded.then(main); - -/** - * main function - * run code that depends on async resources - **/ -function main () { - // data management buttons - const resetButton = document.getElementById("resetButton"); - resetButton.onclick = onResetButton; - const backupButton = document.getElementById("backupButton"); - backupButton.onclick = onBackupButton; - const restoreButton = document.getElementById("restoreButton"); - restoreButton.onchange = onRestoreButton; -} - - -/** - * clears the current config so the defaults will be used - * resets all optional permissions - * reloads the options page afterwards - **/ -function onResetButton () { - const popup = document.getElementById("resetConfirm"); - popup.addEventListener("close", (event) => { - if (event.detail) { - const manifest = browser.runtime.getManifest(); - const removePermissions = browser.permissions.remove( - { permissions: manifest.optional_permissions } - ); - removePermissions.then((values) => { - Config.clear(); - // reload option page to update the ui - window.location.reload(); - }); - } - }, { once: true }); - popup.open = true; -} - - -/** - * saves the current config as a json file - **/ -function onBackupButton () { - const manifest = browser.runtime.getManifest(); - const linkElement = document.createElement("a"); - linkElement.download = `${manifest.name} ${manifest.version} ${ new Date().toDateString() }.json`; - // creates a json file with the current config - linkElement.href = URL.createObjectURL( - new Blob([JSON.stringify(Config.get(), null, ' ')], {type: 'application/json'}) - ); - document.body.appendChild(linkElement); - linkElement.click(); - document.body.removeChild(linkElement); -} - - -/** - * overwrites the current config with the selected config - * and resets all optional permissions - * reloads the options page afterwards - **/ -async function onRestoreButton (event) { - if (this.files[0].type !== "application/json") { - const popup = document.getElementById("restoreAlertWrongFile"); - popup.open = true; - // terminate function - return; - } - - // catch rejected promises and errors - try { - // load file data - const file = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - try { - const restoredConfig = JSON.parse(reader.result); - resolve(restoredConfig) - } - catch (e) { - reject(); - } - } - reader.onerror = reject; - reader.readAsText(this.files[0]); - }); - - // load the commands data in order to request the right permissions - const commands = await fetchJSONAsObject(browser.runtime.getURL("/resources/json/commands.json")); - - // get the necessary permissions - const requiredPermissions = []; - // combine all commands to one array - const usedCommands = []; - if (file.Gestures && file.Gestures.length > 0) { - file.Gestures.forEach(gesture => usedCommands.push(gesture.command)); - } - if (file.Settings && file.Settings.Rocker) { - if (file.Settings.Rocker.rightMouseClick) usedCommands.push(file.Settings.Rocker.rightMouseClick); - if (file.Settings.Rocker.leftMouseClick) usedCommands.push(file.Settings.Rocker.leftMouseClick); - } - if (file.Settings && file.Settings.Wheel) { - if (file.Settings.Wheel.wheelUp) usedCommands.push(file.Settings.Wheel.wheelUp); - if (file.Settings.Wheel.wheelDown) usedCommands.push(file.Settings.Wheel.wheelDown); - } - - for (let command of usedCommands) { - const commandItem = commands.find((element) => { - return element.command === command.name; - }); - if (commandItem.permissions) commandItem.permissions.forEach((permission) => { - if (!requiredPermissions.includes(permission)) requiredPermissions.push(permission); - }); - } - - // display popup because permission request requires user interaction - // also to ensure that the user really wants to override the current config - const popup = document.getElementById("restoreConfirm"); - popup.addEventListener("close", (event) => { - // if user declined exit function - if (!event.detail) return; - // if optional permissions are required request them - if (requiredPermissions.length > 0) { - const permissionRequest = browser.permissions.request({ - permissions: requiredPermissions, - }); - permissionRequest.then((granted) => { - if (granted) proceed(); - }); - } - else proceed(); - }, { once: true }); - popup.open = true; - - // helper function to finish the process - // reload option page to update the ui - function proceed () { - Config.clear(); - Config.set(file); - const popup = document.getElementById("restoreAlertSuccess"); - popup.addEventListener("close", () => window.location.reload(), { once: true }); - popup.open = true; - } - } - catch (e) { - const popup = document.getElementById("restoreAlertNoConfigFile"); - popup.open = true; - // terminate function - return; - } -} \ No newline at end of file diff --git a/src/views/options/data.mjs b/src/views/options/data.mjs new file mode 100644 index 000000000..02ff46830 --- /dev/null +++ b/src/views/options/data.mjs @@ -0,0 +1,246 @@ +import { ContentLoaded, Config } from "/views/options/main.mjs"; + +import ConfigManager from "/core/services/config-manager.mjs"; + +import CommandDefinitions from "/resources/configs/commands.mjs"; + +ContentLoaded.then(main); + + +/** + * main function + * run code that depends on async resources + **/ +function main () { + // data management buttons + document.getElementById("fileBackupButton").onclick = onFileBackupButton; + document.getElementById("fileRestoreButton").onchange = onFileRestoreButton; + document.getElementById("cloudBackupButton").onclick = onCloudBackupButton; + document.getElementById("cloudRestoreButton").onclick = onCloudRestoreButton; + document.getElementById("configResetButton").onclick = onConfigResetButton; + + handleButtonStates(); + browser.storage.sync.onChanged.addListener(handleButtonStates); +} + + +/** + * enabled/disables buttons according to different rules + **/ +async function handleButtonStates() { + // disable download button when sync storage is empty + const bytesInUse = await browser.storage.sync.getBytesInUse(); + const button = document.getElementById("cloudRestoreButton"); + button.disabled = bytesInUse <= 0; +} + + +/** + * saves the current config as a json file + **/ +function onFileBackupButton () { + const manifest = browser.runtime.getManifest(); + const linkElement = document.createElement("a"); + linkElement.download = `${manifest.name} ${manifest.version} ${ new Date().toDateString() }.json`; + // creates a json file with the current config + linkElement.href = URL.createObjectURL( + new Blob([JSON.stringify(Config.get(), null, ' ')], {type: 'application/json'}) + ); + document.body.appendChild(linkElement); + linkElement.click(); + document.body.removeChild(linkElement); +} + + +/** + * overwrites the current config with the selected config + * and resets all optional permissions + * reloads the options page afterwards + **/ +async function onFileRestoreButton (event) { + if (this.files[0].type !== "application/json") { + prompt("restoreAlertWrongFile"); + // terminate function + return; + } + + // catch rejected promises and errors + try { + // load file data + const json = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + try { + const restoredConfig = JSON.parse(reader.result); + resolve(restoredConfig) + } + catch (e) { + reject(e); + } + } + reader.onerror = reject; + reader.readAsText(this.files[0]); + }); + + const proceed = await new Promise((resolve) => { + // display popup because permission request requires user interaction + // also to ensure that the user really wants to override the current config + const popup = document.getElementById("restoreConfirm"); + popup.addEventListener("close", async (event) => { + // if user declined exit function + if (!event.detail) return resolve(false); + // if optional permissions are required request them + const permissionsGranted = await requestPermissionsForConfig(json); + resolve(permissionsGranted); + }, { once: true }); + popup.open = true; + }); + + if (proceed) { + Config.clear(); + Config.set(json); + await prompt("restoreAlertSuccess"); + // reload option page to update the ui + window.location.reload(); + } + } + catch (e) { + prompt("restoreAlertNoConfigFile"); + // terminate function + return; + } +} + + +/** + * uploads the current config to the sync storage + **/ +async function onCloudBackupButton () { + const bytesInUse = await browser.storage.sync.getBytesInUse(); + // check whether there is any pre-existing data in the sync storage and warn user + if (bytesInUse > 0) { + const proceed = await prompt("uploadConfirm"); + if (!proceed) return; + } + // create sync config manager and write current config to it + const cloudConfig = new ConfigManager({ + storageArea: 'sync', + }); + await cloudConfig.loaded; + cloudConfig.set( + Config.get(), + ); + prompt("uploadAlertSuccess"); +} + + +/** + * loads and applies the config from the sync storage + * reloads the options page afterwards + **/ +async function onCloudRestoreButton () { + const cloudConfig = new ConfigManager({ + storageArea: 'sync', + }); + await cloudConfig.loaded; + const json = cloudConfig.get(); + + const proceed = await new Promise((resolve) => { + // display popup because permission request requires user interaction + // also to ensure that the user really wants to override the current config + const popup = document.getElementById("downloadConfirm"); + popup.addEventListener("close", async (event) => { + // if user declined exit function + if (!event.detail) return resolve(false); + // if optional permissions are required request them + const permissionsGranted = await requestPermissionsForConfig(json); + resolve(permissionsGranted); + }, { once: true }); + popup.open = true; + }); + + if (proceed) { + Config.clear(); + Config.set(json); + await prompt("downloadAlertSuccess"); + // reload option page to update the ui + window.location.reload(); + } +} + + +/** + * clears the current config so the defaults will be used + * resets all optional permissions + * reloads the options page afterwards + **/ +async function onConfigResetButton () { + const proceed = await prompt("resetConfirm"); + if (proceed) { + const manifest = browser.runtime.getManifest(); + await browser.permissions.remove( + { permissions: manifest.optional_permissions } + ); + Config.clear(); + // reload option page to update the ui + window.location.reload(); + } +} + + +/** + * Collects and requests all permissions that are required for the given config file + * This returns a promise that will either fulfill with true or false + * If no permissions are required this fulfills with true + **/ +function requestPermissionsForConfig (json) { + // get the necessary permissions + const requiredPermissions = []; + // combine all commands to one array + const usedCommands = []; + if (json.Gestures && json.Gestures.length > 0) { + json.Gestures.forEach(gesture => usedCommands.push(gesture.command)); + } + if (json.Settings && json.Settings.Rocker) { + if (json.Settings.Rocker.rightMouseClick) usedCommands.push(json.Settings.Rocker.rightMouseClick); + if (json.Settings.Rocker.leftMouseClick) usedCommands.push(json.Settings.Rocker.leftMouseClick); + } + if (json.Settings && json.Settings.Wheel) { + if (json.Settings.Wheel.wheelUp) usedCommands.push(json.Settings.Wheel.wheelUp); + if (json.Settings.Wheel.wheelDown) usedCommands.push(json.Settings.Wheel.wheelDown); + } + + for (let command of usedCommands) { + const commandItem = CommandDefinitions.find((element) => { + return element.command === command.name; + }); + if (commandItem.permissions) commandItem.permissions.forEach((permission) => { + if (!requiredPermissions.includes(permission)) requiredPermissions.push(permission); + }); + } + + // if optional permissions are required request them + if (requiredPermissions.length > 0) { + return browser.permissions.request({ + permissions: requiredPermissions, + }); + } + // if no permissions are required resolve to true + return Promise.resolve(true); +} + + +/** + * Helper function to open popups by their ids. + * Returns the result of the popup. + **/ +function prompt (popupId) { + return new Promise((resolve) => { + const popup = document.getElementById(popupId); + popup.addEventListener("close", + async (event) => resolve(event.detail), + { once: true }, + ); + popup.open = true; + }); +} diff --git a/src/views/options/exclusions.mjs b/src/views/options/exclusions.mjs deleted file mode 100644 index afff2de2d..000000000 --- a/src/views/options/exclusions.mjs +++ /dev/null @@ -1,162 +0,0 @@ -import { ContentLoaded, Config } from "/views/options/main.mjs"; - -ContentLoaded.then(main); - -/** - * main function - * run code that depends on async resources - **/ -function main () { - const exclusionsContainer = document.getElementById('exclusionsContainer'); - exclusionsContainer.dataset.noEntriesHint = browser.i18n.getMessage('exclusionsHintNoEntries'); - const exclusionsForm = document.getElementById('exclusionsForm'); - exclusionsForm.onsubmit = onFormSubmit; - exclusionsForm.elements.urlPattern.placeholder = browser.i18n.getMessage('exclusionsPlaceholderURL'); - exclusionsForm.elements.urlPattern.title = browser.i18n.getMessage('exclusionsPlaceholderURL'); - exclusionsForm.elements.urlPattern.onchange = onInputChange; - // add existing exclusions entries - for (const urlPattern of Config.get("Exclusions")) { - const exclusionsEntry = createExclusionsEntry(urlPattern); - exclusionsContainer.appendChild(exclusionsEntry); - } -} - - -/** - * Creates a exclusions entry html element by a given url pattern and returns it - **/ -function createExclusionsEntry (urlPattern) { - const exclusionsEntry = document.createElement('li'); - exclusionsEntry.classList.add('excl-entry'); - exclusionsEntry.dataset.urlPattern = urlPattern; - exclusionsEntry.onclick = onEntryClick; - const inputURLEntry = document.createElement('div'); - inputURLEntry.classList.add('excl-url-pattern'); - inputURLEntry.textContent = urlPattern; - const deleteButton = document.createElement('button'); - deleteButton.type = "button"; - deleteButton.classList.add('excl-remove-button', 'icon-delete'); - exclusionsEntry.append(inputURLEntry, deleteButton); - return exclusionsEntry; -} - - -/** - * Adds a given exclusions entry element to the exclusions ui - **/ -function addExclusionsEntry (exclusionsEntry) { - const exclusionsContainer = document.getElementById('exclusionsContainer'); - // append entry, hide it and move it out of flow to calculate its dimensions - exclusionsContainer.prepend(exclusionsEntry); - exclusionsEntry.style.setProperty('visibility', 'hidden'); - exclusionsEntry.style.setProperty('position', 'absolute'); - // calculate total entry height - const computedStyle = window.getComputedStyle(exclusionsEntry); - const outerHeight = parseInt(computedStyle.marginTop) + exclusionsEntry.offsetHeight + parseInt(computedStyle.marginBottom); - - // move all entries up by one entry including the new one - for (const node of exclusionsContainer.children) { - node.style.setProperty('transform', `translateY(-${outerHeight}px)`); - // remove ongoing transitions if existing - node.style.removeProperty('transition'); - } - // show new entry and bring it back to flow, which pushes all elements down by the height of one entry - exclusionsEntry.style.removeProperty('visibility', 'hidden'); - exclusionsEntry.style.removeProperty('position', 'absolute'); - - // trigger reflow - exclusionsContainer.offsetHeight; - - exclusionsEntry.addEventListener('animationend', (event) => { - event.currentTarget.classList.remove('excl-entry-animate-add'); - }, {once: true }); - exclusionsEntry.classList.add('excl-entry-animate-add'); - - // move all entries down including the new one - for (const node of exclusionsContainer.children) { - node.addEventListener('transitionend', (event) => event.currentTarget.style.removeProperty('transition'), {once: true }); - node.style.setProperty('transition', 'transform 0.3s'); - node.style.removeProperty('transform'); - } -} - - -/** - * Removes a given exclusions entry element from the exclusions ui - **/ -function removeExclusionsEntry (exclusionsEntry) { - // calculate total entry height - const computedStyle = window.getComputedStyle(exclusionsEntry); - const outerHeight = parseInt(computedStyle.marginTop) + exclusionsEntry.offsetHeight + parseInt(computedStyle.marginBottom); - - let node = exclusionsEntry.nextElementSibling; - while (node) { - node.addEventListener('transitionend', (event) => { - event.currentTarget.style.removeProperty('transition'); - event.currentTarget.style.removeProperty('transform'); - }, {once: true }); - node.style.setProperty('transition', 'transform 0.3s'); - node.style.setProperty('transform', `translateY(-${outerHeight}px)`); - node = node.nextElementSibling; - } - exclusionsEntry.addEventListener('animationend', (event) => event.currentTarget.remove(), {once: true }); - exclusionsEntry.classList.add('excl-entry-animate-remove'); -} - - -/** - * Handles the url pattern submit event - * Adds the new url pattern to the config and calls the exclusions entry create function - **/ -function onFormSubmit (event) { - event.preventDefault(); - // remove spaces and cancel the function if the value is empty - const urlPattern = this.elements.urlPattern.value.trim(); - if (!urlPattern) return; - // create and add entry to the exclusions - const exclusionsEntry = createExclusionsEntry(urlPattern); - addExclusionsEntry(exclusionsEntry); - // add new url pattern to the beginning of the array - const exclusionsArray = Config.get("Exclusions"); - exclusionsArray.unshift(urlPattern); - Config.set("Exclusions", exclusionsArray); - // clear input field - this.elements.urlPattern.value = ''; -} - - -/** - * Handles the url pattern input changes - * Marks the field as invalide if the entry already exists - **/ -function onInputChange () { - if (Config.get("Exclusions").indexOf(this.value.trim()) !== -1) { - this.setCustomValidity(browser.i18n.getMessage('exclusionsNotificationAlreadyExists')); - } - else if (this.validity.customError) this.setCustomValidity(''); -} - - -/** - * Handles the exclusions entry click - * Calls the remove exclusions entry function on remove button click and removes it from the config - **/ -function onEntryClick (event) { - // if delete button received the click - if (event.target.classList.contains('excl-remove-button')) { - removeExclusionsEntry(this); - - const exclusionsForm = document.getElementById('exclusionsForm'); - // remove input field invaldility if it was previously a duplicate - if (this.dataset.urlPattern === exclusionsForm.elements.urlPattern.value.trim()) { - exclusionsForm.elements.urlPattern.setCustomValidity(''); - } - const exclusionsArray = Config.get("Exclusions"); - // remove url pattern from array - const index = exclusionsArray.indexOf(this.dataset.urlPattern); - if (index !== -1) { - exclusionsArray.splice(index, 1); - Config.set("Exclusions", exclusionsArray); - } - } -} diff --git a/src/views/options/fragments/about.inc b/src/views/options/fragments/about.inc index 126ff4a46..49e4f8b08 100644 --- a/src/views/options/fragments/about.inc +++ b/src/views/options/fragments/about.inc @@ -23,39 +23,4 @@
  • -
    -
    - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/views/options/fragments/data.inc b/src/views/options/fragments/data.inc new file mode 100644 index 000000000..97a264698 --- /dev/null +++ b/src/views/options/fragments/data.inc @@ -0,0 +1,105 @@ +
    +

    +
    +
    +
    +
    +
    +

    +

    +
    +
    + +
    +
    +
    +
    +

    +

    +
    +
    + +
    +
    +
    +
    +
    +
    +

    +

    +
    +
    + +
    +
    +
    +
    +

    +

    +
    +
    + +
    +
    +
    +
    +
    +
    +

    +

    +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/options/fragments/exclusions.inc b/src/views/options/fragments/exclusions.inc deleted file mode 100644 index 3bf1a55aa..000000000 --- a/src/views/options/fragments/exclusions.inc +++ /dev/null @@ -1,14 +0,0 @@ -
    -

    -
    -

    - - - -

    -
    -
    - - -
    -
      diff --git a/src/views/options/gestures.mjs b/src/views/options/gestures.mjs index 8e27dbd90..2ed1a1bc3 100644 --- a/src/views/options/gestures.mjs +++ b/src/views/options/gestures.mjs @@ -212,8 +212,8 @@ function addGestureListItem (gestureListItem) { // prepend new entry, this pushes all elements by the height / width of one entry gestureAddButtonItem.after(gestureListItem); - // select all visible gesture items - const gestureItems = gestureList.querySelectorAll(".gl-item:not([hidden])"); + // select all gesture items + const gestureItems = gestureList.querySelectorAll(".gl-item"); // check if at least one node already exists if (gestureItems.length > 0) { @@ -267,6 +267,8 @@ function addGestureListItem (gestureListItem) { gestureListItem.offsetHeight; gestureListItem.style.setProperty('transition', 'transform .3s'); gestureListItem.style.transform = gestureListItem.style.transform.replace("scale(1.6)", ""); + // hide new item in case search is active + onSearchInput(); } @@ -287,6 +289,8 @@ function updateGestureListItem (gestureListItem, gesture) { const commandField = gestureListItem.querySelector(".gl-command"); commandField.textContent = gesture.toString(); + // hide updated item in case search is active + onSearchInput(); } @@ -297,8 +301,8 @@ function removeGestureListItem (gestureListItem) { const gestureList = document.getElementById("gestureContainer"); // get child index for current gesture item const gestureItemIndex = Array.prototype.indexOf.call(gestureList.children, gestureListItem); - // select all visible gesture items starting from given gesture item index - const gestureItems = gestureList.querySelectorAll(`.gl-item:not([hidden]):nth-child(n + ${gestureItemIndex + 1})`); + // select all gesture items starting from given gesture item index + const gestureItems = gestureList.querySelectorAll(`.gl-item:nth-child(n + ${gestureItemIndex + 1})`); // for performance improvements: read and cache all grid item positions first const itemPositionCache = new Map(); @@ -426,7 +430,6 @@ function onItemPointerleave (event) { **/ function onSearchInput () { const gestureList = document.getElementById("gestureContainer"); - const gestureAddButtonItem = gestureList.firstElementChild; const searchQuery = document.getElementById("gestureSearchInput").value.toLowerCase().trim(); const searchQueryKeywords = searchQuery.split(" "); @@ -436,14 +439,10 @@ function onSearchInput () { // check if all keywords are matching the command name const isMatching = searchQueryKeywords.every(keyword => gestureString.includes(keyword)); // hide all unmatching commands and show all matching commands - gestureListItem.hidden = !isMatching; + gestureListItem.classList.toggle("hidden", !isMatching); } - // hide gesture add button item on search input - gestureAddButtonItem.hidden = !!searchQuery; - - // toggle "no search results" hint if all items are hidden - gestureList.classList.toggle("empty", !gestureList.querySelectorAll(".gl-item:not([hidden])").length); + gestureList.classList.toggle("searching", !!searchQuery.length); } diff --git a/src/views/options/index.html b/src/views/options/index.html index cbd98eead..8d491ceac 100644 --- a/src/views/options/index.html +++ b/src/views/options/index.html @@ -3,7 +3,7 @@ Gesturefy - + @@ -14,13 +14,12 @@ - - + diff --git a/src/views/options/layout.css b/src/views/options/layout.css index eb238a01f..a1b2c7eba 100644 --- a/src/views/options/layout.css +++ b/src/views/options/layout.css @@ -158,6 +158,11 @@ h2 { font-family: Icons; } +.icon-data::before { + content: "d"; + font-family: Icons; +} + .icon-arrows::before { content: "a"; font-family: Icons; @@ -355,14 +360,6 @@ h2 { margin-bottom: 20px; } -.align-content-right { - display: flex; - justify-content: flex-end; - flex-wrap: wrap; - row-gap: 15px; - column-gap: 15px; -} - .justify-text { text-align: justify; } @@ -598,11 +595,16 @@ label.button > span { color: inherit; } -.button:hover { +.button:not(:disabled):hover { box-shadow: 0 0 10px -4px var(--shadow-color); text-decoration: none; } +.button:disabled { + cursor: default; + opacity: 0.5; +} + .button.danger:hover *, .button.danger:hover { border-color: var(--warning-color); @@ -727,46 +729,6 @@ label.button > span { color: var(--highlighted-color); } -.gesture-list::before { - position: absolute; - display: block; - margin-top: 30vh; - visibility: hidden; - opacity: 0; - color: var(--text-color); - content: "m"; - font-family: Icons; - text-align: center; - font-size: 36px; - grid-column: 1 / -1; -} - -.gesture-list.empty::before { - position: static; - visibility: visible; - opacity: .5; - transition: opacity .3s; -} - -.gesture-list::after { - position: absolute; - display: block; - visibility: hidden; - opacity: 0; - color: var(--text-color); - content: attr(data-no-results-hint); - text-align: center; - font-size: 22px; - grid-column: 1 / -1; -} - -.gesture-list.empty::after { - position: static; - visibility: visible; - opacity: .5; - transition: opacity .3s; -} - /** * Gesture list add button **/ @@ -811,14 +773,16 @@ label.button > span { background-color: var(--base-color); box-shadow: none; cursor: pointer; - transition: box-shadow .3s; + transition: box-shadow .3s, opacity .3s; } -.gl-item[hidden] { - display: none; +.gl-item.hidden { + opacity: 0.2; + pointer-events: none; } -.gl-item:hover { +.gl-item:hover, +.searching > .gl-item:not(.hidden) { box-shadow: 0 0 10px -4px var(--shadow-color); } @@ -1108,135 +1072,3 @@ label.button > span { #gesturePopupPatternContainer .gl-thumbnail-arrow { --arrowScale: 0.6; } - - -/** - * Exclusions layout - **/ - -.excl-form { - display: flex; - align-items: stretch; -} - -.excl-url-pattern-input { - flex: 1; - box-sizing: border-box; - padding: 8px 10px 6px 10px; - border: 1px solid var(--border-color); - border-right: none; - border-radius: 2px 0 0 2px; - background: var(--base-color); - color: var(--text-color); - text-overflow: ellipsis ellipsis; -} - -.excl-url-pattern-input:invalid { - box-shadow: none; -} - -.excl-add-button { - padding: 0 20px; - border-radius: 0 2px 2px 0; - background: var(--highlighted-color); - color: var(--textSecond-color); - text-align: center; - line-height: 100%; -} - -.exclusions { - padding-top: 20px; -} - -.exclusions::after { - display: block; - visibility: hidden; - opacity: 0; - color: var(--text-color); - content: attr(data-no-entries-hint); - text-align: center; - font-size: 18px; -} - -.exclusions:empty::after { - visibility: visible; - opacity: .5; - transition: opacity .3s; -} - -.excl-entry { - display: flex; - align-items: center; - box-sizing: border-box; - padding-bottom: 10px; - cursor: default; -} - -.excl-entry:not(:last-child) { - margin-bottom: 10px; - border-bottom: 1px dashed var(--borderSecond-color); -} - -.excl-url-pattern { - flex: 1; - padding-right: 10px; - word-break: break-all; -} - -.excl-remove-button { - width: 18px; - height: 18px; - border-radius: 50%; - background-color: var(--border-color); - color: var(--base-color); - text-align: center; - font-size: 6px; - font-family: Icons; - line-height: 6px; -} - -.excl-remove-button:hover { - background-color: var(--warning-color); -} - -.excl-entry-animate-add { - z-index: -1; - animation-name: animateAddEntry; - animation-duration: .3s; - animation-timing-function: ease; -} - -@keyframes animateAddEntry { - from { - opacity: 0; - } - 50% { - opacity: .3; - } - to { - opacity: 1; - } -} - -.excl-entry-animate-remove { - animation-name: animateDeleteEntry; - animation-duration: .3s; - animation-timing-function: ease; -} - -@keyframes animateDeleteEntry { - from { - opacity: 1; - transform: scale(1); - } - 30% { - visibility: hidden; - opacity: 0; - transform: scale(.9); - } - to { - visibility: hidden; - opacity: 0; - transform: scale(.9); - } -} \ No newline at end of file diff --git a/src/views/options/main.mjs b/src/views/options/main.mjs index f7562a61e..190702cf5 100644 --- a/src/views/options/main.mjs +++ b/src/views/options/main.mjs @@ -1,9 +1,12 @@ import { fetchHTMLAsFragment } from "/core/utils/commons.mjs"; -import ConfigManager from "/core/helpers/config-manager.mjs"; +import ConfigManager from "/core/services/config-manager.mjs"; -export const Config = new ConfigManager("local", browser.runtime.getURL("/resources/json/defaults.json")); +import DefaultConfig from "/resources/configs/defaults.mjs"; +export const Config = new ConfigManager({ + defaults: DefaultConfig +}); const Resources = [ Config.loaded ]; @@ -45,7 +48,7 @@ function main () { input.checked = input.value === value; } else input.value = value; - input.addEventListener('change', onChage); + input.addEventListener('change', onChange); } // toggle collapsables and add their event function @@ -117,7 +120,7 @@ function onThemeButtonChange () { /** * save input value if valid **/ - function onChage () { + function onChange () { // check if valid, if there is no validity property check if value is set if ((this.validity && this.validity.valid) || (!this.validity && this.value)) { let value; diff --git a/src/views/popup/index.html b/src/views/popup/index.html new file mode 100644 index 000000000..cf1c9e854 --- /dev/null +++ b/src/views/popup/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + +
      + + + Mozilla Add-ons + + + + Github + + + \ No newline at end of file diff --git a/src/views/popup/layout.css b/src/views/popup/layout.css new file mode 100644 index 000000000..2791cc976 --- /dev/null +++ b/src/views/popup/layout.css @@ -0,0 +1,192 @@ +@font-face { + font-weight: normal; + font-style: normal; + font-family: "Icons"; + src: url("/resources/fonts/icons.woff"); +} + +@font-face { + font-weight: normal; + font-style: normal; + font-family: "NunitoSans Regular"; + src: url("/resources/fonts/NunitoSans-Regular.woff"); +} + +/* Light Theme */ + +.light-theme:root, :root { + --base-color: #FBFBFB; + --text-color: #555555; + --surface-color: #ddd; + --on-surface-color: var(--text-color); + --shadowSecond-color:rgba(0,0,0,0.15); + --highlighted-color: #00AAA0; + --warning-color: #FF6347; +} + +/* Dark Theme */ + +.dark-theme:root { + --base-color: #252a32; + --text-color: #EBEBEB; + --surface-color: #464e5e; + --on-surface-color: var(--text-color); + --shadowSecond-color: rgba(200,200,200, 0.15); + --highlighted-color: #00AAA0; + --warning-color: #FF6347; +} + +/* High Contrast Theme */ + +.highContrast-theme:root { + --base-color: #000; + --text-color:#FFF; + --surface-color:#FFF; + --on-surface-color: var(--base-color); + --shadowSecond-color: rgba(255,255,255,0.15); + --highlighted-color: #00ff00; + --warning-color: #FF0000; +} + +* { + margin: 0; + padding: 0; + border: none; + outline: none; +} + +html { + font-family: "NunitoSans Regular", "Arial", sans-serif; + color: var(--text-color); +} + +body { + background: var(--base-color); + display: flex; + flex-direction: column; + justify-content: stretch; + padding: 0.5em; +} + +[hidden] { + display: none !important; +} + +/** + * Icons classes + **/ + +.icon-gear::before { + content: "g"; + font-family: Icons; + vertical-align: top; +} + +.icon-info::before { + content: "I"; + font-family: Icons; +} + +.favicon::before { + content: ''; + display: block; + background-image: var(--favicon-url); + width: 1em; + height: 1em; + background-size: contain; +} + +.favicon.favicon-amo::before { + background-image: url('https://addons.mozilla.org/favicon.ico?v=2'); +} + +.favicon.favicon-github::before { + background-image: url('https://github.githubassets.com/favicons/favicon.svg'); +} + +.highContrast-theme:root .favicon.favicon-github::before, +.dark-theme:root .favicon.favicon-github::before { + background-image: url('https://github.githubassets.com/favicons/favicon-dark.svg'); +} + +/** + * Item list + **/ + +hr { + border-bottom: solid 1px var(--surface-color); + margin: 0.5em 0em; +} + +.item { + display: flex; + justify-content: flex-start; + align-items: center; + column-gap: 0.5em; + padding: 0 0.7em; + border-radius: 0.2em; + background: none; + color: var(--text-color); + font-weight: normal; + text-decoration: none; + font-size: 1em; + line-height: 1em; + text-align: left; +} + +.item:hover, +.item:focus-visible { + background: var(--surface-color); + text-decoration: none; + color: var(--on-surface-color); +} + +.item > span:last-of-type { + padding: 0.7em 0; + flex: 1; +} + +a.item::after { + content: "E"; + text-decoration: none; + font-family: Icons; + font-size: 0.75em; +} + +.item.warning { + color: var(--warning-color); +} + +/** + * Toggle button + **/ + +input[type="checkbox"] { + appearance: none; + height: 1rem; + padding: 0.15em; + box-sizing: content-box; + aspect-ratio: 2 / 1; + border-radius: 2rem; + background-color: var(--surface-color); + box-shadow: inset 0px 0px 3px var(--shadowSecond-color); + transition: background-color 0.2s; +} + +input[type="checkbox"]::before { + content: ""; + display: block; + border-radius: 50%; + height: 100%; + aspect-ratio: 1 / 1; + background: var(--base-color); + transition: transform 0.2s ease; +} + +input[type="checkbox"]:checked { + background-color: var(--highlighted-color); +} + +input[type="checkbox"]:checked::before { + transform: translateX(100%) +} \ No newline at end of file diff --git a/src/views/popup/main.mjs b/src/views/popup/main.mjs new file mode 100644 index 000000000..4ded958cd --- /dev/null +++ b/src/views/popup/main.mjs @@ -0,0 +1,134 @@ +import { getActiveTab } from "/core/utils/commons.mjs"; + +import ExclusionService from "/core/services/exclusion-service.mjs"; + +import HostPermissionService from "/core/services/host-permission-service.mjs"; + +import ConfigManager from "/core/services/config-manager.mjs"; + +import DefaultConfig from "/resources/configs/defaults.mjs"; + +const Config = new ConfigManager({ + defaults: DefaultConfig +}); + +const Exclusions = new ExclusionService(); + +const HostPermissions = new HostPermissionService(); + +Promise.all([ + getActiveTab(), + Config.loaded, + Exclusions.loaded, +]).then(main); + +let activeTab; + +function main(args) { + [activeTab] = args; + // insert text from language files + for (let element of document.querySelectorAll('[data-i18n]')) { + element.textContent = browser.i18n.getMessage(element.dataset.i18n); + } + // register permission change handler and run it initially + HostPermissions.addEventListener('change', onPermissionChange); + Exclusions.addEventListener('change', onPermissionChange); + onPermissionChange(); + // apply theme class + const themeValue = Config.get("Settings.General.theme"); + document.documentElement.classList.add(`${themeValue}-theme`); + // register button event listeners + const settingsButton = document.getElementById('settingsButton'); + settingsButton.addEventListener('click', openSettings); + const permissionRequestButton = document.getElementById('permissionRequestButton'); + permissionRequestButton.title = browser.i18n.getMessage('popupMissingPermissionButtonTooltip'); + permissionRequestButton.addEventListener('click', HostPermissions.requestGlobalPermission); + const restrictedPageWarningText = document.getElementById('restrictedPageWarningText'); + // omit passing the short url here because we only reliably get the url for tabs where the add-on has host permissions + // we never get host permissions for e.g. about: or moz-extension: so we cannot retrieve/show the url + // we would require the "tabs" permission to consistently retrieve all urls + restrictedPageWarningText.textContent = browser.i18n.getMessage('popupProhibitedPageWarning'); + const domainActivationButton = document.getElementById('domainActivationButton'); + domainActivationButton.style.setProperty('--favicon-url', `url(${activeTab.favIconUrl})`); + const domainActivationButtonText = document.getElementById('domainActivationButtonText'); + domainActivationButtonText.textContent = browser.i18n.getMessage( + 'popupExclusionsToggleButton', toShortURL(activeTab.url) + ); + // use click instead of change to prevent default + const domainActivationButtonToggle = document.getElementById('domainActivationButtonToggle'); + domainActivationButtonToggle.addEventListener('click', onDomainToggle); +} + +// handlers \\ + +async function onPermissionChange() { + const [ + _hasGlobalPermission, + _hasTabPermission, + ] = await Promise.all([ + HostPermissions.hasGlobalPermission(), + HostPermissions.hasTabPermission(activeTab), + ]); + + // warnings: + let hasWarning = false; + + const permissionRequestButton = document.getElementById('permissionRequestButton'); + permissionRequestButton.hidden = _hasGlobalPermission; + hasWarning ||= !permissionRequestButton.hidden; + + const restrictedPageWarning = document.getElementById('restrictedPageWarning'); + restrictedPageWarning.hidden = hasWarning || _hasTabPermission; + hasWarning ||= !restrictedPageWarning.hidden; + + // exclusion toggle (only show when no warnings): + const isActive = Exclusions.isEnabledFor(activeTab.url); + const domainActivationButton = document.getElementById('domainActivationButton'); + domainActivationButton.hidden = hasWarning; + domainActivationButton.title = browser.i18n.getMessage( + isActive + ? 'popupExclusionsToggleButtonOnTooltip' + : 'popupExclusionsToggleButtonOffTooltip', + toShortURL(activeTab.url) + ); + const domainActivationButtonToggle = document.getElementById('domainActivationButtonToggle'); + domainActivationButtonToggle.checked = isActive; +} + +function onDomainToggle(event) { + if (Exclusions.isEnabledFor(activeTab.url)) { + Exclusions.disableFor(activeTab.url); + } + else { + Exclusions.enableFor(activeTab.url); + } + event.preventDefault(); +} + +// methods \\ + +function openSettings() { + browser.runtime.openOptionsPage(); + window.close(); +} + +function toShortURL(url) { + try { + url = new URL(url); + } + catch(e) { + return url; + } + if (url.protocol === 'about:') { + return url.protocol + url.pathname; + } + else if (url.protocol === 'moz-extension:') { + return url.protocol; + } + else if (url.protocol === 'chrome:') { + return url.origin; + } + else { + return url.hostname || url.origin; + } +}