webext-active-tab 0.9.0 → 1.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.
@@ -1,3 +1,4 @@
1
+ import { StorageItemMap } from 'webext-storage';
1
2
  type TabId = number;
2
3
  type Origin = string;
3
4
  export type ActiveTab = {
@@ -5,9 +6,9 @@ export type ActiveTab = {
5
6
  origin: Origin;
6
7
  };
7
8
  /**
8
- * The list is not guaranteed to be up to date. The activeTab permission might be lost before it's detected.
9
+ The list is not guaranteed to be up to date. The activeTab permission might be lost before it's detected.
9
10
  */
10
- export declare const possiblyActiveTabs: Map<number, string>;
11
+ export declare const possiblyActiveTabs: StorageItemMap<string, string | undefined> | Map<string, string>;
11
12
  declare function startActiveTabTracking(): void;
12
13
  declare function stopActiveTabTracking(): void;
13
14
  export declare const PRIVATE: {
@@ -1,6 +1,7 @@
1
1
  import { isScriptableUrl } from 'webext-content-scripts';
2
2
  import { isBackground } from 'webext-detect';
3
3
  import SimpleEventTarget from 'simple-event-target';
4
+ import { StorageItemMap } from 'webext-storage';
4
5
  if (!isBackground()) {
5
6
  throw new Error('This module is only allowed in a background script');
6
7
  }
@@ -9,63 +10,87 @@ if (!chrome.runtime.getManifest().permissions?.includes('activeTab')) {
9
10
  }
10
11
  const activation = new SimpleEventTarget();
11
12
  const deactivation = new SimpleEventTarget();
12
- const browserAction = chrome.action ?? chrome.browserAction;
13
- // TODO: use https://github.com/fregante/webext-storage/issues/5
14
13
  /**
15
- * The list is not guaranteed to be up to date. The activeTab permission might be lost before it's detected.
14
+ The list is not guaranteed to be up to date. The activeTab permission might be lost before it's detected.
16
15
  */
17
- export const possiblyActiveTabs = new Map();
16
+ // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Typed as always defined, but `chrome.storage` or `chrome.storage.session` can be missing at runtime
17
+ export const possiblyActiveTabs = chrome.storage?.session
18
+ ? new StorageItemMap('webext-active-tab', {
19
+ area: 'session',
20
+ })
21
+ : new Map();
18
22
  async function addIfScriptable({ url, id }) {
19
- if (id && url
23
+ if (id !== undefined && url !== undefined
20
24
  // Skip if it already exists. A previous change of origin already cleared this
21
- && !possiblyActiveTabs.has(id)
25
+ && !await possiblyActiveTabs.has(String(id))
22
26
  // ActiveTab doesn't make sense on non-scriptable URLs as they generally don't have scriptable frames
23
27
  && isScriptableUrl(url)
24
28
  // Note: Do not filter by `isContentScriptRegistered`; `active-tab` also applies to random `executeScript` calls
25
29
  ) {
26
30
  const { origin } = new URL(url);
27
31
  console.debug('activeTab:', id, 'added', { origin });
28
- possiblyActiveTabs.set(id, origin);
32
+ await possiblyActiveTabs.set(String(id), origin);
29
33
  activation.emit({ id, origin });
30
34
  }
31
35
  else {
32
- console.debug('activeTab:', id, 'not added', { origin });
36
+ console.debug('activeTab:', id, 'not added', { url });
33
37
  }
34
38
  }
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);
39
+ // Serialized per tab, so overlapping events can't both see the entry and emit twice
40
+ const pendingDrops = new Map();
41
+ // Drops the tab if it's active, unless it's still on `keepOrigin`
42
+ async function drop(tabId, keepOrigin) {
43
+ const previous = pendingDrops.get(tabId);
44
+ const { promise: current, resolve: release } = Promise.withResolvers();
45
+ pendingDrops.set(tabId, current);
46
+ try {
47
+ try {
48
+ await previous;
49
+ }
50
+ catch { }
51
+ const activeOrigin = await possiblyActiveTabs.get(String(tabId));
52
+ if (activeOrigin !== undefined && activeOrigin !== keepOrigin) {
53
+ console.debug('activeTab:', tabId, 'removed', { activeOrigin, keepOrigin });
54
+ await possiblyActiveTabs.delete(String(tabId));
55
+ deactivation.emit(tabId);
56
+ }
57
+ }
58
+ finally {
59
+ release();
60
+ if (pendingDrops.get(tabId) === current) {
61
+ pendingDrops.delete(tabId);
41
62
  }
42
63
  }
43
64
  }
65
+ async function dropIfActive(tabId) {
66
+ await drop(tabId);
67
+ }
68
+ async function dropIfOriginChanged(tabId, { url }) {
69
+ if (url === undefined || url === '') {
70
+ return;
71
+ }
72
+ await drop(tabId, new URL(url).origin);
73
+ }
44
74
  function altListener(_, tab) {
45
75
  if (tab) {
46
76
  void addIfScriptable(tab);
47
77
  }
48
78
  }
49
- function drop(tabId) {
50
- console.debug('activeTab:', tabId, 'removed');
51
- possiblyActiveTabs.delete(tabId);
52
- deactivation.emit(tabId);
53
- }
54
79
  // https://developer.chrome.com/docs/extensions/mv3/manifest/activeTab/#invoking-activeTab
55
80
  function startActiveTabTracking() {
56
- browserAction?.onClicked.addListener(addIfScriptable);
81
+ chrome.action?.onClicked.addListener(addIfScriptable);
57
82
  chrome.contextMenus?.onClicked.addListener(altListener);
58
83
  chrome.commands?.onCommand.addListener(altListener);
59
84
  chrome.tabs.onUpdated.addListener(dropIfOriginChanged);
60
- chrome.tabs.onRemoved.addListener(drop);
85
+ chrome.tabs.onRemoved.addListener(dropIfActive);
61
86
  }
62
87
  function stopActiveTabTracking() {
63
- browserAction?.onClicked.removeListener(addIfScriptable);
88
+ chrome.action?.onClicked.removeListener(addIfScriptable);
64
89
  chrome.contextMenus?.onClicked.removeListener(altListener);
65
90
  chrome.commands?.onCommand.removeListener(altListener);
66
91
  chrome.tabs.onUpdated.removeListener(dropIfOriginChanged);
67
- chrome.tabs.onRemoved.removeListener(drop);
68
- possiblyActiveTabs.clear();
92
+ chrome.tabs.onRemoved.removeListener(dropIfActive);
93
+ void possiblyActiveTabs.clear();
69
94
  }
70
95
  export const PRIVATE = {
71
96
  startActiveTabTracking,
@@ -1,41 +1,44 @@
1
1
  import { injectContentScript } from 'webext-content-scripts';
2
2
  import { onActiveTab, possiblyActiveTabs } from './on-active-tab.js';
3
- const gotNavigation = typeof chrome === 'object' && 'webNavigation' in chrome;
3
+ const hasNavigation = typeof chrome === 'object' && 'webNavigation' in chrome;
4
4
  const scripts = [];
5
5
  async function injectAllFrames({ id: tabId, origin: url }) {
6
6
  if (tabId === undefined) {
7
7
  return;
8
8
  }
9
- if (!gotNavigation) {
9
+ if (!hasNavigation) {
10
10
  // Without it, we only inject it into the top frame
11
11
  return injectOneFrame({ frameId: 0, url, tabId });
12
12
  }
13
13
  const frames = await chrome.webNavigation.getAllFrames({ tabId });
14
14
  // .map() needed for async loop
15
- void frames?.map(async ({ frameId, url }) => injectOneFrame({ frameId, url, tabId }));
15
+ void frames?.map(async ({ frameId, url: frameUrl }) => injectOneFrame({ frameId, url: frameUrl, tabId }));
16
16
  }
17
17
  async function injectOneFrame({ tabId, frameId, url }) {
18
18
  const { origin } = new URL(url);
19
+ const activeOrigin = await possiblyActiveTabs.get(String(tabId));
19
20
  // Check origin because the request might be for a frame; cross-origin frames do not receive activeTab
20
- if (possiblyActiveTabs.get(tabId) === origin) {
21
+ if (activeOrigin === origin) {
21
22
  console.debug('activeTab: will inject', { tabId, frameId, url });
22
- await injectContentScript({ tabId, frameId }, scripts);
23
+ await injectContentScript({ tabId, frameId },
24
+ // `webext-content-scripts` uses the `manifest.json` format
25
+ scripts.map(({ runAt, matchAboutBlank, ...script }) => ({ ...script, run_at: runAt, match_about_blank: matchAboutBlank })), { ignoreTargetErrors: true });
23
26
  }
24
27
  else {
25
- console.debug('activeTab: won’t inject', { tabId, frameId, url }, { activeTab: possiblyActiveTabs.get(tabId) ?? 'no' });
28
+ console.debug('activeTab: won’t inject', { tabId, frameId, url }, { activeTab: activeOrigin ?? 'no' });
26
29
  }
27
30
  }
28
31
  async function tabListener(tabId, { status }, { url }) {
29
32
  // Only status updates are relevant
30
33
  // No URL = no permission
31
- if (status === 'loading' && url) {
34
+ if (status === 'loading' && url !== undefined) {
32
35
  await injectOneFrame({ tabId, url, frameId: 0 });
33
36
  }
34
37
  }
35
38
  export default function registerActiveTabScripts(...newScripts) {
36
39
  scripts.push(...newScripts);
37
40
  onActiveTab.addListener(injectAllFrames);
38
- if (gotNavigation) {
41
+ if (hasNavigation) {
39
42
  chrome.webNavigation.onCommitted.addListener(injectOneFrame);
40
43
  }
41
44
  else {
package/package.json CHANGED
@@ -1,26 +1,10 @@
1
1
  {
2
2
  "name": "webext-active-tab",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "description": "WebExtension module: Track `activeTab` permission; automatically inject content scripts",
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
- ],
5
+ "license": "MIT",
21
6
  "repository": "fregante/webext-active-tab",
22
7
  "funding": "https://github.com/sponsors/fregante",
23
- "license": "MIT",
24
8
  "author": "Federico Brigante <me@fregante.com> (https://fregante.com)",
25
9
  "type": "module",
26
10
  "exports": {
@@ -33,46 +17,54 @@
33
17
  "default": "./distribution/register-active-tab-scripts.js"
34
18
  }
35
19
  },
20
+ "sideEffects": false,
21
+ "engines": {
22
+ "node": ">=24"
23
+ },
36
24
  "scripts": {
37
25
  "build": "tsc",
38
26
  "demo:build": "parcel build --no-cache",
39
27
  "demo:watch": "parcel watch --no-cache --no-hmr",
40
- "prepack": "tsc --sourceMap false",
28
+ "prepare": "tsc --sourceMap false",
41
29
  "lint": "xo",
42
30
  "fix": "xo --fix",
43
31
  "test": "run-p lint build demo:build",
44
32
  "watch": "tsc --watch"
45
33
  },
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
- },
34
+ "files": [
35
+ "distribution"
36
+ ],
37
+ "keywords": [
38
+ "contentscript",
39
+ "register",
40
+ "injection",
41
+ "permissions",
42
+ "request",
43
+ "optional_permissions",
44
+ "host_permissions",
45
+ "manifest",
46
+ "new hosts",
47
+ "chrome",
48
+ "firefox",
49
+ "browser",
50
+ "activetab",
51
+ "extension"
52
+ ],
63
53
  "dependencies": {
64
- "simple-event-target": "^2.0.0",
65
- "webext-content-scripts": "^2.7.2",
66
- "webext-detect": "^5.3.2"
54
+ "simple-event-target": "^2.1.0",
55
+ "webext-content-scripts": "^3.1.0",
56
+ "webext-detect": "^6.0.0",
57
+ "webext-storage": "^3.1.0"
67
58
  },
68
59
  "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"
60
+ "@parcel/config-webextension": "^2.16.4",
61
+ "@sindresorhus/tsconfig": "^8.1.0",
62
+ "@types/chrome": "^0.3.6",
63
+ "globals": "^17.13.0",
64
+ "npm-run-all2": "^9.0.3",
65
+ "parcel": "^2.16.4",
66
+ "typescript": "^6.0.3",
67
+ "xo": "^5.0.1"
76
68
  },
77
69
  "@parcel/resolver-default": {
78
70
  "packageExports": true
@@ -86,7 +78,12 @@
86
78
  "sourceMap": {
87
79
  "inline": true
88
80
  },
89
- "optimize": false
81
+ "optimize": false,
82
+ "engines": {
83
+ "browsers": [
84
+ "last 1 Chrome version"
85
+ ]
86
+ }
90
87
  }
91
88
  },
92
89
  "webExt": {
@@ -1 +0,0 @@
1
- export {};
@@ -1,2 +0,0 @@
1
- import register from './scripts.js';
2
- register();
@@ -1 +0,0 @@
1
- export default function register(): void;
@@ -1,47 +0,0 @@
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
- }
@@ -1,11 +0,0 @@
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 {};
@@ -1,65 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
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>;
@@ -1,30 +0,0 @@
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
- }