├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── esm-lint.yml ├── .gitignore ├── .parcelrc ├── demo ├── background.js ├── content.js ├── manifest.json ├── package.json └── readme.md ├── license ├── package-lock.json ├── package.json ├── readme.md ├── source ├── __snapshots__ │ └── inject.test.js.snap ├── inject.test.js ├── inject.ts ├── register.ts └── test-setup.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | Lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version-file: package.json 15 | - run: npm ci 16 | - run: npx xo 17 | 18 | Test: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version-file: package.json 25 | - run: npm ci 26 | - run: npm run test:vitest 27 | 28 | Build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version-file: package.json 35 | - run: npm ci 36 | - run: npm run prepack 37 | 38 | Demo: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v3 42 | - uses: actions/setup-node@v3 43 | with: 44 | node-version-file: package.json 45 | - run: npm ci 46 | - run: npm run demo:build 47 | -------------------------------------------------------------------------------- /.github/workflows/esm-lint.yml: -------------------------------------------------------------------------------- 1 | env: 2 | IMPORT_STATEMENT: import "webext-inject-on-install" 3 | 4 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint 5 | # SOURCE: https://github.com/fregante/ghatemplates 6 | # OPTIONS: {"exclude":["jobs.Parcel"]} 7 | 8 | name: ESM 9 | on: 10 | pull_request: 11 | branches: 12 | - '*' 13 | push: 14 | branches: 15 | - master 16 | - main 17 | jobs: 18 | Pack: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: npm install 23 | - run: npm run build --if-present 24 | - run: npm pack --dry-run 25 | - run: npm pack | tail -1 | xargs -n1 tar -xzf 26 | - uses: actions/upload-artifact@v4 27 | with: 28 | path: package 29 | Publint: 30 | runs-on: ubuntu-latest 31 | needs: Pack 32 | steps: 33 | - uses: actions/download-artifact@v4 34 | - run: npx publint ./artifact 35 | Webpack: 36 | runs-on: ubuntu-latest 37 | needs: Pack 38 | steps: 39 | - uses: actions/download-artifact@v4 40 | - run: npm install --omit=dev ./artifact 41 | - run: echo "$IMPORT_STATEMENT" > index.js 42 | - run: webpack --entry ./index.js 43 | - run: cat dist/main.js 44 | Rollup: 45 | runs-on: ubuntu-latest 46 | needs: Pack 47 | steps: 48 | - uses: actions/download-artifact@v4 49 | - run: npm install --omit=dev ./artifact rollup@4 @rollup/plugin-node-resolve 50 | - run: echo "$IMPORT_STATEMENT" > index.js 51 | - run: npx rollup -p node-resolve index.js 52 | Vite: 53 | runs-on: ubuntu-latest 54 | needs: Pack 55 | steps: 56 | - uses: actions/download-artifact@v4 57 | - run: npm install --omit=dev ./artifact 58 | - run: echo '' > index.html 59 | - run: npx vite build 60 | - run: cat dist/assets/* 61 | esbuild: 62 | runs-on: ubuntu-latest 63 | needs: Pack 64 | steps: 65 | - uses: actions/download-artifact@v4 66 | - run: echo '{}' > package.json 67 | - run: echo "$IMPORT_STATEMENT" > index.js 68 | - run: npm install --omit=dev ./artifact 69 | - run: npx esbuild --bundle index.js 70 | TypeScript: 71 | runs-on: ubuntu-latest 72 | needs: Pack 73 | steps: 74 | - uses: actions/download-artifact@v4 75 | - run: echo '{"type":"module"}' > package.json 76 | - run: npm install --omit=dev ./artifact @sindresorhus/tsconfig 77 | - run: echo "$IMPORT_STATEMENT" > index.ts 78 | - run: > 79 | echo '{"extends":"@sindresorhus/tsconfig","files":["index.ts"]}' > 80 | tsconfig.json 81 | - run: npx --package typescript -- tsc 82 | - run: cat distribution/index.js 83 | Node: 84 | runs-on: ubuntu-latest 85 | needs: Pack 86 | steps: 87 | - uses: actions/download-artifact@v4 88 | - uses: actions/setup-node@v4 89 | with: 90 | node-version-file: artifact/package.json 91 | - run: echo "$IMPORT_STATEMENT" > index.mjs 92 | - run: npm install --omit=dev ./artifact 93 | - run: node index.mjs 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | Desktop.ini 4 | ._* 5 | Thumbs.db 6 | *.tmp 7 | *.bak 8 | *.log 9 | logs 10 | *.map 11 | dist 12 | distribution 13 | .parcel-cache 14 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-webextension" 3 | } 4 | -------------------------------------------------------------------------------- /demo/background.js: -------------------------------------------------------------------------------- 1 | import 'webext-inject-on-install/register'; 2 | -------------------------------------------------------------------------------- /demo/content.js: -------------------------------------------------------------------------------- 1 | console.count('content.js injected'); 2 | -------------------------------------------------------------------------------- /demo/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/chrome-manifest", 3 | "name": "webext-inject-on-install", 4 | "manifest_version": 2, 5 | "version": "1.0.0", 6 | "description": "Injects a script into the page when the extension is installed.", 7 | "permissions": [ 8 | "tabs", 9 | "https://ephiframe.vercel.app/*" 10 | ], 11 | "content_scripts": [ 12 | { 13 | "matches": ["https://ephiframe.vercel.app/*"], 14 | "js": ["content.js"] 15 | } 16 | ], 17 | "background": { 18 | "scripts": ["background.js"] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "alias": { 4 | "webext-inject-on-install": "../source/" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /demo/readme.md: -------------------------------------------------------------------------------- 1 | # Test extension 2 | 3 | Run these 2 commands in parallel to open the extension: 4 | 5 | ```sh 6 | npm run demo:watch 7 | npx web-ext run --target=chromium 8 | ``` 9 | 10 | Also open the background script console to see any errors. 11 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Federico Brigante (https://fregante.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-inject-on-install", 3 | "version": "2.3.0", 4 | "description": "Automatically add content scripts to existing tabs when your extension is installed", 5 | "keywords": [ 6 | "browser", 7 | "content script", 8 | "existing tabs", 9 | "extension", 10 | "chrome", 11 | "firefox", 12 | "safari", 13 | "inject" 14 | ], 15 | "repository": "fregante/webext-inject-on-install", 16 | "funding": "https://github.com/sponsors/fregante", 17 | "license": "MIT", 18 | "author": "Federico Brigante (https://fregante.com)", 19 | "type": "module", 20 | "exports": { 21 | ".": "./distribution/register.js", 22 | "./register": "./distribution/register.js", 23 | "./inject.js": "./distribution/inject.js" 24 | }, 25 | "files": [ 26 | "distribution" 27 | ], 28 | "scripts": { 29 | "build": "tsc", 30 | "demo:build": "parcel build --no-cache", 31 | "demo:watch": "parcel watch --no-cache --no-hmr", 32 | "prepack": "tsc --sourceMap false", 33 | "test": "tsc --noEmit && xo && npm run test:vitest", 34 | "test:vitest": "vitest run", 35 | "watch": "tsc --watch" 36 | }, 37 | "xo": { 38 | "rules": { 39 | "unicorn/prefer-top-level-await": "off", 40 | "@typescript-eslint/no-unsafe-assignment": "off", 41 | "@typescript-eslint/no-unsafe-call": "off", 42 | "@typescript-eslint/no-unsafe-argument": "off", 43 | "type rules disabled because xo is unable to pick up the types": "off" 44 | } 45 | }, 46 | "dependencies": { 47 | "webext-content-scripts": "^2.7.2", 48 | "webext-detect": "^5.3.2", 49 | "webext-events": "^3.1.1", 50 | "webext-polyfill-kinda": "^1.0.2" 51 | }, 52 | "devDependencies": { 53 | "@parcel/config-webextension": "^2.14.4", 54 | "@sindresorhus/tsconfig": "^7.0.0", 55 | "@types/chrome": "^0.0.318", 56 | "@types/sinon-chrome": "^2.2.15", 57 | "parcel": "^2.14.4", 58 | "sinon-chrome": "^3.0.1", 59 | "typescript": "^5.8.3", 60 | "vitest": "^3.1.2", 61 | "xo": "^0.60.0" 62 | }, 63 | "engines": { 64 | "node": ">=18" 65 | }, 66 | "targets": { 67 | "main": false, 68 | "default": { 69 | "engines": { 70 | "browsers": "" 71 | }, 72 | "source": "./demo/manifest.json", 73 | "distDir": "./node_modules/.built/demo", 74 | "sourceMap": { 75 | "inline": true 76 | }, 77 | "optimize": false 78 | } 79 | }, 80 | "webExt": { 81 | "sourceDir": "node_modules/.built/demo", 82 | "run": { 83 | "startUrl": [ 84 | "https://ephiframe.vercel.app/tab-1", 85 | "https://ephiframe.vercel.app/tab-2", 86 | "https://ephiframe.vercel.app/tab-3", 87 | "https://ephiframe.vercel.app/tab-4", 88 | "https://ephiframe.vercel.app/tab-5", 89 | "https://ephiframe.vercel.app/tab-6", 90 | "https://ephiframe.vercel.app/tab-7", 91 | "https://ephiframe.vercel.app/tab-8", 92 | "https://ephiframe.vercel.app/tab-9", 93 | "https://ephiframe.vercel.app/tab-10", 94 | "https://ephiframe.vercel.app/tab-11", 95 | "https://alt-ephiframe.vercel.app" 96 | ] 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # webext-inject-on-install 2 | 3 | 4 | 5 | [badge-gzip]: https://img.shields.io/bundlephobia/minzip/webext-inject-on-install.svg?label=gzipped 6 | [link-bundlephobia]: https://bundlephobia.com/result?p=webext-inject-on-install 7 | 8 | > Automatically add content scripts to existing tabs when your extension is installed. 9 | 10 | Firefox actually already does this natively, so this module is automatically disabled there. 11 | 12 | - Browsers: Chrome, Firefox, and Safari 13 | - Manifest: v2 and v3 14 | - Permissions: `tabs` + explicit host permissions in `permissions`; in Manifest v3 you'll also need `scripting` 15 | - Context: `background` 16 | 17 | **Sponsored by [PixieBrix](https://www.pixiebrix.com)** :tada: 18 | 19 | ## Install 20 | 21 | ```sh 22 | npm install webext-inject-on-install 23 | ``` 24 | 25 | Or download the [standalone bundle](https://bundle.fregante.com/?pkg=webext-inject-on-install) to include in your `manifest.json`. 26 | 27 | ## Usage 28 | 29 | It registers automatically: 30 | 31 | ```js 32 | import "webext-inject-on-install"; 33 | ``` 34 | 35 | ## How it works 36 | 37 | 1. It gets the list of content scripts from the manifest 38 | 2. For each content script group, it looks for open tabs that are not discarded (discarded tabs are already handled by the browser) 39 | 3. It injects the script into the tabs matching the `matches` patterns (`exclude_matches` is not supported https://github.com/fregante/webext-inject-on-install/issues/5) 40 | 4. If the tab count exceeds 10 (each), it injects into the tabs only when they become active. (persistent background pages only https://github.com/fregante/webext-inject-on-install/issues/4) 41 | 42 | ## Related 43 | 44 | - [webext-dynamic-content-scripts](https://github.com/fregante/webext-dynamic-content-scripts) - Automatically registers your `content_scripts` on domains added via `permission.request` 45 | - [webext-content-scripts](https://github.com/fregante/webext-content-scripts) - Utility functions to inject content scripts in WebExtensions. 46 | - [webext-options-sync](https://github.com/fregante/webext-options-sync) - Helps you manage and autosave your extension's options. 47 | - [More…](https://github.com/fregante/webext-fun) 48 | 49 | ## License 50 | 51 | MIT © [Federico Brigante](https://fregante.com) 52 | -------------------------------------------------------------------------------- /source/__snapshots__/inject.test.js.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`base usage 1`] = ` 4 | [ 5 | [ 6 | 1, 7 | { 8 | "allFrames": false, 9 | "file": "foo.js", 10 | "frameId": 0, 11 | "matchAboutBlank": undefined, 12 | "runAt": undefined, 13 | }, 14 | [Function], 15 | ], 16 | [ 17 | 1, 18 | { 19 | "allFrames": false, 20 | "file": "bar.js", 21 | "frameId": 0, 22 | "matchAboutBlank": undefined, 23 | "runAt": undefined, 24 | }, 25 | [Function], 26 | ], 27 | ] 28 | `; 29 | 30 | exports[`base usage 2`] = ` 31 | [ 32 | [ 33 | 1, 34 | { 35 | "allFrames": false, 36 | "file": "foo.css", 37 | "frameId": 0, 38 | "matchAboutBlank": undefined, 39 | "runAt": "document_start", 40 | }, 41 | [Function], 42 | ], 43 | ] 44 | `; 45 | 46 | exports[`deferred usage 1`] = ` 47 | Map { 48 | 0 => [ 49 | { 50 | "css": [ 51 | "foo.css", 52 | ], 53 | "js": [ 54 | "foo.js", 55 | "bar.js", 56 | ], 57 | "matches": [ 58 | "https://example.com/*", 59 | ], 60 | }, 61 | ], 62 | 1 => [ 63 | { 64 | "css": [ 65 | "foo.css", 66 | ], 67 | "js": [ 68 | "foo.js", 69 | "bar.js", 70 | ], 71 | "matches": [ 72 | "https://example.com/*", 73 | ], 74 | }, 75 | ], 76 | 2 => [ 77 | { 78 | "css": [ 79 | "foo.css", 80 | ], 81 | "js": [ 82 | "foo.js", 83 | "bar.js", 84 | ], 85 | "matches": [ 86 | "https://example.com/*", 87 | ], 88 | }, 89 | ], 90 | 3 => [ 91 | { 92 | "css": [ 93 | "foo.css", 94 | ], 95 | "js": [ 96 | "foo.js", 97 | "bar.js", 98 | ], 99 | "matches": [ 100 | "https://example.com/*", 101 | ], 102 | }, 103 | ], 104 | 4 => [ 105 | { 106 | "css": [ 107 | "foo.css", 108 | ], 109 | "js": [ 110 | "foo.js", 111 | "bar.js", 112 | ], 113 | "matches": [ 114 | "https://example.com/*", 115 | ], 116 | }, 117 | ], 118 | 5 => [ 119 | { 120 | "css": [ 121 | "foo.css", 122 | ], 123 | "js": [ 124 | "foo.js", 125 | "bar.js", 126 | ], 127 | "matches": [ 128 | "https://example.com/*", 129 | ], 130 | }, 131 | ], 132 | 6 => [ 133 | { 134 | "css": [ 135 | "foo.css", 136 | ], 137 | "js": [ 138 | "foo.js", 139 | "bar.js", 140 | ], 141 | "matches": [ 142 | "https://example.com/*", 143 | ], 144 | }, 145 | ], 146 | 7 => [ 147 | { 148 | "css": [ 149 | "foo.css", 150 | ], 151 | "js": [ 152 | "foo.js", 153 | "bar.js", 154 | ], 155 | "matches": [ 156 | "https://example.com/*", 157 | ], 158 | }, 159 | ], 160 | 8 => [ 161 | { 162 | "css": [ 163 | "foo.css", 164 | ], 165 | "js": [ 166 | "foo.js", 167 | "bar.js", 168 | ], 169 | "matches": [ 170 | "https://example.com/*", 171 | ], 172 | }, 173 | ], 174 | 9 => [ 175 | { 176 | "css": [ 177 | "foo.css", 178 | ], 179 | "js": [ 180 | "foo.js", 181 | "bar.js", 182 | ], 183 | "matches": [ 184 | "https://example.com/*", 185 | ], 186 | }, 187 | ], 188 | 10 => [ 189 | { 190 | "css": [ 191 | "foo.css", 192 | ], 193 | "js": [ 194 | "foo.js", 195 | "bar.js", 196 | ], 197 | "matches": [ 198 | "https://example.com/*", 199 | ], 200 | }, 201 | ], 202 | } 203 | `; 204 | 205 | exports[`deferred usage 2`] = ` 206 | [ 207 | [ 208 | 5, 209 | { 210 | "allFrames": true, 211 | "file": "foo.js", 212 | "frameId": undefined, 213 | "matchAboutBlank": undefined, 214 | "runAt": undefined, 215 | }, 216 | [Function], 217 | ], 218 | [ 219 | 5, 220 | { 221 | "allFrames": true, 222 | "file": "bar.js", 223 | "frameId": undefined, 224 | "matchAboutBlank": undefined, 225 | "runAt": undefined, 226 | }, 227 | [Function], 228 | ], 229 | ] 230 | `; 231 | 232 | exports[`deferred usage 3`] = ` 233 | [ 234 | [ 235 | 5, 236 | { 237 | "allFrames": true, 238 | "file": "foo.css", 239 | "frameId": undefined, 240 | "matchAboutBlank": undefined, 241 | "runAt": "document_start", 242 | }, 243 | [Function], 244 | ], 245 | ] 246 | `; 247 | -------------------------------------------------------------------------------- /source/inject.test.js: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | expect, test, beforeEach, vi, 4 | } from 'vitest'; 5 | import chrome from 'sinon-chrome'; 6 | // eslint-disable-next-line import/no-unassigned-import 7 | import './test-setup.js'; 8 | import progressivelyInjectScript, {tracked} from './inject.js'; 9 | 10 | vi.mock('webext-detect', () => ({isPersistentBackgroundPage: () => true})); 11 | 12 | beforeEach(() => { 13 | chrome.flush(); 14 | chrome.runtime.getManifest.returns({permissions: ['tabs']}); 15 | tracked.clear(); 16 | }); 17 | 18 | test('base usage', async () => { 19 | const contentScript = { 20 | matches: ['https://example.com/*'], 21 | js: ['foo.js', 'bar.js'], 22 | css: ['foo.css'], 23 | }; 24 | const scriptableTabs = [ 25 | {url: 'https://example.com/', discarded: false, id: 1}, 26 | ]; 27 | 28 | chrome.tabs.query.withArgs({ 29 | url: contentScript.matches, 30 | discarded: false, 31 | status: 'complete', 32 | }).yields(scriptableTabs); 33 | 34 | await progressivelyInjectScript(contentScript); 35 | expect(chrome.tabs.executeScript.getCalls().map(x => x.args)).toMatchSnapshot(); 36 | expect(chrome.tabs.insertCSS.getCalls().map(x => x.args)).toMatchSnapshot(); 37 | expect(chrome.tabs.onUpdated.addListener.callCount).toBe(0); 38 | expect(chrome.tabs.onRemoved.addListener.callCount).toBe(0); 39 | expect(chrome.tabs.onActivated.addListener.callCount).toBe(0); 40 | }); 41 | 42 | test('deferred usage', async () => { 43 | const contentScript = { 44 | matches: ['https://example.com/*'], 45 | js: ['foo.js', 'bar.js'], 46 | css: ['foo.css'], 47 | }; 48 | const scriptableTabs = Array.from({length: 11}).fill(0).map((_, id) => ({url: `https://example.com/${id}`, discarded: false, id})); 49 | 50 | chrome.tabs.query.withArgs({ 51 | url: contentScript.matches, 52 | discarded: false, 53 | status: 'complete', 54 | }).yields(scriptableTabs); 55 | 56 | await progressivelyInjectScript(contentScript); 57 | 58 | // Ensure no injections were made because of the large nunmber of open tabs 59 | expect(chrome.tabs.executeScript.callCount).toBe(0); 60 | expect(chrome.tabs.insertCSS.callCount).toBe(0); 61 | expect(chrome.tabs.onUpdated.addListener.callCount).toBe(11); 62 | expect(chrome.tabs.onRemoved.addListener.callCount).toBe(11); 63 | expect(chrome.tabs.onActivated.addListener.callCount).toBe(11); 64 | expect(tracked).toMatchSnapshot(); 65 | expect(tracked).toHaveLength(11); 66 | 67 | // Ensure that the tracked list of tabs is updated when they're closed 68 | chrome.tabs.onRemoved.trigger(1); 69 | chrome.tabs.onRemoved.trigger(2); 70 | expect(tracked).toHaveLength(9); 71 | 72 | // Ensure that the tracked list of tabs is updated when they're discarded 73 | chrome.tabs.onUpdated.trigger(3, {discarded: true}); 74 | chrome.tabs.onUpdated.trigger(4, {discarded: true}); 75 | expect(tracked).toHaveLength(7); 76 | 77 | // Ensure that the tracked list of tabs is updated when they're activated and that they're injected 78 | chrome.tabs.onActivated.trigger({tabId: 5}); 79 | expect(tracked).toHaveLength(6); 80 | expect(chrome.tabs.executeScript.callCount).toBe(2); 81 | expect(chrome.tabs.insertCSS.callCount).toBe(1); 82 | expect(chrome.tabs.executeScript.getCalls().map(x => x.args)).toMatchSnapshot(); 83 | expect(chrome.tabs.insertCSS.getCalls().map(x => x.args)).toMatchSnapshot(); 84 | 85 | // Ensure that navigation away removes the tab without injecting the script 86 | chrome.webNavigation.onCommitted.trigger({tabId: 6, frameId: 2}); 87 | expect(tracked).toHaveLength(6); 88 | chrome.webNavigation.onCommitted.trigger({tabId: 6, frameId: 0}); 89 | expect(tracked).toHaveLength(5); 90 | expect(chrome.tabs.executeScript.callCount).toBe(2); 91 | 92 | // Ensure that the listeners are removed once the list is empty 93 | expect(chrome.tabs.onUpdated.removeListener.callCount).toBe(0); 94 | chrome.tabs.onRemoved.trigger(7); 95 | chrome.tabs.onRemoved.trigger(8); 96 | chrome.tabs.onRemoved.trigger(9); 97 | chrome.tabs.onRemoved.trigger(10); 98 | chrome.tabs.onRemoved.trigger(11); 99 | expect(chrome.tabs.onUpdated.removeListener.callCount).toBe(0); 100 | chrome.tabs.onRemoved.trigger(0); 101 | expect(tracked).toHaveLength(0); 102 | 103 | // TODO: This should be 1, I'm not sure why `forgetTab` is being called 6 times at once. I assume it's a sinon-chrome bug 104 | expect(chrome.tabs.onUpdated.removeListener.callCount).toBe(6); 105 | }); 106 | -------------------------------------------------------------------------------- /source/inject.ts: -------------------------------------------------------------------------------- 1 | import {injectContentScript, isScriptableUrl} from 'webext-content-scripts'; 2 | import {isPersistentBackgroundPage} from 'webext-detect'; 3 | import chromeP from 'webext-polyfill-kinda'; 4 | 5 | const acceptableInjectionsCount = 10; 6 | 7 | const errorEnterprisePolicy = 'This page cannot be scripted due to an ExtensionsSettings policy.'; 8 | 9 | type ContentScript = NonNullable[number]; 10 | 11 | export const tracked = new Map(); 12 | 13 | const injectAndDiscardCertainErrors: typeof injectContentScript = async (tabId, contentScript) => { 14 | try { 15 | await injectContentScript(tabId, contentScript); 16 | } catch (error) { 17 | if (error instanceof Error && error.message === errorEnterprisePolicy) { 18 | console.debug('webext-inject-on-install: Enteprise policy blocked access to tab', tabId, error); 19 | } else { 20 | throw error; 21 | } 22 | } 23 | }; 24 | 25 | function forgetTab(tabId: number) { 26 | tracked.delete(tabId); 27 | if (tracked.size === 0) { 28 | chrome.tabs.onUpdated.removeListener(onDiscarded); 29 | chrome.tabs.onRemoved.removeListener(forgetTab); 30 | chrome.tabs.onActivated.removeListener(onActivated); 31 | chrome.webNavigation?.onCommitted.removeListener(onCommitted); 32 | } 33 | } 34 | 35 | function onDiscarded(tabId: number, changeInfo: {discarded?: boolean}) { 36 | if (changeInfo.discarded) { 37 | forgetTab(tabId); 38 | } 39 | } 40 | 41 | function onCommitted({tabId, frameId}: {tabId: number; frameId: number}) { 42 | if (frameId === 0) { 43 | forgetTab(tabId); 44 | } 45 | } 46 | 47 | function onActivated({tabId}: {tabId: number}) { 48 | const scripts = tracked.get(tabId); 49 | if (!scripts) { 50 | return; 51 | } 52 | 53 | forgetTab(tabId); 54 | console.debug('webext-inject-on-install: Deferred injection', scripts, 'into tab', tabId); 55 | for (const script of scripts) { 56 | void injectAndDiscardCertainErrors(tabId, script); 57 | } 58 | } 59 | 60 | export default async function progressivelyInjectScript(contentScript: ContentScript) { 61 | const permissions = globalThis.chrome?.runtime.getManifest().permissions; 62 | if (!permissions?.includes('tabs')) { 63 | throw new Error('webext-inject-on-install: The "tabs" permission is required'); 64 | } 65 | 66 | const liveTabs = await chromeP.tabs.query({ 67 | url: contentScript.matches, 68 | discarded: false, 69 | 70 | // Excludes unloaded tabs https://github.com/fregante/webext-inject-on-install/issues/11 71 | status: 'complete', 72 | }); 73 | 74 | // `tab.url` is empty when the browser is starting, which is convenient because we don't need to inject anything. 75 | const scriptableTabs = liveTabs.filter(tab => isScriptableUrl(tab.url)); 76 | console.debug('webext-inject-on-install: Found', scriptableTabs.length, 'tabs matching', contentScript); 77 | 78 | if (scriptableTabs.length === 0) { 79 | return; 80 | } 81 | 82 | // TODO: Non-persistent pages support via chrome.storage.session 83 | // https://github.com/fregante/webext-inject-on-install/issues/4 84 | const singleInjection = !isPersistentBackgroundPage() || scriptableTabs.length <= acceptableInjectionsCount; 85 | console.debug('webext-inject-on-install: Single injection?', singleInjection); 86 | 87 | for (const tab of scriptableTabs) { 88 | if (singleInjection || tab.active) { 89 | console.debug('webext-inject-on-install: Injecting', contentScript, 'into tab', tab.id); 90 | void injectAndDiscardCertainErrors( 91 | // Unless https://github.com/fregante/webext-content-scripts/issues/30 is changed 92 | contentScript.all_frames ? tab.id! : {tabId: tab.id!, frameId: 0}, 93 | contentScript, 94 | ); 95 | } else { 96 | chrome.tabs.onUpdated.addListener(onDiscarded); 97 | chrome.tabs.onRemoved.addListener(forgetTab); 98 | chrome.tabs.onActivated.addListener(onActivated); 99 | 100 | // Catch tab navigations that happen while the tab is not active 101 | chrome.webNavigation?.onCommitted.addListener(onCommitted); 102 | 103 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Uh, wrong? 104 | const scripts = tracked.get(tab.id!) ?? []; 105 | scripts.push(contentScript); 106 | 107 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Uh, wrong? 108 | tracked.set(tab.id!, scripts); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /source/register.ts: -------------------------------------------------------------------------------- 1 | import {onExtensionStart} from 'webext-events'; 2 | import progressivelyInjectScript from './inject.js'; 3 | 4 | function register() { 5 | const {content_scripts: scripts} = chrome.runtime.getManifest(); 6 | 7 | if (!scripts?.length) { 8 | throw new Error('webext-inject-on-install tried to inject content scripts, but no content scripts were found in the manifest.'); 9 | } 10 | 11 | console.debug('webext-inject-on-install: Found', scripts.length, 'content script(s) in the manifest.'); 12 | 13 | for (const contentScript of scripts) { 14 | void progressivelyInjectScript(contentScript); 15 | } 16 | } 17 | 18 | if (globalThis.chrome && !navigator.userAgent.includes('Firefox')) { 19 | onExtensionStart.addListener(register); 20 | chrome.runtime.onStartup.addListener(() => { 21 | onExtensionStart.removeListener(register); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /source/test-setup.js: -------------------------------------------------------------------------------- 1 | import chrome from 'sinon-chrome'; 2 | 3 | globalThis.chrome = chrome; 4 | 5 | chrome.runtime.getManifest.returns({background: {persistent: true}, permissions: ['tabs']}); 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "distribution" 5 | }, 6 | "include": [ 7 | "source" 8 | ] 9 | } 10 | --------------------------------------------------------------------------------