@arraypress/extension-kit 1.11.1 → 1.12.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.
- package/README.md +75 -1
- package/package.json +2 -1
- package/src/build/vendor.js +1 -1
- package/src/files/save.d.ts +40 -0
- package/src/files/save.js +46 -0
- package/src/files/zip.d.ts +217 -0
- package/src/files/zip.js +498 -0
- package/src/index.d.ts +17 -2
- package/src/index.js +11 -2
- package/src/net/requests.d.ts +130 -0
- package/src/net/requests.js +199 -0
- package/src/sites/sites.d.ts +32 -1
- package/src/sites/sites.js +64 -1
- package/src/toolbar/theme.d.ts +8 -4
- package/src/toolbar/theme.js +9 -6
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One request from a service worker that follows nothing, with what `fetch`
|
|
3
|
+
* hides of a redirect read through webRequest: for anything that must see
|
|
4
|
+
* each hop before it's made (a link's redirects shown one by one, a page
|
|
5
|
+
* fetched on a schedule that must never be sent on into the person's own
|
|
6
|
+
* network).
|
|
7
|
+
*
|
|
8
|
+
* What MV3 allows, checked in Chromium 156 (Clean Links' `test/e2e/where.mjs`,
|
|
9
|
+
* Better Alerts' `test/e2e/background.mjs`):
|
|
10
|
+
* - `fetch(url, {redirect: 'manual'})` in the worker answers a redirect with an
|
|
11
|
+
* `opaqueredirect` response: status 0, no headers, so no `Location`, even
|
|
12
|
+
* with host access. `redirect: 'follow'` hides the hops, and the request to
|
|
13
|
+
* the next site is made before anyone can look at where it goes.
|
|
14
|
+
* - webRequest does see the worker's own requests (tab -1, initiator the
|
|
15
|
+
* extension) when it has host access to them: `onHeadersReceived` gives the
|
|
16
|
+
* status line and every header, `Location` included, even for a manual
|
|
17
|
+
* redirect. So each hop is one manual fetch, and its redirect is read there.
|
|
18
|
+
* - `webRequest` adds no install warning, and must be a required permission:
|
|
19
|
+
* a listener added after Chrome grants an optional webRequest at run time
|
|
20
|
+
* gets no events until the worker restarts. It sees nothing without host
|
|
21
|
+
* access, which can stay optional.
|
|
22
|
+
*
|
|
23
|
+
* Requests carry no cookies (`credentials: 'omit'`), no referrer, and skip the cache.
|
|
24
|
+
*
|
|
25
|
+
* @module net/requests
|
|
26
|
+
*/
|
|
27
|
+
/** How long to wait for webRequest's headers after the response is in (they come first). */
|
|
28
|
+
export declare const HEADERS_WAIT_MS = 2000;
|
|
29
|
+
/** The filter for the worker's own requests: outside any tab, as `fetch` makes them. */
|
|
30
|
+
export declare const OWN_REQUESTS: chrome.webRequest.RequestFilter;
|
|
31
|
+
export type SeenRequest = {
|
|
32
|
+
statusCode: number;
|
|
33
|
+
responseHeaders?: {
|
|
34
|
+
name: string;
|
|
35
|
+
value?: string;
|
|
36
|
+
}[];
|
|
37
|
+
} | {
|
|
38
|
+
error: string;
|
|
39
|
+
};
|
|
40
|
+
export type OwnRequestEvents = {
|
|
41
|
+
/**
|
|
42
|
+
* Response headers.
|
|
43
|
+
*/
|
|
44
|
+
onHeadersReceived: {
|
|
45
|
+
addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter, extra?: string[]): void;
|
|
46
|
+
removeListener(listener: (details: any) => void): void;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Failed requests.
|
|
50
|
+
*/
|
|
51
|
+
onErrorOccurred: {
|
|
52
|
+
addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter): void;
|
|
53
|
+
removeListener(listener: (details: any) => void): void;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
export type OwnAnswer = {
|
|
57
|
+
/**
|
|
58
|
+
* The HTTP status; a redirect's own (301…), read through webRequest; 0
|
|
59
|
+
* when nothing could be read.
|
|
60
|
+
*/
|
|
61
|
+
status: number;
|
|
62
|
+
/**
|
|
63
|
+
* The response's headers, by lower-case
|
|
64
|
+
* name; a redirect's `location` among them.
|
|
65
|
+
*/
|
|
66
|
+
headers: {
|
|
67
|
+
get(name: string): string | null;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The response, to read a body from; null for a redirect.
|
|
71
|
+
*/
|
|
72
|
+
response: Response | null;
|
|
73
|
+
/**
|
|
74
|
+
* It redirected to something that isn't a web address, and where
|
|
75
|
+
* can't be read (Chrome refuses such a redirect, `net::ERR_UNSAFE_REDIRECT`, before its headers show).
|
|
76
|
+
*/
|
|
77
|
+
leavesWeb: boolean;
|
|
78
|
+
};
|
|
79
|
+
export type OwnRequestOptions = {
|
|
80
|
+
/**
|
|
81
|
+
* `'GET'` (the default) or `'HEAD'`.
|
|
82
|
+
*/
|
|
83
|
+
method?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Request headers (`If-None-Match`…).
|
|
86
|
+
*/
|
|
87
|
+
headers?: Record<string, string>;
|
|
88
|
+
/**
|
|
89
|
+
* Stops it.
|
|
90
|
+
*/
|
|
91
|
+
signal?: AbortSignal;
|
|
92
|
+
};
|
|
93
|
+
export type OwnRequests = {
|
|
94
|
+
/**
|
|
95
|
+
* Start listening to webRequest for as long as requests are
|
|
96
|
+
* being made; the function it returns stops. Not at the worker's top level: the filter can't
|
|
97
|
+
* name the extension, only “outside any tab”, which is also every website's own service worker,
|
|
98
|
+
* and with access to many sites each of their requests would wake this worker to be thrown away.
|
|
99
|
+
* Work that makes requests keeps the worker awake by itself, so nothing is missed.
|
|
100
|
+
*/
|
|
101
|
+
listen: () => () => void;
|
|
102
|
+
/**
|
|
103
|
+
* One request,
|
|
104
|
+
* following nothing. Rejects as `fetch` does (the network, an abort).
|
|
105
|
+
*/
|
|
106
|
+
request: (url: string, options?: OwnRequestOptions) => Promise<OwnAnswer>;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* The worker's own requests, one at a time and following nothing, a redirect's
|
|
110
|
+
* status and `Location` read through webRequest.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} edges - Chrome's side.
|
|
113
|
+
* @param {OwnRequestEvents} edges.webRequest - `chrome.webRequest`.
|
|
114
|
+
* @param {string} edges.extensionId - `chrome.runtime.id`: only this extension's requests are read.
|
|
115
|
+
* @param {typeof fetch} [edges.fetch] - `fetch`. Default the global one.
|
|
116
|
+
* @param {number} [edges.headersWaitMs] - Default {@link HEADERS_WAIT_MS}.
|
|
117
|
+
* @returns {OwnRequests} `listen` and `request`.
|
|
118
|
+
* @example
|
|
119
|
+
* const own = ownRequests({ webRequest: chrome.webRequest, extensionId: chrome.runtime.id });
|
|
120
|
+
* const stop = own.listen();
|
|
121
|
+
* const { status, headers } = await own.request('https://bit.ly/abc', { method: 'HEAD' });
|
|
122
|
+
* // 301, headers.get('location') === 'https://example.com/'
|
|
123
|
+
* stop();
|
|
124
|
+
*/
|
|
125
|
+
export declare function ownRequests({ webRequest, extensionId, fetch: ask, headersWaitMs }: {
|
|
126
|
+
webRequest: OwnRequestEvents;
|
|
127
|
+
extensionId: string;
|
|
128
|
+
fetch?: typeof fetch;
|
|
129
|
+
headersWaitMs?: number;
|
|
130
|
+
}): OwnRequests;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One request from a service worker that follows nothing, with what `fetch`
|
|
3
|
+
* hides of a redirect read through webRequest: for anything that must see
|
|
4
|
+
* each hop before it's made (a link's redirects shown one by one, a page
|
|
5
|
+
* fetched on a schedule that must never be sent on into the person's own
|
|
6
|
+
* network).
|
|
7
|
+
*
|
|
8
|
+
* What MV3 allows, checked in Chromium 156 (Clean Links' `test/e2e/where.mjs`,
|
|
9
|
+
* Better Alerts' `test/e2e/background.mjs`):
|
|
10
|
+
* - `fetch(url, {redirect: 'manual'})` in the worker answers a redirect with an
|
|
11
|
+
* `opaqueredirect` response: status 0, no headers, so no `Location`, even
|
|
12
|
+
* with host access. `redirect: 'follow'` hides the hops, and the request to
|
|
13
|
+
* the next site is made before anyone can look at where it goes.
|
|
14
|
+
* - webRequest does see the worker's own requests (tab -1, initiator the
|
|
15
|
+
* extension) when it has host access to them: `onHeadersReceived` gives the
|
|
16
|
+
* status line and every header, `Location` included, even for a manual
|
|
17
|
+
* redirect. So each hop is one manual fetch, and its redirect is read there.
|
|
18
|
+
* - `webRequest` adds no install warning, and must be a required permission:
|
|
19
|
+
* a listener added after Chrome grants an optional webRequest at run time
|
|
20
|
+
* gets no events until the worker restarts. It sees nothing without host
|
|
21
|
+
* access, which can stay optional.
|
|
22
|
+
*
|
|
23
|
+
* Requests carry no cookies (`credentials: 'omit'`), no referrer, and skip the cache.
|
|
24
|
+
*
|
|
25
|
+
* @module net/requests
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** How long to wait for webRequest's headers after the response is in (they come first). */
|
|
29
|
+
export const HEADERS_WAIT_MS = 2000;
|
|
30
|
+
|
|
31
|
+
/** The filter for the worker's own requests: outside any tab, as `fetch` makes them. */
|
|
32
|
+
export const OWN_REQUESTS = /** @type {chrome.webRequest.RequestFilter} */ (Object.freeze({ urls: ['<all_urls>'], tabId: -1, types: ['xmlhttprequest'] }));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What webRequest reports of one of the worker's requests: its response's status and headers, or
|
|
36
|
+
* the error that ended it.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {{statusCode: number, responseHeaders?: {name: string, value?: string}[]} | {error: string}} SeenRequest
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The parts of `chrome.webRequest` used (fakes in tests).
|
|
43
|
+
*
|
|
44
|
+
* @typedef {object} OwnRequestEvents
|
|
45
|
+
* @property {{addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter, extra?: string[]): void, removeListener(listener: (details: any) => void): void}} onHeadersReceived
|
|
46
|
+
* Response headers.
|
|
47
|
+
* @property {{addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter): void, removeListener(listener: (details: any) => void): void}} onErrorOccurred
|
|
48
|
+
* Failed requests.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One answer.
|
|
53
|
+
*
|
|
54
|
+
* @typedef {object} OwnAnswer
|
|
55
|
+
* @property {number} status The HTTP status; a redirect's own (301…), read through webRequest; 0
|
|
56
|
+
* when nothing could be read.
|
|
57
|
+
* @property {{get(name: string): string | null}} headers The response's headers, by lower-case
|
|
58
|
+
* name; a redirect's `location` among them.
|
|
59
|
+
* @property {Response | null} response The response, to read a body from; null for a redirect.
|
|
60
|
+
* @property {boolean} leavesWeb It redirected to something that isn't a web address, and where
|
|
61
|
+
* can't be read (Chrome refuses such a redirect, `net::ERR_UNSAFE_REDIRECT`, before its headers show).
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What a request may set.
|
|
66
|
+
*
|
|
67
|
+
* @typedef {object} OwnRequestOptions
|
|
68
|
+
* @property {string} [method] `'GET'` (the default) or `'HEAD'`.
|
|
69
|
+
* @property {Record<string, string>} [headers] Request headers (`If-None-Match`…).
|
|
70
|
+
* @property {AbortSignal} [signal] Stops it.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The worker's own requests.
|
|
75
|
+
*
|
|
76
|
+
* @typedef {object} OwnRequests
|
|
77
|
+
* @property {() => () => void} listen Start listening to webRequest for as long as requests are
|
|
78
|
+
* being made; the function it returns stops. Not at the worker's top level: the filter can't
|
|
79
|
+
* name the extension, only “outside any tab”, which is also every website's own service worker,
|
|
80
|
+
* and with access to many sites each of their requests would wake this worker to be thrown away.
|
|
81
|
+
* Work that makes requests keeps the worker awake by itself, so nothing is missed.
|
|
82
|
+
* @property {(url: string, options?: OwnRequestOptions) => Promise<OwnAnswer>} request One request,
|
|
83
|
+
* following nothing. Rejects as `fetch` does (the network, an abort).
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The key a request is matched by: method and URL without its fragment (webRequest never sees one).
|
|
88
|
+
*
|
|
89
|
+
* @param {string} method - 'HEAD' or 'GET'.
|
|
90
|
+
* @param {string} url - The address.
|
|
91
|
+
* @returns {string} The key.
|
|
92
|
+
*/
|
|
93
|
+
const requestKey = (method, url) => `${method} ${new URL(url).href.replace(/#.*$/, '')}`;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One header from webRequest's list (names are case-insensitive).
|
|
97
|
+
*
|
|
98
|
+
* @param {SeenRequest | undefined} seen - What webRequest saw.
|
|
99
|
+
* @param {string} name - The header's name.
|
|
100
|
+
* @returns {string | null} Its value.
|
|
101
|
+
*/
|
|
102
|
+
const seenHeader = (seen, name) => (seen && 'responseHeaders' in seen ? seen.responseHeaders : [])
|
|
103
|
+
?.find((header) => header.name.toLowerCase() === name.toLowerCase())?.value ?? null;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A wait that gives nothing.
|
|
107
|
+
*
|
|
108
|
+
* @param {number} ms - How long.
|
|
109
|
+
* @returns {Promise<undefined>} Resolves after it.
|
|
110
|
+
*/
|
|
111
|
+
const pause = (ms) => new Promise((resolve) => { setTimeout(() => resolve(undefined), ms); });
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The worker's own requests, one at a time and following nothing, a redirect's
|
|
115
|
+
* status and `Location` read through webRequest.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} edges - Chrome's side.
|
|
118
|
+
* @param {OwnRequestEvents} edges.webRequest - `chrome.webRequest`.
|
|
119
|
+
* @param {string} edges.extensionId - `chrome.runtime.id`: only this extension's requests are read.
|
|
120
|
+
* @param {typeof fetch} [edges.fetch] - `fetch`. Default the global one.
|
|
121
|
+
* @param {number} [edges.headersWaitMs] - Default {@link HEADERS_WAIT_MS}.
|
|
122
|
+
* @returns {OwnRequests} `listen` and `request`.
|
|
123
|
+
* @example
|
|
124
|
+
* const own = ownRequests({ webRequest: chrome.webRequest, extensionId: chrome.runtime.id });
|
|
125
|
+
* const stop = own.listen();
|
|
126
|
+
* const { status, headers } = await own.request('https://bit.ly/abc', { method: 'HEAD' });
|
|
127
|
+
* // 301, headers.get('location') === 'https://example.com/'
|
|
128
|
+
* stop();
|
|
129
|
+
*/
|
|
130
|
+
export function ownRequests({ webRequest, extensionId, fetch: ask = fetch, headersWaitMs = HEADERS_WAIT_MS }) {
|
|
131
|
+
/** Requests waiting for webRequest's report, by {@link requestKey}. @type {Map<string, (seen: SeenRequest) => void>} */
|
|
132
|
+
const waiting = new Map();
|
|
133
|
+
let listeners = 0;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether a report is of this extension's own request.
|
|
137
|
+
*
|
|
138
|
+
* @param {{tabId: number, initiator?: string}} details - What Chrome reports.
|
|
139
|
+
* @returns {boolean} It is.
|
|
140
|
+
*/
|
|
141
|
+
const own = (details) => details.tabId === -1 && details.initiator === `chrome-extension://${extensionId}`;
|
|
142
|
+
/** @param {{tabId: number, initiator?: string, method: string, url: string, statusCode: number, responseHeaders?: {name: string, value?: string}[]}} details */
|
|
143
|
+
const onHeaders = (details) => {
|
|
144
|
+
if (own(details)) waiting.get(requestKey(details.method, details.url))?.(details);
|
|
145
|
+
};
|
|
146
|
+
/** @param {{tabId: number, initiator?: string, method: string, url: string, error: string}} details */
|
|
147
|
+
const onError = (details) => {
|
|
148
|
+
if (own(details)) waiting.get(requestKey(details.method, details.url))?.({ error: details.error });
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
listen() {
|
|
153
|
+
if (listeners++ === 0) {
|
|
154
|
+
webRequest.onHeadersReceived.addListener(onHeaders, OWN_REQUESTS, ['responseHeaders']);
|
|
155
|
+
webRequest.onErrorOccurred.addListener(onError, OWN_REQUESTS);
|
|
156
|
+
}
|
|
157
|
+
let stopped = false;
|
|
158
|
+
return () => {
|
|
159
|
+
if (stopped) return;
|
|
160
|
+
stopped = true;
|
|
161
|
+
if (--listeners > 0) return;
|
|
162
|
+
webRequest.onHeadersReceived.removeListener(onHeaders);
|
|
163
|
+
webRequest.onErrorOccurred.removeListener(onError);
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async request(url, { method = 'GET', headers = {}, signal } = {}) {
|
|
168
|
+
const key = requestKey(method, url);
|
|
169
|
+
/** @type {Promise<SeenRequest | undefined>} */
|
|
170
|
+
const seen = new Promise((resolve) => {
|
|
171
|
+
waiting.set(key, resolve);
|
|
172
|
+
signal?.addEventListener('abort', () => resolve(undefined), { once: true });
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
/** @type {Response} */
|
|
176
|
+
let response;
|
|
177
|
+
try {
|
|
178
|
+
response = await ask(url, { method, headers, redirect: 'manual', credentials: 'omit', cache: 'no-store', referrerPolicy: 'no-referrer', signal });
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// Chrome fails even a manual fetch whose redirect leaves the web (Location: mailto:…),
|
|
181
|
+
// reporting only the error: that redirect is still an answer, though where it goes can't be read.
|
|
182
|
+
const details = await Promise.race([seen, pause(200)]);
|
|
183
|
+
if (signal?.aborted || !details || !('error' in details) || details.error !== 'net::ERR_UNSAFE_REDIRECT') throw error;
|
|
184
|
+
return { status: 0, headers: { get: () => null }, response: null, leavesWeb: true };
|
|
185
|
+
}
|
|
186
|
+
if (response.type !== 'opaqueredirect') return { status: response.status, headers: response.headers, response, leavesWeb: false };
|
|
187
|
+
const details = await Promise.race([seen, pause(headersWaitMs)]);
|
|
188
|
+
return {
|
|
189
|
+
status: details && 'statusCode' in details ? details.statusCode : 0,
|
|
190
|
+
headers: { get: (name) => seenHeader(details, name) },
|
|
191
|
+
response: null,
|
|
192
|
+
leavesWeb: false,
|
|
193
|
+
};
|
|
194
|
+
} finally {
|
|
195
|
+
waiting.delete(key);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
package/src/sites/sites.d.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* the origin an extension asks Chrome for access to, the host a per-site
|
|
5
5
|
* on/off list keeps, whether a page is on a listed site (subdomains
|
|
6
6
|
* included), the match patterns for both, and a list with a site added,
|
|
7
|
-
* taken out or edited
|
|
7
|
+
* taken out or edited; and whether an address is a web page's, and whether
|
|
8
|
+
* it is on this computer or its own network rather than the internet. Pure.
|
|
8
9
|
*
|
|
9
10
|
* A site is a web host: http or https, with a dot in its name (or
|
|
10
11
|
* `localhost`). Text without a scheme is read as https. Only a real host
|
|
@@ -117,3 +118,33 @@ export declare function withoutSite(sites: readonly string[], site: string): str
|
|
|
117
118
|
* withSiteReplaced(['a.com', 'b.com'], 'a.com', 'b.com'); // ['b.com']
|
|
118
119
|
*/
|
|
119
120
|
export declare function withSiteReplaced(sites: readonly string[], site: string, edited: string): string[];
|
|
121
|
+
/**
|
|
122
|
+
* Whether an address is a web page (http or https), the only kind an
|
|
123
|
+
* extension can ask Chrome for access to, or fetch.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} url - Any address, valid or not.
|
|
126
|
+
* @returns {boolean} True for http(s).
|
|
127
|
+
* @example
|
|
128
|
+
* isWebUrl('https://example.com/'); // true
|
|
129
|
+
* isWebUrl('mailto:a@example.com'); // false
|
|
130
|
+
*/
|
|
131
|
+
export declare function isWebUrl(url: string): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Whether a host is this computer or on its own network rather than the internet: `localhost`
|
|
134
|
+
* and names under it, `.local`, `.lan`, `.internal` and `.home.arpa` names, a name without a dot, and the addresses
|
|
135
|
+
* kept for loopback, private networks and link-local use (IPv4 0/8, 10/8, 100.64/10, 127/8,
|
|
136
|
+
* 169.254/16, 172.16/12, 192.168/16; IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4 ones
|
|
137
|
+
* written as IPv6). Told from the address alone: a public name that resolves to a private
|
|
138
|
+
* address can't be seen from here.
|
|
139
|
+
*
|
|
140
|
+
* For anything that follows a link on a person's behalf (a redirect check, a link checker): a hop
|
|
141
|
+
* from a site on the internet to a local address is where to stop, since a request there can
|
|
142
|
+
* change something on a router, a printer or a development server.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} url - A web address (`new URL` writes "0x7f.1" and "2130706433" as 127.0.0.1).
|
|
145
|
+
* @returns {boolean} True for a local address; false for the internet, or what isn't an address.
|
|
146
|
+
* @example
|
|
147
|
+
* isLocalUrl('http://192.168.1.1/admin'); // true
|
|
148
|
+
* isLocalUrl('https://example.com/'); // false
|
|
149
|
+
*/
|
|
150
|
+
export declare function isLocalUrl(url: string): boolean;
|
package/src/sites/sites.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* the origin an extension asks Chrome for access to, the host a per-site
|
|
5
5
|
* on/off list keeps, whether a page is on a listed site (subdomains
|
|
6
6
|
* included), the match patterns for both, and a list with a site added,
|
|
7
|
-
* taken out or edited
|
|
7
|
+
* taken out or edited; and whether an address is a web page's, and whether
|
|
8
|
+
* it is on this computer or its own network rather than the internet. Pure.
|
|
8
9
|
*
|
|
9
10
|
* A site is a web host: http or https, with a dot in its name (or
|
|
10
11
|
* `localhost`). Text without a scheme is read as https. Only a real host
|
|
@@ -184,3 +185,65 @@ export function withoutSite(sites, site) {
|
|
|
184
185
|
export function withSiteReplaced(sites, site, edited) {
|
|
185
186
|
return [...new Set(sites.map((s) => (s === site ? edited : s)))];
|
|
186
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Whether an address is a web page (http or https), the only kind an
|
|
191
|
+
* extension can ask Chrome for access to, or fetch.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} url - Any address, valid or not.
|
|
194
|
+
* @returns {boolean} True for http(s).
|
|
195
|
+
* @example
|
|
196
|
+
* isWebUrl('https://example.com/'); // true
|
|
197
|
+
* isWebUrl('mailto:a@example.com'); // false
|
|
198
|
+
*/
|
|
199
|
+
export function isWebUrl(url) {
|
|
200
|
+
try {
|
|
201
|
+
return ['http:', 'https:'].includes(new URL(url).protocol);
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether a host is this computer or on its own network rather than the internet: `localhost`
|
|
209
|
+
* and names under it, `.local`, `.lan`, `.internal` and `.home.arpa` names, a name without a dot, and the addresses
|
|
210
|
+
* kept for loopback, private networks and link-local use (IPv4 0/8, 10/8, 100.64/10, 127/8,
|
|
211
|
+
* 169.254/16, 172.16/12, 192.168/16; IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4 ones
|
|
212
|
+
* written as IPv6). Told from the address alone: a public name that resolves to a private
|
|
213
|
+
* address can't be seen from here.
|
|
214
|
+
*
|
|
215
|
+
* For anything that follows a link on a person's behalf (a redirect check, a link checker): a hop
|
|
216
|
+
* from a site on the internet to a local address is where to stop, since a request there can
|
|
217
|
+
* change something on a router, a printer or a development server.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} url - A web address (`new URL` writes "0x7f.1" and "2130706433" as 127.0.0.1).
|
|
220
|
+
* @returns {boolean} True for a local address; false for the internet, or what isn't an address.
|
|
221
|
+
* @example
|
|
222
|
+
* isLocalUrl('http://192.168.1.1/admin'); // true
|
|
223
|
+
* isLocalUrl('https://example.com/'); // false
|
|
224
|
+
*/
|
|
225
|
+
export function isLocalUrl(url) {
|
|
226
|
+
let host;
|
|
227
|
+
try {
|
|
228
|
+
host = new URL(url).hostname.toLowerCase().replace(/\.$/, '');
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
if (!host) return false;
|
|
233
|
+
if (host.startsWith('[')) {
|
|
234
|
+
const address = host.slice(1, -1);
|
|
235
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(address);
|
|
236
|
+
if (mapped) {
|
|
237
|
+
const [high, low] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
|
|
238
|
+
return isLocalUrl(`http://${high >> 8}.${high & 255}.${low >> 8}.${low & 255}/`);
|
|
239
|
+
}
|
|
240
|
+
return address === '::' || address === '::1' || /^f[cd][0-9a-f]{2}:/.test(address) || /^fe[89ab][0-9a-f]:/.test(address);
|
|
241
|
+
}
|
|
242
|
+
const ipv4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(host);
|
|
243
|
+
if (ipv4) {
|
|
244
|
+
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
|
245
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
246
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
247
|
+
}
|
|
248
|
+
return host === 'localhost' || !host.includes('.') || /\.(?:localhost|local|internal|lan|home\.arpa)$/.test(host);
|
|
249
|
+
}
|
package/src/toolbar/theme.d.ts
CHANGED
|
@@ -135,9 +135,11 @@ export type OffscreenApi = {
|
|
|
135
135
|
* @param {string} [justification] - What the document is for, when it does more than watch the
|
|
136
136
|
* scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
|
|
137
137
|
* with the document, so it has to be the whole truth.
|
|
138
|
+
* @param {readonly string[]} [reasons] - Chrome's reasons for whatever else the document does
|
|
139
|
+
* (`'DOM_PARSER'` for one that also reads fetched HTML), after MATCH_MEDIA. Default none.
|
|
138
140
|
* @returns {Promise<boolean>} Whether it opened one (false: already open).
|
|
139
141
|
*/
|
|
140
|
-
export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string): Promise<boolean>;
|
|
142
|
+
export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string, reasons?: readonly string[]): Promise<boolean>;
|
|
141
143
|
export type ToolbarWorkerApi = {
|
|
142
144
|
/**
|
|
143
145
|
* `chrome.storage`.
|
|
@@ -182,14 +184,16 @@ export type ToolbarWorkerApi = {
|
|
|
182
184
|
* worker wakes.
|
|
183
185
|
*
|
|
184
186
|
* @param {ToolbarWorkerApi} api - `chrome`, or a fake.
|
|
185
|
-
* @param {{url?: string, justification?: string}} [document] - The offscreen
|
|
186
|
-
* it's for, when
|
|
187
|
+
* @param {{url?: string, justification?: string, reasons?: readonly string[]}} [document] - The offscreen
|
|
188
|
+
* document, what it's for and Chrome's reasons for what else it does, when they aren't the kit's
|
|
189
|
+
* defaults ({@link watchFromOffscreen}).
|
|
187
190
|
* @returns {ToolbarTheme} The theme, for tests.
|
|
188
191
|
* @example
|
|
189
192
|
* // background.js
|
|
190
193
|
* startToolbarTheme(chrome);
|
|
191
194
|
*/
|
|
192
|
-
export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification }?: {
|
|
195
|
+
export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification, reasons }?: {
|
|
193
196
|
url?: string;
|
|
194
197
|
justification?: string;
|
|
198
|
+
reasons?: readonly string[];
|
|
195
199
|
}): ToolbarTheme;
|
package/src/toolbar/theme.js
CHANGED
|
@@ -131,15 +131,17 @@ const SCHEME_JUSTIFICATION = 'Reads whether Chrome is in light or dark mode, to
|
|
|
131
131
|
* @param {string} [justification] - What the document is for, when it does more than watch the
|
|
132
132
|
* scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
|
|
133
133
|
* with the document, so it has to be the whole truth.
|
|
134
|
+
* @param {readonly string[]} [reasons] - Chrome's reasons for whatever else the document does
|
|
135
|
+
* (`'DOM_PARSER'` for one that also reads fetched HTML), after MATCH_MEDIA. Default none.
|
|
134
136
|
* @returns {Promise<boolean>} Whether it opened one (false: already open).
|
|
135
137
|
*/
|
|
136
|
-
export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION) {
|
|
138
|
+
export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION, reasons = []) {
|
|
137
139
|
opening ??= (async () => {
|
|
138
140
|
const open = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
|
|
139
141
|
if (open.length) return false;
|
|
140
142
|
await offscreen.createDocument({
|
|
141
143
|
url,
|
|
142
|
-
reasons: [offscreen.Reason.MATCH_MEDIA],
|
|
144
|
+
reasons: [offscreen.Reason.MATCH_MEDIA, ...reasons],
|
|
143
145
|
justification,
|
|
144
146
|
});
|
|
145
147
|
return true;
|
|
@@ -165,17 +167,18 @@ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH,
|
|
|
165
167
|
* worker wakes.
|
|
166
168
|
*
|
|
167
169
|
* @param {ToolbarWorkerApi} api - `chrome`, or a fake.
|
|
168
|
-
* @param {{url?: string, justification?: string}} [document] - The offscreen
|
|
169
|
-
* it's for, when
|
|
170
|
+
* @param {{url?: string, justification?: string, reasons?: readonly string[]}} [document] - The offscreen
|
|
171
|
+
* document, what it's for and Chrome's reasons for what else it does, when they aren't the kit's
|
|
172
|
+
* defaults ({@link watchFromOffscreen}).
|
|
170
173
|
* @returns {ToolbarTheme} The theme, for tests.
|
|
171
174
|
* @example
|
|
172
175
|
* // background.js
|
|
173
176
|
* startToolbarTheme(chrome);
|
|
174
177
|
*/
|
|
175
|
-
export function startToolbarTheme(api, { url, justification } = {}) {
|
|
178
|
+
export function startToolbarTheme(api, { url, justification, reasons } = {}) {
|
|
176
179
|
const theme = createToolbarTheme({ storage: api.storage.local, action: api.action, extensionId: api.runtime.id });
|
|
177
180
|
theme.restore();
|
|
178
|
-
watchFromOffscreen(api, url, justification).catch((error) => console.error('Opening the colour scheme watcher:', error));
|
|
181
|
+
watchFromOffscreen(api, url, justification, reasons).catch((error) => console.error('Opening the colour scheme watcher:', error));
|
|
179
182
|
api.runtime.onMessage.addListener((message, sender) => {
|
|
180
183
|
theme.onMessage(message, sender);
|
|
181
184
|
return false;
|