├── pop.flac ├── .gitignore ├── eslint.config.js ├── .tx └── config ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml ├── workflows │ ├── transifex.yml │ ├── test.yml │ └── browserslist.yml └── SUPPORT.md ├── .editorconfig ├── scripts ├── firstrun.js ├── stored-sound.js ├── content-script.js ├── options.js └── background.js ├── styles ├── firstrun.css └── options.css ├── images ├── Notification_Sound_Icon_Square.svg └── Notification_Sound_Icon.svg ├── manifest.json ├── _locales └── en │ └── messages.json ├── package.json ├── README.md ├── pages ├── firstrun.html └── options.html └── LICENSE /pop.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freaktechnik/notification-sounds/HEAD/pop.flac -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.xpi 2 | *~ 3 | node_modules 4 | web-ext-artifacts 5 | _locales/* 6 | !_locales/en/* 7 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import freaktechnikConfigExtension from "@freaktechnik/eslint-config-extension"; 2 | 3 | export default [ 4 | ...freaktechnikConfigExtension, 5 | ] 6 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [notification-sound.messages] 5 | file_filter = _locales//messages.json 6 | source_file = _locales/en/messages.json 7 | source_lang = en 8 | type = CHROME 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discourse Support Thread 4 | url: https://discourse.mozilla.org/t/support-notification-sound/23758/47 5 | about: Support thread for troubleshooting and such. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore:" 9 | open-pull-requests-limit: 5 10 | versioning-strategy: "increase" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | commit-message: 16 | prefix: "chore:" 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 4 12 | 13 | # C-style doc comments for eclint 14 | block_comment_start = /* 15 | block_comment = * 16 | block_comment_end = */ 17 | 18 | [{package.json,package-lock.json,*.yml}] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.github/workflows/transifex.yml: -------------------------------------------------------------------------------- 1 | name: transifex 2 | on: 3 | push: 4 | paths: 5 | - _locales/en/messages.json 6 | branches: 7 | - main 8 | jobs: 9 | push-messages: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: docker://sergioisidoro/github-transifex-action:v0.2.0 14 | with: 15 | TX_TOKEN: ${{ secrets.TX_TOKEN }} 16 | github_token: ${{ secrets.GITHUB_TOKEN }} 17 | pull_translations: false 18 | pull_sources: false 19 | push_translations: false 20 | push_sources: true 21 | translations_folder: ./_locales/ 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Notification Sound Support 2 | 3 | To get support with the Notification Sound Firefox extension, please use one of the following channels: 4 | 5 | - [Mozilla Discourse](https://discourse.mozilla.org/t/support-notification-sound/23758/47) 6 | 7 | When asking for help, make sure to specify your extension and Firefox versions. An exact description of your problem and possibly a screenshot or screencapture to help illustrate it. 8 | 9 | Please note some of the known issues, notably that the extension can't work on all websites. If you're unsure if the extension is working or not, please use the [Notification Tester](https://freaktechnik.github.io/notification-sounds/) website. It lets you easily test if only some aspects of the extension aren't working. 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. Windows 10] 30 | - Firefox version: [e.g. 22] 31 | - Extension version [e.g. 2.1.0] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /scripts/firstrun.js: -------------------------------------------------------------------------------- 1 | const player = new Audio(), 2 | canPlay = player.canPlayType("audio/flac"); 3 | 4 | document.addEventListener("DOMContentLoaded", () => { 5 | if(canPlay) { 6 | document.getElementById("preview").addEventListener("click", () => { 7 | browser.runtime.sendMessage({ 8 | command: "preview-sound", 9 | pref: 'soundName', 10 | }).catch(console.error); 11 | }, { 12 | passive: true, 13 | }); 14 | } 15 | else { 16 | document.getElementById("support").hidden = true; 17 | document.getElementById("preview").hidden = true; 18 | document.getElementById("nosupport").hidden = false; 19 | } 20 | 21 | document.getElementById("options").addEventListener("click", () => { 22 | browser.runtime.openOptionsPage(); 23 | }, { 24 | passive: true, 25 | }); 26 | }, { 27 | passive: true, 28 | once: true, 29 | }); 30 | -------------------------------------------------------------------------------- /styles/firstrun.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: sans-serif; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | main { 9 | max-width: 850px; 10 | margin: 0 auto; 11 | } 12 | 13 | .buttons { 14 | text-align: center; 15 | margin-bottom: 1.5em; 16 | } 17 | 18 | button { 19 | background: #0a84ff; 20 | padding: 0.7em; 21 | border-radius: 5px; 22 | border: none; 23 | color: white; 24 | font-size: 1.3em; 25 | } 26 | 27 | button:active { 28 | background: #0060df; 29 | } 30 | 31 | summary { 32 | font-weight: bold; 33 | } 34 | 35 | footer { 36 | font-size: 0.8em; 37 | background: #d7d7db; 38 | padding: 0.5em; 39 | margin-top: 1em; 40 | text-align: center; 41 | } 42 | 43 | footer ul { 44 | margin: 0; 45 | padding: 1em 0; 46 | list-style-type: none; 47 | } 48 | 49 | footer ul li { 50 | display: inline-block; 51 | margin: 0; 52 | padding: 0; 53 | margin-right: 1em; 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | setup: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v6 8 | - uses: actions/setup-node@v6 9 | with: 10 | cache: 'npm' 11 | - run: npm ci --ignore-scripts --no-audit 12 | lint-js: 13 | runs-on: ubuntu-latest 14 | needs: setup 15 | steps: 16 | - uses: actions/checkout@v6 17 | - uses: actions/setup-node@v6 18 | with: 19 | cache: 'npm' 20 | - run: npm ci --no-audit 21 | - run: npm run lint:js 22 | lint-css: 23 | runs-on: ubuntu-latest 24 | needs: setup 25 | steps: 26 | - uses: actions/checkout@v6 27 | - uses: actions/setup-node@v6 28 | with: 29 | cache: 'npm' 30 | - run: npm ci --no-audit 31 | - run: npm run lint:css 32 | lint-webext: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v6 36 | - uses: freaktechnik/web-ext-lint@v1 37 | -------------------------------------------------------------------------------- /images/Notification_Sound_Icon_Square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /images/Notification_Sound_Icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/browserslist.yml: -------------------------------------------------------------------------------- 1 | name: Update browserslist 2 | on: 3 | schedule: 4 | - cron: "15 14 * * 3" 5 | 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | update: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: actions/setup-node@v6 17 | with: 18 | node-version: latest 19 | cache: 'npm' 20 | - name: Update browsers list 21 | run: npx --yes update-browserslist-db@latest 22 | - name: Check for Changes 23 | id: check_changes 24 | run: | 25 | if [[ -n "$(git diff --exit-code)" ]]; then 26 | echo "Changes detected." 27 | echo "::set-output name=has_changes::true" 28 | else 29 | echo "No changes detected." 30 | echo "::set-output name=has_changes::false" 31 | fi 32 | - name: Save updated db 33 | if: steps.check_changes.outputs.has_changes == 'true' 34 | run: | 35 | git config user.name github-actions 36 | git config user.email github-actions@github.com 37 | git add package-lock.json 38 | git commit -m "chore: bump browserslist" 39 | git push origin HEAD:main 40 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "__MSG_extensionName__", 4 | "description": "__MSG_extensionDescription__", 5 | "default_locale": "en", 6 | "browser_specific_settings": { 7 | "gecko": { 8 | "id": "@notification-sound", 9 | "strict_min_version": "63.0a1", 10 | "data_collection_permissions": { 11 | "required": ["none"] 12 | } 13 | } 14 | }, 15 | "author": "freaktechnik", 16 | "version": "1.4.1", 17 | "icons": { 18 | "48": "images/Notification_Sound_Icon.svg", 19 | "96": "images/Notification_Sound_Icon.svg" 20 | }, 21 | "background": { 22 | "scripts": [ 23 | "scripts/stored-sound.js", 24 | "scripts/background.js" 25 | ] 26 | }, 27 | "options_ui": { 28 | "page": "pages/options.html", 29 | "browser_style": true 30 | }, 31 | "permissions": [ 32 | "storage", 33 | "management", 34 | "downloads", 35 | "menus", 36 | "tabs" 37 | ], 38 | "content_scripts": [ 39 | { 40 | "matches": [ 41 | "*://*/*" 42 | ], 43 | "js": [ 44 | "scripts/content-script.js" 45 | ], 46 | "run_at": "document_start", 47 | "all_frames": true 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Notification Sound" 4 | }, 5 | "extensionDescription": { 6 | "message": "Makes a sound when a notification is shown" 7 | }, 8 | "remove": { 9 | "message": "Remove" 10 | }, 11 | "ignoreHost": { 12 | "message": "Mute notifications &from $HOST$", 13 | "placeholders": { 14 | "HOST": { 15 | "content": "$1" 16 | } 17 | } 18 | }, 19 | "allowHost": { 20 | "message": "Make sound for notifications &from $HOST$", 21 | "placeholders": { 22 | "HOST": { 23 | "content": "$1" 24 | } 25 | } 26 | }, 27 | "currentSound": { 28 | "message": "Current sound:" 29 | }, 30 | "defaultSound": { 31 | "message": "Default" 32 | }, 33 | "globalSound": { 34 | "message": "Global" 35 | }, 36 | "previewSound": { 37 | "message": "Preview sound" 38 | }, 39 | "resetSound": { 40 | "message": "Reset sound" 41 | }, 42 | "volume": { 43 | "message": "Volume:" 44 | }, 45 | "replaceSound": { 46 | "message": "Replace sound:" 47 | }, 48 | "customSound": { 49 | "message": "Set custom sound" 50 | }, 51 | "cannotDecode": { 52 | "message": "Browser can not play selected file" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Notification Sound", 3 | "name": "notification-sound", 4 | "version": "1.4.0", 5 | "description": "Makes a sound when a notification is shown", 6 | "author": "Martin Giger (https://humanoids.be)", 7 | "license": "MPL-2.0", 8 | "private": true, 9 | "scripts": { 10 | "lint:js": "eslint scripts/ manifest.json", 11 | "lint:css": "stylelint styles/*.css", 12 | "lint:webext": "web-ext lint", 13 | "test": "npm run lint:js && npm run lint:css && npm run lint:webext", 14 | "start": "web-ext run", 15 | "build": "web-ext build -i README.md package.json package-lock.json images/Notification_Sound_Icon_Square.svg" 16 | }, 17 | "homepage": "https://addons.mozilla.org/en-US/firefox/addon/notification-sound/", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/freaktechnik/notification-sounds.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/freaktechnik/notification-sounds/issues" 24 | }, 25 | "devDependencies": { 26 | "@freaktechnik/eslint-config-extension": "^12.0.1", 27 | "eslint": "^9.39.2", 28 | "stylelint": "^16.26.1", 29 | "stylelint-config-standard": "^39.0.1", 30 | "stylelint-no-unsupported-browser-features": "^8.0.5", 31 | "web-ext": "^9.2.0" 32 | }, 33 | "browserslist": [ 34 | "Firefox >= 63" 35 | ], 36 | "stylelint": { 37 | "extends": "stylelint-config-standard", 38 | "plugins": [ 39 | "stylelint-no-unsupported-browser-features" 40 | ], 41 | "rules": { 42 | "color-named": "always-where-possible", 43 | "plugin/no-unsupported-browser-features": true 44 | } 45 | }, 46 | "type": "module" 47 | } 48 | -------------------------------------------------------------------------------- /styles/options.css: -------------------------------------------------------------------------------- 1 | button + button { 2 | margin-left: 0.5em; 3 | } 4 | 5 | output { 6 | margin-right: 0.5em; 7 | } 8 | 9 | footer { 10 | font-size: 0.8em; 11 | background: #d7d7db; 12 | padding: 0.2em; 13 | margin-top: 1em; 14 | } 15 | 16 | footer ul { 17 | margin: 0; 18 | padding: 1em 0; 19 | list-style-type: none; 20 | } 21 | 22 | footer ul li { 23 | display: inline-block; 24 | margin: 0; 25 | padding: 0; 26 | margin-right: 1em; 27 | } 28 | 29 | .current-sound-label { 30 | font-weight: bold; 31 | } 32 | 33 | .current-sound-label > output { 34 | font-weight: normal; 35 | } 36 | 37 | .filterlist { 38 | max-height: 500px; 39 | } 40 | 41 | .filterlist form, 42 | .filterlist form label { 43 | display: flex; 44 | align-items: baseline; 45 | } 46 | 47 | .filterlist form label, 48 | .filterlist ul > li span { 49 | flex-grow: 2; 50 | margin-right: 0.5em; 51 | } 52 | 53 | .filterlist form label input { 54 | flex-grow: 2; 55 | margin-left: 0.5em; 56 | } 57 | 58 | .filterlist ul { 59 | list-style-type: none; 60 | margin: 0.5em 0; 61 | padding: 0; 62 | } 63 | 64 | .filterlist ul > li { 65 | margin: 0; 66 | padding: 0; 67 | } 68 | 69 | .filterlist ul > li details { 70 | padding: 0.25em 0; 71 | } 72 | 73 | .filterlist ul > li details > summary { 74 | list-style-position: outside; 75 | margin-left: 1em; 76 | } 77 | 78 | .filterlist ul > li details > summary > div, 79 | .filterlist ul > li.no-details { 80 | display: flex; 81 | } 82 | 83 | .subsection { 84 | margin-left: 24px; 85 | } 86 | 87 | .error { 88 | background: #ff0039; 89 | color: white; 90 | padding: 0.5em; 91 | margin-bottom: 0.5em; 92 | } 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![icon](images/Notification_Sound_Icon_Square.svg) Notification Sound 2 | 3 | Makes a sound when a notification is shown. 4 | 5 | Overrides Website's `Notification` constructor and `showNotification` method on 6 | ServiceWorker registrations to get notified when they're called. Other extensions 7 | can also let this extension know that they've shown a notification. 8 | 9 | Currently can not play a sound when a ServiceWorker shows a notification. 10 | 11 | Supports the `silent` and `sound` option on the `Notification` constructor. 12 | 13 | ## Official Download 14 | 15 | [![addons.mozilla.org/](https://addons.cdn.mozilla.net/static/img/addons-buttons/AMO-button_2.png)](https://addons.mozilla.org/firefox/addon/notification-sound/?utm_source=github&utm_content=readme) 16 | 17 | ## Extension integration 18 | 19 | For extensions to trigger a sound when creating a notification, they have to send the following message: 20 | 21 | ```js 22 | browser.runtime.sendMessage("@notification-sound", "new-notification"); 23 | ``` 24 | 25 | `new-notification` returns a Promise that resolves to a boolean, indicating, whether the sound was played (based on user settings). 26 | 27 | Starting from Firefox 56, an extension can just add the following code to the top level of its background page to send the message whenever it shows a notification: 28 | 29 | ```js 30 | browser.notifications.onShown.addListener(() => { 31 | browser.runtime.sendMessage("@notification-sound", "new-notification"); 32 | }); 33 | ``` 34 | 35 | ## Notification tester 36 | 37 | [Notification Tester](https://freaktechnik.github.io/notification-sounds/) 38 | 39 | ## Translations 40 | 41 | Translations are managed on [Transifex](https://www.transifex.com/freaktechnik/notification-sound/). Feel free to request a new language if you are willing to translate into it. 42 | 43 | ## Support and FAQ 44 | 45 | https://discourse.mozilla.org/t/support-notification-sound/23758 46 | 47 | ## License 48 | 49 | The code that makes up this project is licensed under the MPL-2.0 50 | 51 | The pop.flac sound is licensed under the [CC-3.0-BY license](https://creativecommons.org/licenses/by/3.0/) and was created by [Tobiasz 'unfa' Karoń](https://freesound.org/people/unfa/), original available on [freesound.org](https://freesound.org/people/unfa/sounds/245645/) 52 | 53 | The icon was created by @elioqoshi via request on http://opensourcedesign.net/. 54 | -------------------------------------------------------------------------------- /scripts/stored-sound.js: -------------------------------------------------------------------------------- 1 | const Database = { 2 | DB_NAME: "notification-sounds", 3 | DB_VERSION: 2, 4 | STORE_NAME: "sounds", 5 | db: undefined, 6 | _waitForRequest(request) { 7 | return new Promise((resolve, reject) => { 8 | request.addEventListener("success", (event) => resolve(event.target.result), { once: true }); 9 | request.addEventListener("error", reject, { once: true }); 10 | }); 11 | }, 12 | init() { 13 | if(!this.db) { 14 | const request = globalThis.indexedDB.open(Database.DB_NAME, Database.DB_VERSION); 15 | request.addEventListener("upgradeneeded", (event) => { 16 | if(!Array.from(event.target.result.objectStoreNames).includes(Database.STORE_NAME)) { 17 | event.target.result.createObjectStore(Database.STORE_NAME); 18 | } 19 | }, { once: true }); 20 | return this._waitForRequest(request).then((r) => { 21 | this.db = r; 22 | }); 23 | } 24 | return Promise.resolve(); 25 | }, 26 | async store(blob, name) { 27 | await this.init(); 28 | const transaction = this.db.transaction(Database.STORE_NAME, "readwrite"), 29 | store = transaction.objectStore(Database.STORE_NAME), 30 | request = store.put(blob, name); 31 | return this._waitForRequest(request); 32 | }, 33 | async get(name) { 34 | await this.init(); 35 | const transaction = this.db.transaction(Database.STORE_NAME, "readonly"), 36 | store = transaction.objectStore(Database.STORE_NAME), 37 | request = store.get(name); 38 | return this._waitForRequest(request); 39 | }, 40 | async remove(name) { 41 | await this.init(); 42 | const transaction = this.db.transaction(Database.STORE_NAME, "readwrite"), 43 | store = transaction.objectStore(Database.STORE_NAME), 44 | request = store.delete(name); 45 | return this._waitForRequest(request); 46 | }, 47 | }; 48 | 49 | // eslint-disable-next-line no-unused-vars 50 | class StoredBlob { 51 | constructor(name) { 52 | Database.init(); 53 | this.name = name; 54 | } 55 | 56 | save(blob) { 57 | return Database.store(blob, this.name); 58 | } 59 | 60 | get() { 61 | return Database.get(this.name); 62 | } 63 | 64 | delete() { 65 | return Database.remove(this.name); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pages/firstrun.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Notification Sound 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

