├── .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 |
--------------------------------------------------------------------------------