webext-active-tab 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ type TabId = number;
2
+ type Origin = string;
3
+ export type ActiveTab = {
4
+ id: TabId;
5
+ origin: Origin;
6
+ };
7
+ /**
8
+ * The list is not guaranteed to be up to date. The activeTab permission might be lost before it's detected.
9
+ */
10
+ export declare const possiblyActiveTabs: Map<number, string>;
11
+ declare function startActiveTabTracking(): void;
12
+ declare function stopActiveTabTracking(): void;
13
+ export declare const PRIVATE: {
14
+ startActiveTabTracking: typeof startActiveTabTracking;
15
+ stopActiveTabTracking: typeof stopActiveTabTracking;
16
+ };
17
+ export declare const onActiveTab: {
18
+ addListener(callback: (tab: ActiveTab) => void): void;
19
+ removeListener: (callback: import("simple-event-target").SimpleEventListener<ActiveTab>) => void;
20
+ };
21
+ export declare const onActiveTabLost: {
22
+ addListener(callback: (tabId: TabId) => void): void;
23
+ removeListener: (callback: import("simple-event-target").SimpleEventListener<number>) => void;
24
+ };
25
+ export {};
@@ -0,0 +1,87 @@
1
+ import { isScriptableUrl } from 'webext-content-scripts';
2
+ import { isBackground } from 'webext-detect';
3
+ import SimpleEventTarget from 'simple-event-target';
4
+ if (!isBackground()) {
5
+ throw new Error('This module is only allowed in a background script');
6
+ }
7
+ if (!chrome.runtime.getManifest().permissions?.includes('activeTab')) {
8
+ throw new Error('The manifest should include the `activeTab` permission for `webext-active-tab` to work');
9
+ }
10
+ const activation = new SimpleEventTarget();
11
+ const deactivation = new SimpleEventTarget();
12
+ const browserAction = chrome.action ?? chrome.browserAction;
13
+ // TODO: use https://github.com/fregante/webext-storage/issues/5
14
+ /**
15
+ * The list is not guaranteed to be up to date. The activeTab permission might be lost before it's detected.
16
+ */
17
+ export const possiblyActiveTabs = new Map();
18
+ async function addIfScriptable({ url, id }) {
19
+ if (id && url
20
+ // Skip if it already exists. A previous change of origin already cleared this
21
+ && !possiblyActiveTabs.has(id)
22
+ // ActiveTab doesn't make sense on non-scriptable URLs as they generally don't have scriptable frames
23
+ && isScriptableUrl(url)
24
+ // Note: Do not filter by `isContentScriptRegistered`; `active-tab` also applies to random `executeScript` calls
25
+ ) {
26
+ const { origin } = new URL(url);
27
+ console.debug('activeTab:', id, 'added', { origin });
28
+ possiblyActiveTabs.set(id, origin);
29
+ activation.emit({ id, origin });
30
+ }
31
+ else {
32
+ console.debug('activeTab:', id, 'not added', { origin });
33
+ }
34
+ }
35
+ function dropIfOriginChanged(tabId, { url }) {
36
+ if (url && possiblyActiveTabs.has(tabId)) {
37
+ const { origin } = new URL(url);
38
+ if (possiblyActiveTabs.get(tabId) !== origin) {
39
+ console.debug('activeTab:', tabId, 'removed because origin changed from', possiblyActiveTabs.get(tabId), 'to', origin);
40
+ possiblyActiveTabs.delete(tabId);
41
+ }
42
+ }
43
+ }
44
+ function altListener(_, tab) {
45
+ if (tab) {
46
+ void addIfScriptable(tab);
47
+ }
48
+ }
49
+ function drop(tabId) {
50
+ console.debug('activeTab:', tabId, 'removed');
51
+ possiblyActiveTabs.delete(tabId);
52
+ deactivation.emit(tabId);
53
+ }
54
+ // https://developer.chrome.com/docs/extensions/mv3/manifest/activeTab/#invoking-activeTab
55
+ function startActiveTabTracking() {
56
+ browserAction?.onClicked.addListener(addIfScriptable);
57
+ chrome.contextMenus?.onClicked.addListener(altListener);
58
+ chrome.commands?.onCommand.addListener(altListener);
59
+ chrome.tabs.onUpdated.addListener(dropIfOriginChanged);
60
+ chrome.tabs.onRemoved.addListener(drop);
61
+ }
62
+ function stopActiveTabTracking() {
63
+ browserAction?.onClicked.removeListener(addIfScriptable);
64
+ chrome.contextMenus?.onClicked.removeListener(altListener);
65
+ chrome.commands?.onCommand.removeListener(altListener);
66
+ chrome.tabs.onUpdated.removeListener(dropIfOriginChanged);
67
+ chrome.tabs.onRemoved.removeListener(drop);
68
+ possiblyActiveTabs.clear();
69
+ }
70
+ export const PRIVATE = {
71
+ startActiveTabTracking,
72
+ stopActiveTabTracking,
73
+ };
74
+ export const onActiveTab = {
75
+ addListener(callback) {
76
+ startActiveTabTracking();
77
+ activation.subscribe(callback);
78
+ },
79
+ removeListener: activation.unsubscribe.bind(activation),
80
+ };
81
+ export const onActiveTabLost = {
82
+ addListener(callback) {
83
+ startActiveTabTracking();
84
+ deactivation.subscribe(callback);
85
+ },
86
+ removeListener: deactivation.unsubscribe.bind(deactivation),
87
+ };
@@ -0,0 +1,8 @@
1
+ import { type RunAt } from 'webext-content-scripts';
2
+ export type ActiveTabContentScript = {
3
+ css?: string[];
4
+ js?: string[];
5
+ matchAboutBlank?: boolean;
6
+ runAt?: RunAt;
7
+ };
8
+ export default function registerActiveTabScripts(...newScripts: ActiveTabContentScript[]): void;
@@ -0,0 +1,44 @@
1
+ import { injectContentScript } from 'webext-content-scripts';
2
+ import { onActiveTab, possiblyActiveTabs } from './on-active-tab.js';
3
+ const gotNavigation = typeof chrome === 'object' && 'webNavigation' in chrome;
4
+ const scripts = [];
5
+ async function injectAllFrames({ id: tabId, origin: url }) {
6
+ if (tabId === undefined) {
7
+ return;
8
+ }
9
+ if (!gotNavigation) {
10
+ // Without it, we only inject it into the top frame
11
+ return injectOneFrame({ frameId: 0, url, tabId });
12
+ }
13
+ const frames = await chrome.webNavigation.getAllFrames({ tabId });
14
+ // .map() needed for async loop
15
+ void frames?.map(async ({ frameId, url }) => injectOneFrame({ frameId, url, tabId }));
16
+ }
17
+ async function injectOneFrame({ tabId, frameId, url }) {
18
+ const { origin } = new URL(url);
19
+ // Check origin because the request might be for a frame; cross-origin frames do not receive activeTab
20
+ if (possiblyActiveTabs.get(tabId) === origin) {
21
+ console.debug('activeTab: will inject', { tabId, frameId, url });
22
+ await injectContentScript({ tabId, frameId }, scripts);
23
+ }
24
+ else {
25
+ console.debug('activeTab: won’t inject', { tabId, frameId, url }, { activeTab: possiblyActiveTabs.get(tabId) ?? 'no' });
26
+ }
27
+ }
28
+ async function tabListener(tabId, { status }, { url }) {
29
+ // Only status updates are relevant
30
+ // No URL = no permission
31
+ if (status === 'loading' && url) {
32
+ await injectOneFrame({ tabId, url, frameId: 0 });
33
+ }
34
+ }
35
+ export default function registerActiveTabScripts(...newScripts) {
36
+ scripts.push(...newScripts);
37
+ onActiveTab.addListener(injectAllFrames);
38
+ if (gotNavigation) {
39
+ chrome.webNavigation.onCommitted.addListener(injectOneFrame);
40
+ }
41
+ else {
42
+ chrome.tabs.onUpdated.addListener(tabListener);
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import register from './scripts.js';
2
+ register();
@@ -0,0 +1 @@
1
+ export default function register(): void;
@@ -0,0 +1,47 @@
1
+ import { injectContentScript } from 'webext-content-scripts';
2
+ import { addActiveTabListener, possiblyActiveTabs } from './tracker.js';
3
+ import { isContentScriptRegistered } from './utils.js';
4
+ const gotNavigation = typeof chrome === 'object' && 'webNavigation' in chrome;
5
+ const scripts = chrome.runtime.getManifest().content_scripts;
6
+ async function injectToTabUnlessRegistered({ id: tabId, origin }) {
7
+ if (tabId === undefined) {
8
+ return;
9
+ }
10
+ const frames = gotNavigation
11
+ // Only with `webNavigation` we can inject into the frames
12
+ ? await chrome.webNavigation.getAllFrames({ tabId })
13
+ // Without it, we only inject it into the top frame
14
+ : [{ frameId: 0, url: origin }];
15
+ // .map() needed for async loop
16
+ void frames?.map(async ({ frameId, url }) => injectIfActive({ frameId, url, tabId }));
17
+ }
18
+ async function injectIfActive({ tabId, frameId, url }) {
19
+ const { origin } = new URL(url);
20
+ if (
21
+ // Check origin because the request might be for a frame; cross-origin frames do not receive activeTab
22
+ possiblyActiveTabs.get(tabId) === origin
23
+ // Don't inject if already registered
24
+ && !(await isContentScriptRegistered(url))) {
25
+ console.debug('activeTab: will inject', { tabId, frameId, url });
26
+ await injectContentScript({ tabId, frameId }, scripts);
27
+ }
28
+ else {
29
+ console.debug('activeTab: won’t inject', { tabId, frameId, url }, { activeTab: possiblyActiveTabs.get(tabId) ?? 'no' });
30
+ }
31
+ }
32
+ async function tabListener(tabId, { status }, { url }) {
33
+ // Only status updates are relevant
34
+ // No URL = no permission
35
+ if (status === 'loading' && url) {
36
+ await injectIfActive({ tabId, url, frameId: 0 });
37
+ }
38
+ }
39
+ export default function register() {
40
+ addActiveTabListener(injectToTabUnlessRegistered);
41
+ if (gotNavigation) {
42
+ chrome.webNavigation.onCommitted.addListener(injectIfActive);
43
+ }
44
+ else {
45
+ chrome.tabs.onUpdated.addListener(tabListener);
46
+ }
47
+ }
@@ -0,0 +1,11 @@
1
+ type TabId = number;
2
+ type Origin = string;
3
+ export type ActiveTab = {
4
+ id: TabId;
5
+ origin: Origin;
6
+ };
7
+ export declare const possiblyActiveTabs: Map<number, string>;
8
+ export declare function startActiveTabTracking(): void;
9
+ export declare function stopActiveTabTracking(): void;
10
+ export declare function addActiveTabListener(callback: (tab: ActiveTab) => void): void;
11
+ export {};
@@ -0,0 +1,65 @@
1
+ import { isScriptableUrl } from 'webext-content-scripts';
2
+ import { isBackground } from 'webext-detect';
3
+ import SimpleEventTarget from 'simple-event-target';
4
+ if (!isBackground()) {
5
+ throw new Error('This module is only allowed in a background script');
6
+ }
7
+ const newActiveTabs = new SimpleEventTarget();
8
+ const browserAction = chrome.action ?? chrome.browserAction;
9
+ // TODO: use webext-storage
10
+ export const possiblyActiveTabs = new Map();
11
+ async function addIfScriptable({ url, id }) {
12
+ if (id && url
13
+ // Skip if it already exists. A previous change of origin already cleared this
14
+ && !possiblyActiveTabs.has(id)
15
+ // ActiveTab doesn't make sense on non-scriptable URLs as they generally don't have scriptable frames
16
+ && isScriptableUrl(url)
17
+ // Note: Do not filter by `isContentScriptRegistered`; `active-tab` also applies to random `executeScript` calls
18
+ ) {
19
+ const { origin } = new URL(url);
20
+ console.debug('activeTab:', id, 'added', { origin });
21
+ possiblyActiveTabs.set(id, origin);
22
+ newActiveTabs.emit({ id, origin });
23
+ }
24
+ else {
25
+ console.debug('activeTab:', id, 'not added', { origin });
26
+ }
27
+ }
28
+ function dropIfOriginChanged(tabId, { url }) {
29
+ if (url && possiblyActiveTabs.has(tabId)) {
30
+ const { origin } = new URL(url);
31
+ if (possiblyActiveTabs.get(tabId) !== origin) {
32
+ console.debug('activeTab:', tabId, 'removed because origin changed from', possiblyActiveTabs.get(tabId), 'to', origin);
33
+ possiblyActiveTabs.delete(tabId);
34
+ }
35
+ }
36
+ }
37
+ function altListener(_, tab) {
38
+ if (tab) {
39
+ void addIfScriptable(tab);
40
+ }
41
+ }
42
+ function drop(tabId) {
43
+ console.debug('activeTab:', tabId, 'removed');
44
+ possiblyActiveTabs.delete(tabId);
45
+ }
46
+ // https://developer.chrome.com/docs/extensions/mv3/manifest/activeTab/#invoking-activeTab
47
+ export function startActiveTabTracking() {
48
+ browserAction?.onClicked.addListener(addIfScriptable);
49
+ chrome.contextMenus?.onClicked.addListener(altListener);
50
+ chrome.commands?.onCommand.addListener(altListener);
51
+ chrome.tabs.onUpdated.addListener(dropIfOriginChanged);
52
+ chrome.tabs.onRemoved.addListener(drop);
53
+ }
54
+ export function stopActiveTabTracking() {
55
+ browserAction?.onClicked.removeListener(addIfScriptable);
56
+ chrome.contextMenus?.onClicked.removeListener(altListener);
57
+ chrome.commands?.onCommand.removeListener(altListener);
58
+ chrome.tabs.onUpdated.removeListener(dropIfOriginChanged);
59
+ chrome.tabs.onRemoved.removeListener(drop);
60
+ possiblyActiveTabs.clear();
61
+ }
62
+ export function addActiveTabListener(callback) {
63
+ startActiveTabTracking();
64
+ newActiveTabs.listen(callback);
65
+ }
@@ -0,0 +1,7 @@
1
+ export declare function isContentScriptStaticallyRegistered(url: string): boolean;
2
+ export declare function isContentScriptDynamicallyRegistered(url: string): Promise<boolean>;
3
+ /**
4
+ Checks whether a URL will have the content scripts automatically injected.
5
+ It returns a promise that resolves with string indicating the type of injection ('static' or 'dynamic') or `false` if it won't be injected on the specified URL.
6
+ */
7
+ export declare function isContentScriptRegistered(url: string): Promise<'static' | 'dynamic' | false>;
@@ -0,0 +1,30 @@
1
+ import { queryAdditionalPermissions } from 'webext-permissions';
2
+ import { patternToRegex } from 'webext-patterns';
3
+ export function isContentScriptStaticallyRegistered(url) {
4
+ return Boolean(chrome.runtime
5
+ .getManifest()
6
+ .content_scripts
7
+ ?.flatMap(script => script.matches)
8
+ .some(pattern => patternToRegex(pattern).test(url)));
9
+ }
10
+ export async function isContentScriptDynamicallyRegistered(url) {
11
+ // Injected by `webext-active-tab`
12
+ const { origins } = await queryAdditionalPermissions({
13
+ strictOrigins: false,
14
+ });
15
+ // Do not replace the 2 calls above with `permissions.getAll` because it might also include hosts that are permitted by the manifest but have no content script registered
16
+ return patternToRegex(...origins).test(url);
17
+ }
18
+ /**
19
+ Checks whether a URL will have the content scripts automatically injected.
20
+ It returns a promise that resolves with string indicating the type of injection ('static' or 'dynamic') or `false` if it won't be injected on the specified URL.
21
+ */
22
+ export async function isContentScriptRegistered(url) {
23
+ if (isContentScriptStaticallyRegistered(url)) {
24
+ return 'static';
25
+ }
26
+ if (await isContentScriptDynamicallyRegistered(url)) {
27
+ return 'dynamic';
28
+ }
29
+ return false;
30
+ }
package/license ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) Federico Brigante <me@fregante.com> (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.
package/package.json ADDED
@@ -0,0 +1,102 @@
1
+ {
2
+ "name": "webext-active-tab",
3
+ "version": "0.0.0",
4
+ "description": "WebExtension module: Automatically registers your `content_scripts` on domains added via `permission.request` or on `activeTab`",
5
+ "keywords": [
6
+ "contentscript",
7
+ "register",
8
+ "injection",
9
+ "permissions",
10
+ "request",
11
+ "optional_permissions",
12
+ "host_permissions",
13
+ "manifest",
14
+ "new hosts",
15
+ "chrome",
16
+ "firefox",
17
+ "browser",
18
+ "activetab",
19
+ "extension"
20
+ ],
21
+ "repository": "fregante/webext-active-tab",
22
+ "funding": "https://github.com/sponsors/fregante",
23
+ "license": "MIT",
24
+ "author": "Federico Brigante <me@fregante.com> (https://fregante.com)",
25
+ "type": "module",
26
+ "exports": {
27
+ "./on-active-tabs": {
28
+ "types": "./distribution/on-active-tabs.d.ts",
29
+ "default": "./distribution/on-active-tabs.js"
30
+ },
31
+ "./register-active-tab-scripts": {
32
+ "types": "./distribution/register-active-tab-scripts.d.ts",
33
+ "default": "./distribution/register-active-tab-scripts.js"
34
+ }
35
+ },
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "demo:build": "parcel build --no-cache",
39
+ "demo:watch": "parcel watch --no-cache --no-hmr",
40
+ "prepack": "tsc --sourceMap false",
41
+ "lint": "xo",
42
+ "fix": "xo --fix",
43
+ "test": "run-p lint build demo:build",
44
+ "watch": "tsc --watch"
45
+ },
46
+ "xo": {
47
+ "envs": [
48
+ "browser",
49
+ "webextensions"
50
+ ],
51
+ "rules": {
52
+ "no-implicit-globals": "off",
53
+ "@typescript-eslint/prefer-nullish-coalescing": "off",
54
+ "@typescript-eslint/no-implicit-any-catch": "off",
55
+ "@typescript-eslint/naming-convention": "off",
56
+ "import/extensions": "off",
57
+ "import/no-unassigned-import": "off",
58
+ "unicorn/prefer-top-level-await": "off",
59
+ "unicorn/prefer-node-protocol": "off",
60
+ "n/file-extension-in-import": "off"
61
+ }
62
+ },
63
+ "dependencies": {
64
+ "simple-event-target": "^2.0.0",
65
+ "webext-content-scripts": "^2.7.2",
66
+ "webext-detect": "^5.3.2"
67
+ },
68
+ "devDependencies": {
69
+ "@parcel/config-webextension": "^2.13.3",
70
+ "@sindresorhus/tsconfig": "^7.0.0",
71
+ "@types/chrome": "^0.0.300",
72
+ "npm-run-all2": "^7.0.2",
73
+ "parcel": "^2.13.3",
74
+ "typescript": "^5.7.3",
75
+ "xo": "^0.60.0"
76
+ },
77
+ "@parcel/resolver-default": {
78
+ "packageExports": true
79
+ },
80
+ "targets": {
81
+ "main": false,
82
+ "module": false,
83
+ "demo": {
84
+ "source": "./demo-extension/manifest.json",
85
+ "distDir": "./dist",
86
+ "sourceMap": {
87
+ "inline": true
88
+ },
89
+ "optimize": false
90
+ }
91
+ },
92
+ "webExt": {
93
+ "sourceDir": "dist",
94
+ "run": {
95
+ "startUrl": [
96
+ "https://extra-ephiframe.vercel.app/Inject-via-context-menu-please",
97
+ "https://extra-ephiframe.vercel.app/Inject-via-context-menu-please?iframe=https://ephiframe.vercel.app/Static-inner",
98
+ "chrome://extensions/"
99
+ ]
100
+ }
101
+ }
102
+ }
package/readme.md ADDED
@@ -0,0 +1,36 @@
1
+ # webext-active-tab [![npm version](https://img.shields.io/npm/v/webext-active-tab.svg)](https://www.npmjs.com/package/webext-active-tab)
2
+
3
+ > WebExtension module: Track `activeTab` permission; automatically inject content scripts
4
+
5
+ The main use case is to ship your extension without `host_permissions` and have the user invoke the extension on click on any website.
6
+
7
+ Alternatively, consider using [webext-permission-toggle](https://github.com/fregante/webext-permission-toggle) and [webext-dynamic-content-scripts](https://github.com/fregante/webext-dynamic-content-scripts) to permanently receive access to certain hosts and automatically register content scripts on it.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install webext-active-tab
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ This package exports various utilities, just import what you need.
18
+
19
+ - [on-active-tab](./source/on-active-tab.md) - Watch when tabs receive `activeTab`
20
+ - [register-active-tab-scripts](./source/register-active-tab-scripts.md) - Automatically inject scripts into tabs with `activeTab`
21
+
22
+ > [!NOTE]
23
+ > Firefox has some limitations:
24
+ > - `activeTab` seems to be lost after a reload
25
+ > - further `contextMenu` clicks receive a moz-extension URL rather than the current page’s URL
26
+
27
+ ## Related
28
+
29
+ - [webext-dynamic-content-scripts](https://github.com/fregante/webext-dynamic-content-scripts) - Automatically registers your `content_scripts` on domains added via `permission.request`.
30
+ - [webext-events](https://github.com/fregante/webext-events) - High-level events and utilities for events in Web Extensions
31
+ - [Awesome-WebExtensions](https://github.com/fregante/Awesome-WebExtensions) - A curated list of awesome resources for WebExtensions development.
32
+ - [More…](https://github.com/fregante/webext-fun)
33
+
34
+ ## License
35
+
36
+ MIT © [Federico Brigante](https://fregante.com)