Congratulations, you can now hear your notifications!

15 |

The extension will now play the default sound for most notifications. Click the button below to preview the sound or head to the options to customize the sound.

16 | 17 |
18 | 19 | 20 |
21 |
22 | What notifications the sound will play for 23 |

The notification sound will be played for the following notifications:

24 |
    25 |
  • Notifications generated by a site while it is open in a tab
  • 26 |
  • Notifications from an extension that supports this extension
  • 27 |
28 |

The following cases can not be detected to play a sound:

29 |
    30 |
  • Website displays a push notification, no matter if the tab for it is open or closed (service workers)
  • 31 |
  • Extension that does not support this extension shows a notification
  • 32 |
  • Other programs on your computer display a notification
  • 33 |
34 |
35 |
36 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /scripts/content-script.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* global cloneInto, exportFunction */ 3 | //TODO handle renotify & tag 4 | 5 | const OPTIONS_INDEX = 1, 6 | dispatchNotificationEvent = (options) => { 7 | if(Notification.permission === "granted" && (!options || !options.silent)) { 8 | if(!options || !options.sound) { 9 | browser.runtime.sendMessage("new-notification"); 10 | } 11 | else { 12 | browser.runtime.sendMessage({ 13 | command: 'play', 14 | url: new URL(options.sound, globalThis.location).toString(), 15 | }); 16 | } 17 | } 18 | }, 19 | OriginalNotification = globalThis.wrappedJSObject.Notification, 20 | /** 21 | * @class 22 | * @param {?} arguments_ - Arguments. 23 | * @returns {Notification} Instance. 24 | * @extends {Notification} 25 | */ 26 | ModifiedNotification = function(...arguments_) { 27 | dispatchNotificationEvent(arguments_.length > OPTIONS_INDEX ? arguments_[OPTIONS_INDEX] : undefined); 28 | return new OriginalNotification(...arguments_); 29 | }, 30 | descriptor = globalThis.wrappedJSObject.Object.getOwnPropertyDescriptors(OriginalNotification); 31 | 32 | 33 | // Replace original Notification constructor with the version that plays sounds. 34 | globalThis.wrappedJSObject.Notification = exportFunction(ModifiedNotification, globalThis, { 35 | allowCrossOriginArguments: true, 36 | }); 37 | // Ensure the prototype is correct, inheriting from the original prototype. 38 | descriptor.prototype.value = cloneInto({}, globalThis); 39 | Object.setPrototypeOf(descriptor.prototype.value, OriginalNotification.prototype); 40 | // Make instanceof work with overwritten constructor. 41 | descriptor[Symbol.hasInstance] = cloneInto({ 42 | enumerable: false, 43 | writable: false, 44 | configurable: false, 45 | value: (instance) => instance instanceof OriginalNotification, 46 | }, globalThis, { 47 | cloneFunctions: true, 48 | }); 49 | // Set static propertties on our constructor. 50 | globalThis.wrappedJSObject.Object.defineProperties(globalThis.wrappedJSObject.Notification, descriptor); 51 | // Set the constructor property. Have to set it here so it gets set to the same instance as the main constructor. 52 | globalThis.wrappedJSObject.Notification.prototype.constructor = globalThis.wrappedJSObject.Notification; 53 | // Set the top level proto, which for some reason is an EventTarget. 54 | Object.setPrototypeOf(globalThis.wrappedJSObject.Notification, Object.getPrototypeOf(OriginalNotification)); 55 | 56 | // Override serviceWorker notifications in website scope. 57 | const original = globalThis.wrappedJSObject.ServiceWorkerRegistration.prototype.showNotification, 58 | /** 59 | * @param {?} arguments_ - Arguments. 60 | * @returns {Promise} Resolves with the original promise. 61 | * @this {ServiceWorkerRegistration} 62 | */ 63 | replacement = function(...arguments_) { 64 | dispatchNotificationEvent(arguments_.length > OPTIONS_INDEX ? arguments_[OPTIONS_INDEX] : undefined); 65 | return Reflect.apply(original, this, arguments_); 66 | }; 67 | 68 | globalThis.wrappedJSObject.ServiceWorkerRegistration.prototype.showNotification = exportFunction(replacement, globalThis, { 69 | allowCrossOriginArguments: true, 70 | }); 71 | -------------------------------------------------------------------------------- /pages/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |

Sound

13 |

Default

14 |

15 | 18 |
19 |
20 |

Websites

21 |

22 | 23 |

24 |

25 | 26 |

27 | 28 |

Ignored Websites

29 |

Allowed Websites

30 |
31 |
32 | 33 | 34 |
35 |
    36 |
37 |
38 |
39 |
40 |

Extensions

41 | 42 |

Ignored Extensions

43 |

Allowed Extensions

44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 |
    56 |
57 |
58 |
59 |
60 |

Downloads

61 |

62 | 63 |

64 |

65 | 66 |

67 |
68 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /scripts/options.js: -------------------------------------------------------------------------------- 1 | /* global StoredBlob */ 2 | "use strict"; 3 | 4 | //TODO nicer look for extensions list. Probably needs some inter-extension communication though :S 5 | //TODO react to changes from context menu 6 | //TODO test media with an audio element and canPlayType() 7 | 8 | const stores = { 9 | extension: { 10 | blocked: "blockedExtensions", 11 | allowed: "allowedExtensions", 12 | }, 13 | website: { 14 | blocked: "blockedWebsites", 15 | allowed: "allowedWebsites", 16 | }, 17 | }, 18 | WWW_PREFIX = "www.", 19 | FULL_VOLUME = 1.0, 20 | GLOBAL_PREF = 'soundName', 21 | PREFIX = 'sound-', 22 | TEST_AUDIO = new Audio(), 23 | EMPTY = 0, 24 | showError = (error) => { 25 | console.error(error); 26 | let message; 27 | if(typeof error === 'object') { 28 | if(error instanceof Error || 'message' in error) { 29 | ({ message } = error); 30 | } 31 | else if('target' in error && 'error' in error.target) { 32 | ({ message } = error.target.error); 33 | } 34 | else { 35 | message = JSON.stringify(error); 36 | } 37 | } 38 | else { 39 | message = error; 40 | } 41 | const errorPanel = document.getElementById("error"); 42 | errorPanel.textContent = message; 43 | errorPanel.hidden = false; 44 | }; 45 | 46 | let globalSound; 47 | 48 | class Sound { 49 | static canPlay(mime) { 50 | return TEST_AUDIO.canPlayType(mime).length > EMPTY; 51 | } 52 | 53 | constructor(prefName, root, defaultSound = browser.i18n.getMessage('defaultSound')) { 54 | this.prefName = prefName; 55 | this.root = root; 56 | this.defaultSound = defaultSound; 57 | this.resetButton = this.root.querySelector(".resetSound"); 58 | this.input = this.root.querySelector(".sound"); 59 | this.output = this.root.querySelector(".currentSound"); 60 | this.volume = this.root.querySelector(".volume"); 61 | this.preview = this.root.querySelector(".playSound"); 62 | this.restoreFile(); 63 | 64 | this.input.addEventListener("input", () => this.selectFile().catch(showError), { 65 | capture: false, 66 | passive: true, 67 | }); 68 | this.resetButton.addEventListener("click", () => this.reset(), { 69 | capture: false, 70 | passive: true, 71 | }); 72 | this.preview.addEventListener("click", () => { 73 | browser.runtime.sendMessage({ 74 | command: "preview-sound", 75 | pref: this.prefName, 76 | }).catch(showError); 77 | }, { 78 | capture: false, 79 | passive: true, 80 | }); 81 | this.volume.addEventListener("input", () => this.saveVolume(), { 82 | capture: false, 83 | passive: true, 84 | }); 85 | } 86 | 87 | get volumePref() { 88 | return `${this.prefName}-volume`; 89 | } 90 | 91 | async reset() { 92 | this.input.setCustomValidity(""); 93 | this.resetButton.disabled = true; 94 | this.resetButton.classList.add("disabled"); 95 | this.input.value = ''; 96 | this.output.value = this.defaultSound; 97 | if(this.prefName != GLOBAL_PREF) { 98 | this.preview.disabled = true; 99 | this.preview.classList.add("disabled"); 100 | } 101 | const { [this.prefName]: soundName } = await browser.storage.local.get(this.prefName); 102 | if(soundName) { 103 | const storedFile = new StoredBlob(this.prefName + soundName); 104 | await storedFile.delete(); 105 | } 106 | return browser.storage.local.set({ 107 | [this.prefName]: '', 108 | }); 109 | } 110 | 111 | async selectFile() { 112 | if(this.input.files.length) { 113 | const [ file ] = this.input.files, 114 | storedFile = new StoredBlob(this.prefName + file.name); 115 | if(!Sound.canPlay(file.type)) { 116 | console.error("Browser reported that it can not play", file.type, "at all"); 117 | this.input.setCustomValidity(browser.i18n.getMessage("cannotDecode")); 118 | return; 119 | } 120 | const { [this.prefName]: oldName } = await browser.storage.local.get(this.prefName); 121 | await storedFile.save(file); 122 | await browser.storage.local.set({ 123 | [this.prefName]: file.name, 124 | }); 125 | this.input.setCustomValidity(""); 126 | this.resetButton.disabled = false; 127 | this.resetButton.classList.remove("disabled"); 128 | this.preview.disabled = false; 129 | this.preview.classList.remove("disabled"); 130 | this.output.value = file.name; 131 | if(oldName) { 132 | const oldFile = new StoredBlob(this.prefName + oldName); 133 | await oldFile.delete(); 134 | } 135 | } 136 | } 137 | 138 | saveVolume() { 139 | return browser.storage.local.set({ 140 | [this.volumePref]: this.volume.valueAsNumber, 141 | }); 142 | } 143 | 144 | async restoreFile() { 145 | const { 146 | [this.prefName]: soundName, 147 | [this.volumePref]: volume, 148 | } = await browser.storage.local.get({ 149 | [this.prefName]: '', 150 | [this.volumePref]: this.prefName === GLOBAL_PREF ? FULL_VOLUME : globalSound.volume.valueAsNumber, 151 | }); 152 | if(soundName.length) { 153 | this.output.value = soundName; 154 | this.resetButton.disabled = false; 155 | this.resetButton.classList.remove("disabled"); 156 | if(this.prefName != GLOBAL_PREF) { 157 | this.preview.disabled = false; 158 | this.preview.classList.remove("disabled"); 159 | } 160 | } 161 | this.volume.value = volume; 162 | } 163 | } 164 | 165 | class FilterList { 166 | static buildPlayer(soundName = browser.i18n.getMessage('globalSound'), volumeValue = FULL_VOLUME) { 167 | const root = document.createDocumentFragment(), 168 | firstP = document.createElement('p'), 169 | secondP = document.createElement('p'), 170 | currentSoundLabel = document.createElement('label'), 171 | currentSound = document.createElement('output'), 172 | playSound = document.createElement('button'), 173 | resetSound = document.createElement('button'), 174 | volumeLabel = document.createElement('label'), 175 | volume = document.createElement('input'), 176 | soundLabel = document.createElement('label'), 177 | sound = document.createElement('input'); 178 | 179 | currentSoundLabel.classList.add('browser-style-label', 'current-sound-label'); 180 | currentSoundLabel.textContent = `${browser.i18n.getMessage('currentSound')} `; 181 | currentSound.classList.add('currentSound'); 182 | currentSound.value = soundName; 183 | currentSoundLabel.append(currentSound); 184 | firstP.append(currentSoundLabel); 185 | 186 | playSound.textContent = '▶'; 187 | playSound.classList.add('playSound', 'browser-style', 'disabled'); 188 | playSound.disabled = true; 189 | playSound.title = browser.i18n.getMessage('previewSound'); 190 | firstP.append(playSound); 191 | 192 | resetSound.textContent = '🗙'; 193 | resetSound.classList.add('resetSound', 'browser-style', 'disabled'); 194 | resetSound.disabled = true; 195 | resetSound.type = 'reset'; 196 | resetSound.title = browser.i18n.getMessage('resetSound'); 197 | firstP.append(resetSound); 198 | 199 | firstP.append(' '); 200 | 201 | volumeLabel.classList.add('browser-style-label'); 202 | volumeLabel.textContent = browser.i18n.getMessage('volume'); 203 | 204 | volume.type = 'range'; 205 | volume.classList.add('volume'); 206 | volume.min = 0; 207 | volume.max = 1; 208 | volume.step = "any"; 209 | volume.value = volumeValue; 210 | 211 | volumeLabel.append(volume); 212 | firstP.append(volumeLabel); 213 | 214 | soundLabel.classList.add('browser-style-label'); 215 | soundLabel.textContent = `${browser.i18n.getMessage('replaceSound')} `; 216 | 217 | sound.type = 'file'; 218 | sound.classList.add('sound'); 219 | sound.accept = 'audio/*'; 220 | 221 | soundLabel.append(sound); 222 | secondP.append(soundLabel); 223 | 224 | root.append(firstP); 225 | root.append(secondP); 226 | return root; 227 | } 228 | 229 | constructor(datastore, anchor, showSoundEditor = false) { 230 | this.datastore = datastore; 231 | this.anchor = anchor; 232 | this.list = this.anchor.querySelector("ul"); 233 | this.showSoundEditor = showSoundEditor; 234 | this.changed = false; 235 | 236 | browser.storage.local.get({ 237 | [this.datastore]: [], 238 | }) 239 | .then((values) => { 240 | if(!values[this.datastore].length) { 241 | this.changed = true; 242 | return browser.storage.local.set({ 243 | [this.datastore]: [], 244 | }); 245 | } 246 | 247 | for(const value of values[this.datastore]) { 248 | this.appendItem(value); 249 | } 250 | }) 251 | .catch(showError); 252 | 253 | const input = this.anchor.querySelector("input"); 254 | 255 | this.addListener = (event) => { 256 | event.preventDefault(); 257 | if(input.validity.valid && input.value != "") { 258 | this.addItem(input.value); 259 | input.value = ""; 260 | } 261 | }; 262 | this.anchor.querySelector(".addbutton").addEventListener("click", this.addListener); 263 | this.anchor.querySelector("form").addEventListener("submit", this.addListener); 264 | } 265 | 266 | async itemContent(value) { 267 | const span = document.createElement("span"); 268 | span.textContent = value; 269 | return span; 270 | } 271 | 272 | async appendItem(value) { 273 | const root = document.createElement("li"), 274 | button = document.createElement("button"); 275 | let container = root, 276 | parent = root; 277 | if(this.showSoundEditor) { 278 | const details = document.createElement("details"), 279 | flexContainer = document.createElement("div"); 280 | 281 | 282 | // Lazily initialize the sound settings controller. 283 | details.addEventListener('toggle', () => { 284 | new Sound(PREFIX + value, details, browser.i18n.getMessage('globalSound')); 285 | }, { 286 | once: true, 287 | passive: true, 288 | capture: false, 289 | }); 290 | 291 | container = flexContainer; 292 | parent = details; 293 | } 294 | else { 295 | root.classList.add('no-details'); 296 | } 297 | container.append(await this.itemContent(value)); 298 | root.dataset.value = value; 299 | 300 | if(this.showSoundEditor) { 301 | const soundButton = document.createElement("button"); 302 | soundButton.textContent = '♬'; 303 | soundButton.title = browser.i18n.getMessage('customSound'); 304 | soundButton.classList.add('browser-style'); 305 | container.append(soundButton); 306 | } 307 | 308 | button.textContent = "🗙"; 309 | button.title = browser.i18n.getMessage("remove"); 310 | button.classList.add("removebutton", "browser-style"); 311 | button.addEventListener("click", () => { 312 | this.removeItem(value); 313 | }, { 314 | passive: true, 315 | capture: false, 316 | }); 317 | 318 | container.append(button); 319 | if(!container.isEqualNode(parent)) { 320 | parent.append(container); 321 | } 322 | 323 | if(this.showSoundEditor) { 324 | const summary = document.createElement("summary"), 325 | soundControls = FilterList.buildPlayer(); 326 | summary.append(container); 327 | parent.append(summary); 328 | parent.append(soundControls); 329 | } 330 | if(!parent.isEqualNode(root)) { 331 | root.append(parent); 332 | } 333 | 334 | this.list.append(root); 335 | } 336 | 337 | validate(value) { 338 | return value; 339 | } 340 | 341 | addItem(value) { 342 | try { 343 | value = this.validate(value); 344 | Promise.all([ 345 | browser.storage.local.get(this.datastore).then((values) => { 346 | values[this.datastore].push(value); 347 | this.changed = true; 348 | return browser.storage.local.set(values); 349 | }), 350 | this.appendItem(value), 351 | ]).catch(showError); 352 | } 353 | catch{ 354 | // Do nothing 355 | // showError(error); 356 | } 357 | } 358 | 359 | removeItem(value) { 360 | const p = browser.storage.local.get(this.datastore).then((values) => { 361 | const newValue = values[this.datastore].filter((v) => v !== value); 362 | this.changed = true; 363 | return browser.storage.local.set({ 364 | [this.datastore]: newValue, 365 | }); 366 | }), 367 | item = document.querySelector(`[data-value="${value}"]`); 368 | item.remove(); 369 | return p; 370 | } 371 | 372 | clear() { 373 | while(this.list.firstElementChild) { 374 | this.list.firstElementChild.remove(); 375 | } 376 | this.anchor.querySelector(".addbutton").removeEventListener("click", this.addListener); 377 | this.anchor.querySelector("form").removeEventListener("submit", this.addListener); 378 | } 379 | 380 | didChange() { 381 | if(this.changed) { 382 | this.changed = false; 383 | return true; 384 | } 385 | return false; 386 | } 387 | } 388 | 389 | class Filter { 390 | constructor(prefStores, section, listType = FilterList) { 391 | this.stores = prefStores; 392 | this.section = section; 393 | this.listType = listType; 394 | 395 | this.all = this.section.querySelector(".all"); 396 | 397 | browser.storage.local.get({ 398 | [this.all.id]: true, 399 | }) 400 | .then((values) => { 401 | this.all.checked = values[this.all.id]; 402 | this.update(); 403 | }) 404 | .catch(showError); 405 | 406 | this.all.addEventListener("input", () => { 407 | this.update(); 408 | browser.storage.local.set({ 409 | [this.all.id]: this.all.checked, 410 | }); 411 | }); 412 | 413 | browser.storage.onChanged.addListener((changes, area) => { 414 | if(area === 'local' && changes.hasOwnProperty(this.storeName) && !this.list.didChange()) { 415 | this.update(); 416 | } 417 | }); 418 | } 419 | 420 | get storeName() { 421 | return this.all.checked ? this.stores.blocked : this.stores.allowed; 422 | } 423 | 424 | update() { 425 | this.updateList(); 426 | this.updateTitle(); 427 | } 428 | 429 | updateList() { 430 | if(this.list) { 431 | this.list.clear(); 432 | } 433 | 434 | const anchor = this.section.querySelector(".filterlist"), 435 | ListConstructor = this.listType; 436 | this.list = new ListConstructor(this.storeName, anchor, !this.all.checked); 437 | } 438 | 439 | updateTitle() { 440 | this.section.querySelector(".blocked").hidden = !this.all.checked; 441 | this.section.querySelector(".allowed").hidden = this.all.checked; 442 | } 443 | } 444 | 445 | class ExtensionFilterList extends FilterList { 446 | async itemContent(value) { 447 | try { 448 | const extension = await browser.management.get(value), 449 | span = document.createElement("span"); 450 | span.append(document.createTextNode(`${extension.name} (${extension.id})`)); 451 | return span; 452 | } 453 | catch{ 454 | return super.itemContent(value); 455 | } 456 | } 457 | } 458 | 459 | class HostFilterList extends FilterList { 460 | validate(value) { 461 | const NO_RESULT = -1; 462 | if(value.search(/[\d.A-Za-z-]+\.[a-z]{2,}/) === NO_RESULT) { 463 | throw new Error("Not a valid host name"); 464 | } 465 | if(value.startsWith(WWW_PREFIX)) { 466 | return value.slice(WWW_PREFIX.length); 467 | } 468 | return value; 469 | } 470 | } 471 | 472 | class Checkbox { 473 | constructor(storageKey, node, defaultValue = true) { 474 | this.storageKey = storageKey; 475 | this.checkbox = node; 476 | this.defaultValue = defaultValue; 477 | this.changeListeners = new Set(); 478 | 479 | this.checkbox.addEventListener("change", () => { 480 | browser.storage.local.set({ 481 | [this.storageKey]: this.checkbox.checked, 482 | }); 483 | for(const listener of this.changeListeners.values()) { 484 | listener(this.checkbox.checked); 485 | } 486 | }, { 487 | capture: false, 488 | passive: true, 489 | }); 490 | 491 | browser.storage.local.get({ 492 | [this.storageKey]: this.defaultValue, 493 | }) 494 | .then(({ [this.storageKey]: value }) => { 495 | this.checkbox.checked = value; 496 | for(const listener of this.changeListeners.values()) { 497 | listener(this.checkbox.checked); 498 | } 499 | }) 500 | .catch(showError); 501 | } 502 | 503 | addChangeListener(cbk) { 504 | this.changeListeners.add(cbk); 505 | } 506 | 507 | toggleState(state) { 508 | this.checkbox.disabled = !state; 509 | this.checkbox.parentNode.classList.toggle('disabled', !state); 510 | } 511 | } 512 | 513 | browser.runtime.onMessage.addListener((message) => { 514 | if(typeof message === "object" && message.command === "error") { 515 | showError(message.error); 516 | } 517 | }); 518 | 519 | globalThis.addEventListener("DOMContentLoaded", () => { 520 | globalSound = new Sound(GLOBAL_PREF, document.getElementById('sound-section')); 521 | new Filter(stores.extension, document.getElementById("extension-section"), ExtensionFilterList); 522 | new Filter(stores.website, document.getElementById("website-section"), HostFilterList); 523 | const download = new Checkbox("download", document.getElementById("download")), 524 | downloadAlways = new Checkbox("downloadAlways", document.getElementById("downloadalways"), false); 525 | new Checkbox("websiteSound", document.getElementById("websitesound"), true); 526 | new Checkbox("tabMuted", document.getElementById("tabmuted"), true); 527 | 528 | download.addChangeListener((checked) => { 529 | downloadAlways.toggleState(checked); 530 | }); 531 | if(!download.checkbox.checked) { 532 | downloadAlways.toggleState(false); 533 | } 534 | 535 | const datalist = document.getElementById("extensions"); 536 | browser.runtime.sendMessage("recent-extensions") 537 | .then((recents) => { 538 | const existingRecents = new Set(Array.from(datalist.options, (o) => o.value)); 539 | return Promise.all(recents 540 | .filter((recent) => !existingRecents.has(recent)) 541 | .map((recent) => browser.management.get(recent) 542 | .then((existing) => new Option(existing.name, recent), () => new Option(recent)), 543 | ), 544 | ); 545 | }) 546 | .then((options) => { 547 | for(const o of options) { 548 | datalist.append(o); 549 | } 550 | }) 551 | .catch(showError); 552 | }); 553 | -------------------------------------------------------------------------------- /scripts/background.js: -------------------------------------------------------------------------------- 1 | /* global StoredBlob */ 2 | "use strict"; 3 | 4 | //TODO tab context menu to quickly whitelist/unwhitelist host (requires the before show listener to prepare) 5 | //TODO sync? 6 | //TODO catch ServiceWorker scoped showNotification calls. 7 | 8 | const SOURCES = { 9 | WEBSITE: 0, 10 | EXTENSION: 1, 11 | }, 12 | NOTIFICATION_TOPIC = "new-notification", 13 | WWW_PREFIX = 'www.', 14 | DEFAULT_VOLUME = 1.0, 15 | NotificationListener = { 16 | DEFAULT_SOUND: browser.runtime.getURL('pop.flac'), 17 | PREF_NAME: 'soundName', 18 | getPlayer() { 19 | const player = new Audio(); 20 | player.autoplay = false; 21 | player.preload = true; 22 | return player; 23 | }, 24 | init() { 25 | this.player = this.getPlayer(); 26 | this.playing = this.player; 27 | this.currentPref = this.PREF_NAME; 28 | this.loadSound(); 29 | this.setPlayerVolume(); 30 | 31 | browser.storage.onChanged.addListener((changes, areaName) => { 32 | if(areaName === "local") { 33 | if(this.PREF_NAME in changes) { 34 | this.loadSound(); 35 | } 36 | if(`${this.PREF_NAME}-volume` in changes) { 37 | this.player.volume = changes[`${this.PREF_NAME}-volume`].newValue; 38 | if(this.currentPref === this.PREF_NAME) { 39 | this.playing.volume = this.player.volume; 40 | } 41 | } 42 | if(this.currentPref !== this.PREF_NAME && `${this.currentPref}-volume` in changes) { 43 | this.playing.volume = changes[`${this.currentPref}-volume`].newValue; 44 | } 45 | } 46 | }); 47 | }, 48 | async setPlayerVolume(prefName = this.PREF_NAME, player = this.player) { 49 | const volumePrefName = `${prefName}-volume`; 50 | let { [volumePrefName]: data } = await browser.storage.local.get({ 51 | [volumePrefName]: false, 52 | }); 53 | // Fall back to global volume if none is set for the given pref 54 | if(data === false && prefName !== this.PREF_NAME) { 55 | const defaultVolumePref = `${this.PREF_NAME}-volume`, 56 | { [defaultVolumePref]: defaultData } = await browser.storage.local.get({ 57 | [defaultVolumePref]: DEFAULT_VOLUME, 58 | }); 59 | this.currentPref = this.PREF_NAME; 60 | data = defaultData; 61 | } 62 | else if(data === false) { 63 | this.currentPref = prefName; 64 | data = DEFAULT_VOLUME; 65 | } 66 | player.volume = data; 67 | }, 68 | async loadFile(soundName) { 69 | const storedFile = new StoredBlob(soundName), 70 | file = await storedFile.get(); 71 | return URL.createObjectURL(file); 72 | }, 73 | async loadSound() { 74 | const { [this.PREF_NAME]: soundName } = await browser.storage.local.get({ 75 | [this.PREF_NAME]: '', 76 | }); 77 | if(this.player.src && this.player.src !== this.DEFAULT_SOUND) { 78 | const oldURL = this.player.src; 79 | this.player.src = ""; 80 | URL.revokeObjectURL(oldURL); 81 | } 82 | let url = this.DEFAULT_SOUND; 83 | if(soundName.length) { 84 | try { 85 | url = await this.loadFile(this.PREF_NAME + soundName); 86 | } 87 | catch(error) { 88 | console.error("Could not load configured sound", error); 89 | await browser.runtime.sendMessage({ 90 | command: "error", 91 | error: error.message, 92 | }); 93 | url = this.DEFAULT_SOUND; 94 | } 95 | } 96 | this.player.src = url; 97 | }, 98 | async extensionAllowed(id) { 99 | const { 100 | allExtensions, allowedExtensions, blockedExtensions, 101 | } = await browser.storage.local.get({ 102 | allExtensions: true, 103 | allowedExtensions: [], 104 | blockedExtensions: [], 105 | }); 106 | if(allExtensions) { 107 | return !blockedExtensions.includes(id); 108 | } 109 | 110 | return allowedExtensions.includes(id); 111 | }, 112 | async websiteAllowed(host, isMuted = false) { 113 | const { 114 | allWebsites, 115 | allowedWebsites, 116 | blockedWebsites, 117 | tabMuted, 118 | } = await browser.storage.local.get({ 119 | allWebsites: true, 120 | allowedWebsites: [], 121 | blockedWebsites: [], 122 | tabMuted: true, 123 | }); 124 | if(tabMuted && isMuted) { 125 | return false; 126 | } 127 | if(allWebsites) { 128 | return !blockedWebsites.includes(host); 129 | } 130 | 131 | return allowedWebsites.includes(host); 132 | }, 133 | shouldMakeSound(source, sourceSpec, sourceMuted = false) { 134 | if(source === SOURCES.EXTENSION) { 135 | return this.extensionAllowed(sourceSpec); 136 | } 137 | else if(source === SOURCES.WEBSITE) { 138 | return this.websiteAllowed(sourceSpec, sourceMuted); 139 | } 140 | return false; 141 | }, 142 | async promisedPlay(player) { 143 | await player.play(); 144 | this.playing = player; 145 | }, 146 | makeSound() { 147 | if(this.playing !== this.player && !this.playing.paused) { 148 | this.playing.pause(); 149 | } 150 | this.currentPref = this.PREF_NAME; 151 | this.player.currentTime = 0; 152 | return this.promisedPlay(this.player); 153 | }, 154 | async getPrefForHost(sourceSpec) { 155 | // For now we only have one pref per host, but hopefully not forever. 156 | //TODO even though a page might not have a custom sound, it may have a custom volume! 157 | const hostPrefName = `sound-${sourceSpec}`, 158 | { [hostPrefName]: hostPrefValue } = await browser.storage.local.get({ 159 | [hostPrefName]: false, 160 | }); 161 | if(hostPrefValue) { 162 | return hostPrefName; 163 | } 164 | return this.PREF_NAME; 165 | }, 166 | async onNotification(source, sourceSpec, sourceMuted = false) { 167 | if(await this.shouldMakeSound(source, sourceSpec, sourceMuted)) { 168 | const prefName = await this.getPrefForHost(sourceSpec); 169 | if(prefName !== this.PREF_NAME) { 170 | try { 171 | await this.playFromStorage(prefName); 172 | return true; 173 | } 174 | catch(error) { 175 | console.error("Could not load and play custom sound, falling back to global sound", error); 176 | } 177 | } 178 | await this.makeSound(); 179 | return true; 180 | } 181 | return false; 182 | }, 183 | async shouldPlaySound(source, sourceSpec, sourceMuted = false) { 184 | if(source === SOURCES.WEBSITE) { 185 | const { websiteSound } = await browser.storage.local.get({ 186 | websiteSound: true, 187 | }); 188 | if(!websiteSound) { 189 | return false; 190 | } 191 | } 192 | return this.shouldMakeSound(source, sourceSpec, sourceMuted); 193 | }, 194 | play(url, player = this.getPlayer()) { 195 | if(this.playing && !this.playing.paused) { 196 | this.playing.pause(); 197 | } 198 | player.src = url; 199 | return this.promisedPlay(player); 200 | }, 201 | preparePlay(url, prefName) { 202 | const player = this.getPlayer(); 203 | this.setPlayerVolume(prefName, player); 204 | return this.play(url, player); 205 | }, 206 | async onPlay(source, sourceSpec, url, sourceMuted = false) { 207 | if(await this.shouldPlaySound(source, sourceSpec, sourceMuted)) { 208 | const prefName = await this.getPrefForHost(sourceSpec); 209 | try { 210 | await this.preparePlay(url, prefName); 211 | } 212 | catch(error) { 213 | console.warn("Couldn't play file specified by the website, falling back to default sound", error); 214 | await this.onNotification(source, sourceSpec, sourceMuted); 215 | } 216 | } 217 | }, 218 | async playFromStorage(prefName) { 219 | const { [prefName]: soundName } = await browser.storage.local.get(prefName); 220 | if(soundName) { 221 | const url = await this.loadFile(prefName + soundName), 222 | discard = (event) => { 223 | let otherEvent = "ended"; 224 | if(event.type == otherEvent) { 225 | otherEvent = "pause"; 226 | } 227 | this.playing.removeEventListener(otherEvent, discard); 228 | URL.revokeObjectURL(url); 229 | this.playing = null; 230 | }; 231 | await this.preparePlay(url, prefName); 232 | this.playing.addEventListener("ended", discard, { 233 | once: true, 234 | passive: true, 235 | }); 236 | this.playing.addEventListener("pause", discard, { 237 | once: true, 238 | passive: true, 239 | }); 240 | } 241 | }, 242 | async preview(prefName) { 243 | if(prefName === this.PREF_NAME) { 244 | return this.makeSound(); 245 | } 246 | return this.playFromStorage(prefName); 247 | }, 248 | }, 249 | extractHost = (url) => { 250 | const urlObject = new URL(url); 251 | let host = urlObject.hostname; 252 | if(host.startsWith(WWW_PREFIX)) { 253 | host = host.slice(WWW_PREFIX.length); 254 | } 255 | return host; 256 | }, 257 | RecentExtensions = { 258 | recents: new Set(), 259 | add(id) { 260 | this.recents.add(id); 261 | }, 262 | get() { 263 | return Array.from(this.recents.values()); 264 | }, 265 | }, 266 | DownloadListener = { 267 | DOWNLOAD_COMPLETE: "complete", 268 | init() { 269 | browser.downloads.onChanged.addListener(async (download) => { 270 | if(download.state.current === this.DOWNLOAD_COMPLETE && download.state.previous !== this.DOWNLOAD_COMPLETE) { 271 | const lastWindow = await browser.windows.getLastFocused({ 272 | windowTypes: [ 'normal' ], 273 | }); 274 | if(!lastWindow.focused || lastWindow.state === "minimized") { 275 | const { download: downloadSound } = await browser.storage.local.get({ 276 | download: true, 277 | }); 278 | if(downloadSound) { 279 | NotificationListener.makeSound(); 280 | } 281 | } 282 | else { 283 | const { 284 | download: downloadSound, 285 | downloadAlways, 286 | } = await browser.storage.local.get({ 287 | download: true, 288 | downloadAlways: false, 289 | }); 290 | if(downloadSound && downloadAlways) { 291 | NotificationListener.makeSound(); 292 | } 293 | } 294 | } 295 | }); 296 | }, 297 | }, 298 | TabMenu = { 299 | MENU_ITEM: 'toggle-ignore', 300 | ONE_ITEM: 1, 301 | TST_ID: 'treestyletab@piro.sakura.ne.jp', 302 | TYPES: { 303 | VANILLA: 'vanilla', 304 | TST: 'tst', 305 | }, 306 | TST_TIMEOUT: 10000, 307 | currentId: 0, 308 | tstCurrentId: 0, 309 | disabledLabel: browser.i18n.getMessage('extensionName'), 310 | hasTST: false, 311 | init() { 312 | if(!browser.menus.hasOwnProperty('onShown') || !browser.menus.hasOwnProperty('onHidden')) { 313 | // Don't show the menu when we can't update the menu item 314 | return; 315 | } 316 | const { content_scripts: [ contentScript ] } = browser.runtime.getManifest(), 317 | parameters = { 318 | contexts: [ "tab" ], 319 | documentUrlPatterns: contentScript.matches, 320 | id: this.MENU_ITEM, 321 | title: this.disabledLabel, 322 | enabled: false, 323 | type: 'checkbox', 324 | }; 325 | browser.menus.create(parameters); 326 | this.registerTST(parameters); 327 | browser.menus.onShown.addListener((context, tab) => this.updateItem(context, tab).catch(console.error)); 328 | browser.menus.onHidden.addListener(() => this.closeMenu()); 329 | browser.menus.onClicked.addListener((context, tab) => this.handleClick(context, tab).catch(console.error)); 330 | browser.runtime.onMessageExternal.addListener((message, sender) => { 331 | if(sender.id === this.TST_ID) { 332 | switch(message.type) { 333 | case 'fake-contextMenu-click': 334 | this.handleClick(message.info, message.tab).catch(console.error); 335 | break; 336 | case 'fake-contextMenu-shown': 337 | this.updateItem(message.info, message.tab, this.TYPES.TST).catch(console.error); 338 | break; 339 | case 'fake-contextMenu-hidden': 340 | this.closeMenu(this.TYPES.TST); 341 | break; 342 | case 'shutdown': 343 | if(this.tstCheck) { 344 | clearInterval(this.tstCheck); 345 | } 346 | this.hasTST = false; 347 | break; 348 | case 'ready': 349 | this.registerTST(parameters); 350 | break; 351 | default: 352 | } 353 | } 354 | }); 355 | }, 356 | async registerTST(parameters) { 357 | try { 358 | await browser.runtime.sendMessage(this.TST_ID, { 359 | type: 'register-self', 360 | name: browser.i18n.getMessage("extensionName"), 361 | icons: browser.runtime.getManifest().icons, 362 | listeningTypes: [ 363 | 'ready', 364 | 'shutdown', 365 | 'fake-contextMenu-click', 366 | 'fake-contextMenu-shown', 367 | 'fake-contextMenu-hidden', 368 | ], 369 | }); 370 | this.hasTST = true; 371 | await browser.runtime.sendMessage(this.TST_ID, { 372 | type: 'fake-contextMenu-create', 373 | params: parameters, 374 | }); 375 | this.tstCheck = setInterval(() => this.checkTST().catch(console.error), this.TST_TIMEOUT); 376 | } 377 | catch{ 378 | this.hasTST = false; 379 | } 380 | }, 381 | async checkTST() { 382 | try { 383 | await browser.runtime.sendMessage(this.TST_ID, { 384 | type: 'ping', 385 | }); 386 | } 387 | catch{ 388 | clearInterval(this.tstCheck); 389 | this.hasTST = false; 390 | } 391 | }, 392 | async updateItem(context, tab, type = this.TYPES.VANILLA) { 393 | const menuId = type === this.TYPES.VANILLA ? this.currentId : this.tstCurrentId; 394 | if(context.menuIds.includes(this.MENU_ITEM) || type === this.TYPES.TST) { 395 | const updatedSpec = { 396 | enabled: true, 397 | }, 398 | { allWebsites } = await browser.storage.local.get({ 399 | allWebsites: true, 400 | }); 401 | if(!this.isCurrentMenu(menuId, type)) { 402 | return; 403 | } 404 | const host = extractHost(tab.url); 405 | updatedSpec.title = browser.i18n.getMessage(allWebsites ? 'ignoreHost' : 'allowHost', host); 406 | const canPlaySound = await NotificationListener.websiteAllowed(host, false); 407 | if(!this.isCurrentMenu(menuId, type)) { 408 | return; 409 | } 410 | updatedSpec.checked = allWebsites ? !canPlaySound : canPlaySound; 411 | if(this.hasTST && type === this.TYPES.TST) { 412 | await browser.runtime.sendMessage(this.TST_ID, { 413 | type: 'fake-contextMenu-update', 414 | params: [ 415 | this.MENU_ITEM, 416 | updatedSpec, 417 | ], 418 | }); 419 | } 420 | else if(type === this.TYPES.VANILLA) { 421 | browser.menus.update(this.MENU_ITEM, updatedSpec); 422 | browser.menus.refresh(); 423 | } 424 | } 425 | }, 426 | closeMenu(type = this.TYPES.VANILLA) { 427 | if(type === this.TYPES.VANILLA) { 428 | ++this.currentId; 429 | } 430 | else if(type === this.TYPES.TST) { 431 | ++this.tstCurrentId; 432 | } 433 | }, 434 | isCurrentMenu(menuId, type = this.TYPES.VANILLA) { 435 | if(type === this.TYPES.VANILLA) { 436 | return menuId === this.currentId; 437 | } 438 | else if(type === this.TYPES.TST) { 439 | return menuId === this.tstCurrentId; 440 | } 441 | }, 442 | async handleClick(context, tab) { 443 | const host = extractHost(tab.url), 444 | { 445 | allWebsites, 446 | allowedWebsites, 447 | blockedWebsites, 448 | } = await browser.storage.local.get({ 449 | allWebsites: true, 450 | allowedWebsites: [], 451 | blockedWebsites: [], 452 | }), 453 | list = allWebsites ? blockedWebsites : allowedWebsites, 454 | updateProperty = allWebsites ? 'blockedWebsites' : 'allowedWebsites'; 455 | if(list.includes(host)) { 456 | list.splice(list.indexOf(host), this.ONE_ITEM); 457 | } 458 | else { 459 | list.push(host); 460 | } 461 | return browser.storage.local.set({ 462 | [updateProperty]: list, 463 | }); 464 | }, 465 | }, 466 | isWebsite = (sender) => sender.url.startsWith("http"); 467 | 468 | browser.runtime.onMessage.addListener((message, sender) => { 469 | if(message === NOTIFICATION_TOPIC && isWebsite(sender)) { 470 | NotificationListener.onNotification(SOURCES.WEBSITE, extractHost(sender.url), sender.tab.mutedInfo && sender.tab.mutedInfo.muted) 471 | .catch(console.error); 472 | } 473 | else if(typeof message === "object" && message.command === "play" && isWebsite(sender)) { 474 | NotificationListener.onPlay(SOURCES.WEBSITE, extractHost(sender.url), message.url, sender.tab.mutedInfo && sender.tab.mutedInfo.muted) 475 | .catch(console.error); 476 | } 477 | else if(typeof message === "object" && message.command === "preview-sound") { 478 | return NotificationListener.preview(message.pref); 479 | } 480 | else if(message === "recent-extensions") { 481 | return Promise.resolve(RecentExtensions.get()); 482 | } 483 | }); 484 | browser.runtime.onMessageExternal.addListener((message, sender) => { 485 | if(message === NOTIFICATION_TOPIC) { 486 | RecentExtensions.add(sender.id); 487 | return NotificationListener.onNotification(SOURCES.EXTENSION, sender.id); 488 | } 489 | }); 490 | browser.runtime.onInstalled.addListener((details) => { 491 | if(details.reason === "install" && !details.temporary) { 492 | browser.tabs.create({ 493 | url: browser.runtime.getURL("pages/firstrun.html"), 494 | }); 495 | } 496 | }); 497 | 498 | NotificationListener.init(); 499 | DownloadListener.init(); 500 | TabMenu.init(); 501 | --------------------------------------------------------------------------------