@arraypress/extension-kit 1.11.1 → 1.13.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 +76 -1
- package/package.json +2 -1
- package/src/build/index.d.ts +1 -0
- package/src/build/index.js +1 -0
- package/src/build/promo-tile.d.ts +75 -0
- package/src/build/promo-tile.js +87 -0
- 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
package/README.md
CHANGED
|
@@ -21,6 +21,9 @@ The pieces ArrayPress's Chrome extensions share, so each is written once:
|
|
|
21
21
|
every answer, cold starts, and the settings page's “Chrome’s built-in AI” row.
|
|
22
22
|
- **Content-script helpers**: a big page worked through in slices that never hold it long, and a
|
|
23
23
|
change watcher for single-page apps and endless lists.
|
|
24
|
+
- **Files**: a ZIP written a file at a time with nothing but the platform (stored or deflated,
|
|
25
|
+
ZIP64 when it's needed, memory flat however big the files), and files saved through
|
|
26
|
+
`chrome.downloads` or a link's download.
|
|
24
27
|
- **Build and test helpers** (Node): icon rendering, vendoring npm packages (with a staleness
|
|
25
28
|
check), test builds, store screenshots, zipping for the Chrome Web Store, a fake
|
|
26
29
|
`chrome.storage`, Playwright helpers with time limits, packaging checks, and a copied-code guard.
|
|
@@ -234,11 +237,20 @@ hostPatterns('github.com'); // ['*://github.com/*', '*://*.gith
|
|
|
234
237
|
originPattern('https://bit.ly/x'); // 'https://bit.ly/*' (permissions.request)
|
|
235
238
|
siteLabel('https://example.com'); // 'example.com', as a list shows it
|
|
236
239
|
withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com'], what a list keeps after Add (also withoutSite, withSiteReplaced)
|
|
240
|
+
isWebUrl('mailto:a@example.com'); // false: only http and https are web pages
|
|
241
|
+
isLocalUrl('http://192.168.1.1/admin'); // true: this computer or its network, not the internet
|
|
237
242
|
```
|
|
238
243
|
|
|
239
244
|
A site is http or https with a dot in its host (or `localhost`); text without a scheme is https;
|
|
240
245
|
`localhost:8080` is a host and port. Pure, so a content script can load `sites/sites.js` alone.
|
|
241
246
|
|
|
247
|
+
`isLocalUrl` is for anything that follows links on a person's behalf (a redirect check, a link
|
|
248
|
+
checker): `localhost` and names under it, a name without a dot, `.local`, `.lan`, `.internal`
|
|
249
|
+
and `.home.arpa` names, and the loopback, private and link-local address ranges (IPv4 and IPv6,
|
|
250
|
+
however written: `0x7f.1`, `2130706433`, `[::ffff:192.168.0.1]`). A hop from a site on the
|
|
251
|
+
internet to one of these is where to stop: a request there can change something. It is told from
|
|
252
|
+
the address alone; a public name that resolves to a private address can't be seen from here.
|
|
253
|
+
|
|
242
254
|
## A page's side
|
|
243
255
|
|
|
244
256
|
```js
|
|
@@ -464,6 +476,47 @@ in document order, into open and closed shadow roots (`shadowRootOf`, through
|
|
|
464
476
|
(`MAX_ELEMENTS`). `otherSiteFrames(doc)` gives the origins of frames from other sites, which a
|
|
465
477
|
reading of the tab leaves out until the person allows those sites.
|
|
466
478
|
|
|
479
|
+
## Files
|
|
480
|
+
|
|
481
|
+
```js
|
|
482
|
+
const zip = createZip();
|
|
483
|
+
await zip.add('Lake.jpg', blob); // a Blob: stored, read in 4 MiB slices
|
|
484
|
+
await zip.add('notes/Read me.txt', encoder.encode('Hello')); // or a Uint8Array: deflated
|
|
485
|
+
await saveFiles([{ name: 'Trip.zip', blob: await zip.finish() }], chrome.downloads);
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`createZip({store, zip64, heldBytes, date})` writes a ZIP with no library: `add(name, data, {store, date})`
|
|
489
|
+
puts a `Blob` or `Uint8Array` in and resolves once it's written, in the order added (an `add` made
|
|
490
|
+
before the last has resolved waits its turn); `count()`, `bytes()` (uncompressed), and `finish()`
|
|
491
|
+
gives a `Blob` of type `application/zip`. Runs in a page, a worker or Node.
|
|
492
|
+
|
|
493
|
+
- **Stored or deflated by name.** Formats that are compressed already (`isStoredName(name)`: JPEG,
|
|
494
|
+
PNG, WebP, MP4, PDF, ZIP, Office documents, WOFF…) are stored as they are; the rest goes through
|
|
495
|
+
the platform's `CompressionStream('deflate-raw')` (Chrome 103, Node 20.12). `store: true` stores
|
|
496
|
+
everything (or one file); an empty file is always stored.
|
|
497
|
+
- **Memory stays flat.** ZIP bytes are held up to `heldBytes` (16 MiB, `HELD_BYTES`) and then handed
|
|
498
|
+
to a growing `Blob`, which Chrome keeps outside the page's memory. A `Blob` bigger than 4 MiB
|
|
499
|
+
(`SLICE_BYTES`) is read in slices, so a 2 GB video never sits in memory whole. A `Uint8Array` is
|
|
500
|
+
kept, not copied, until it's handed over: don't write into it again.
|
|
501
|
+
- **Every reader opens it.** A stored file is read twice, once for its CRC-32, so its local header
|
|
502
|
+
is complete and no data descriptor follows (a stored entry with one can't be read forwards, and
|
|
503
|
+
some readers refuse it); only deflated files have a data descriptor, as every streaming writer's
|
|
504
|
+
do. Names are UTF-8 and say so (bit 11), with `/` between folders, and the ZIP says it was made
|
|
505
|
+
on Unix: Info-ZIP's `unzip` converts the names of an MS-DOS ZIP from an old code page, UTF-8 bit
|
|
506
|
+
or not, and “Zürich.png” then fails to extract. Checked with fflate, macOS's `unzip`, `ditto`
|
|
507
|
+
(Archive Utility's reader), `bsdtar` reading forwards and Python's `zipfile`.
|
|
508
|
+
- **ZIP64 when it's needed**, and only then: a file of 4 GiB or more (from 16 MiB short of it), a
|
|
509
|
+
ZIP that has grown past 4 GiB, more than 65,535 files. `zip64: true` writes it regardless.
|
|
510
|
+
- **A name already in the ZIP is refused** (two files of one name extract as one; give them
|
|
511
|
+
different names first), as is `add` after `finish`. A file that can't be read rejects its `add`
|
|
512
|
+
and leaves the ZIP as it was before it, so the rest can still be saved.
|
|
513
|
+
|
|
514
|
+
`crc32(bytes, seed?)` is the same CRC-32 a PNG's chunks use; `seed` carries on from the bytes before.
|
|
515
|
+
|
|
516
|
+
`saveFiles(files, downloads, doc?)` saves `{name, blob}` files in order: through `chrome.downloads`
|
|
517
|
+
when the person has granted it (a name already taken gets a number), else as a link's download,
|
|
518
|
+
which Chrome saves the same way. Each blob address is let go after a minute (`KEEP_URL_MS`).
|
|
519
|
+
|
|
467
520
|
## Time limits
|
|
468
521
|
|
|
469
522
|
```js
|
|
@@ -481,6 +534,27 @@ await eachLimited(files, 4, async (file) => save(file)); // at most four at once
|
|
|
481
534
|
`eachLimited(items, limit, work)`: `work(item, slot, index)` on every item, at most `limit` at once;
|
|
482
535
|
in the runtime half too (`/testing` exports it as well).
|
|
483
536
|
|
|
537
|
+
## A worker's own requests
|
|
538
|
+
|
|
539
|
+
```js
|
|
540
|
+
// Manifest: "webRequest" (no install warning), and host access to the sites asked (optional is fine).
|
|
541
|
+
const own = ownRequests({ webRequest: chrome.webRequest, extensionId: chrome.runtime.id });
|
|
542
|
+
const stop = own.listen(); // webRequest listeners, only while requests are made
|
|
543
|
+
const answer = await own.request('https://bit.ly/abc', { headers: { 'If-None-Match': etag }, signal });
|
|
544
|
+
if (answer.status >= 300 && answer.status < 400) next = new URL(answer.headers.get('location'), url).href;
|
|
545
|
+
else html = await answer.response.text();
|
|
546
|
+
stop();
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
One request that follows nothing: `redirect: 'manual'`, no cookies, no referrer, no cache. Chrome's
|
|
550
|
+
`fetch` answers a manual redirect with an opaque response (no status, no `Location`); webRequest
|
|
551
|
+
does see the worker's own requests to sites it has access to, so the redirect is read from its
|
|
552
|
+
report of the same request (`status`, `headers.get('location')`, `response: null`). The caller
|
|
553
|
+
decides whether to make the next hop: with `isLocalUrl`, a page on the internet never sends a
|
|
554
|
+
check into the person's own network. `leavesWeb` marks a redirect to something that isn't a web
|
|
555
|
+
address (`mailto:`), which Chrome refuses before showing where. `webRequest` must be a required
|
|
556
|
+
permission (a listener added after an optional grant hears nothing until the worker restarts).
|
|
557
|
+
|
|
484
558
|
## Page components
|
|
485
559
|
|
|
486
560
|
Chrome's own components, made in the page by script (no markup to copy), styled by `chrome-ui.css`.
|
|
@@ -639,6 +713,7 @@ A list with an Add button needs every key but `optionsRemoveLabel`; one with an
|
|
|
639
713
|
| `testBuildDir(root, name)` | `.test-builds/<name>`, or under `$EXTENSION_TEST_BUILDS` (to test another build, or run two side by side). |
|
|
640
714
|
| `storeFrame` | Store screenshots: `canvas`, `windowChrome` (and `windowChromeSvg`, from `frame/window.js`), `geometry`, `letterIcon`, `cursor`, `THEMES`, `W`×`H`. |
|
|
641
715
|
| `storeShots({browser, dir, toolbarIcon})`, `storeShotHtml(shot)`, `contextMenuHtml(menu)`, `pngUrl(png)`, `PANEL_W`, `PANEL_HEADER_H` | The family's store screenshots in one style: a caption in sentence case over Chrome's light window with the extension pinned in the toolbar, real captures (taken at 2×) inside; what headless Chrome can't capture composed around the real extension UI: the popup page hung from the pressed toolbar button, the side panel page beside the page under a header with the extension's name, the right-click menu drawn as macOS draws Chrome's (the extension's real titles). `shoot(file, shot)` writes a 1280×800 PNG. |
|
|
716
|
+
| `storePromoTile({browser, extension, file, line, glow?, name?, icon?})`, `promoTileHtml(tile)`, `PROMO_W`, `PROMO_H` | The store's small promo tile (440×280) in the family's one style: the extension's `icons/icon.svg` with a soft glow (its colour `glow`, default Chrome's blue), its English `extShortName` and one short `line`, on the store screenshots' light background. Writes the PNG and returns its path. |
|
|
642
717
|
| `placeholderWarning`, `repositoryReminder`, `printReleaseChecks` | See About links. |
|
|
643
718
|
| `findDisallowedClones({runJscpd})` | Run jscpd and return clones `.jscpd-allowlist.json` doesn't cover (the `extension-kit duplication` command). |
|
|
644
719
|
|
|
@@ -735,7 +810,7 @@ chrome://bookmarks in Chromium 156 / Chrome 157; the sources are listed at the t
|
|
|
735
810
|
|
|
736
811
|
## Development
|
|
737
812
|
|
|
738
|
-
`npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests (the menu,
|
|
813
|
+
`npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests (the menu, the ZIP writer in Chrome,
|
|
739
814
|
the dialog, the toast, the dialog site list, the window frame, icon rendering and the Chrome readers in Playwright's
|
|
740
815
|
Chromium, the rest in Node and jsdom), and the
|
|
741
816
|
copied-code guard. The `.d.ts` files are generated from the JSDoc: `npm run types` writes them, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/extension-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Shared pieces for Chrome extensions: Chrome's own page styles and components, a Chrome Sync settings store, the light/dark toolbar glyph, Chrome's on-device model, content-script helpers, About links, and build and test helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"@types/jsdom": "^30.0.0",
|
|
61
61
|
"@types/node": "^26.6.6",
|
|
62
62
|
"eslint": "^10.14.0",
|
|
63
|
+
"fflate": "^0.8.3",
|
|
63
64
|
"globals": "^17.14.0",
|
|
64
65
|
"jscpd": "^5.4.3",
|
|
65
66
|
"jsdom": "^31.0.0",
|
package/src/build/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
|
|
8
8
|
export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
|
|
9
|
+
export { PROMO_H, PROMO_W, promoTileHtml, storePromoTile } from './promo-tile.js';
|
|
9
10
|
export * as storeFrame from './store-frame.js';
|
|
10
11
|
export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
|
|
11
12
|
export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
|
package/src/build/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
|
|
9
9
|
export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
|
|
10
|
+
export { PROMO_H, PROMO_W, promoTileHtml, storePromoTile } from './promo-tile.js';
|
|
10
11
|
export * as storeFrame from './store-frame.js';
|
|
11
12
|
export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
|
|
12
13
|
export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Chrome Web Store's small promo tile (440×280) for the whole family, in one style: the
|
|
3
|
+
* extension's icon (drawn from its SVG, so it's sharp at any size) with a soft glow, its name
|
|
4
|
+
* and one short line, on the store screenshots' light background.
|
|
5
|
+
*
|
|
6
|
+
* {@link promoTileHtml} lays it out (pure); {@link storePromoTile} reads the icon and name from
|
|
7
|
+
* the extension folder and writes the PNG.
|
|
8
|
+
*
|
|
9
|
+
* @module build/promo-tile
|
|
10
|
+
*/
|
|
11
|
+
/** The promo tile's width. */
|
|
12
|
+
export declare const PROMO_W = 440;
|
|
13
|
+
/** The promo tile's height. */
|
|
14
|
+
export declare const PROMO_H = 280;
|
|
15
|
+
export type PromoTile = {
|
|
16
|
+
/**
|
|
17
|
+
* The icon: SVG markup, or an image's URL (a data: URL, say).
|
|
18
|
+
*/
|
|
19
|
+
icon: string;
|
|
20
|
+
/**
|
|
21
|
+
* The extension's name (`extShortName`).
|
|
22
|
+
*/
|
|
23
|
+
name: string;
|
|
24
|
+
/**
|
|
25
|
+
* One short line under it, sentence case (“Prices in your currency”).
|
|
26
|
+
*/
|
|
27
|
+
line: string;
|
|
28
|
+
/**
|
|
29
|
+
* The colour of the glow under the icon, any CSS colour: the icon's own (default Chrome's blue).
|
|
30
|
+
*/
|
|
31
|
+
glow?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* One promo tile.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {object} PromoTile
|
|
37
|
+
* @property {string} icon The icon: SVG markup, or an image's URL (a data: URL, say).
|
|
38
|
+
* @property {string} name The extension's name (`extShortName`).
|
|
39
|
+
* @property {string} line One short line under it, sentence case (“Prices in your currency”).
|
|
40
|
+
* @property {string} [glow] The colour of the glow under the icon, any CSS colour: the icon's own (default Chrome's blue).
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The tile's page: the icon, the name and the line, centred, at 440×280.
|
|
44
|
+
*
|
|
45
|
+
* @param {PromoTile} tile - What it shows.
|
|
46
|
+
* @returns {string} A whole HTML document.
|
|
47
|
+
* @example
|
|
48
|
+
* promoTileHtml({ icon: svg, name: 'Better Prices', line: 'Prices in your currency', glow: '#146c2e' });
|
|
49
|
+
*/
|
|
50
|
+
export declare function promoTileHtml({ icon, name, line, glow }: PromoTile): string;
|
|
51
|
+
/**
|
|
52
|
+
* Write an extension's promo tile: its `icons/icon.svg` and English `extShortName` (unless
|
|
53
|
+
* `icon` or `name` is given), drawn by {@link promoTileHtml} in a page of the given browser.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} options - What and where.
|
|
56
|
+
* @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
|
|
57
|
+
* @param {string} options.extension - The extension's folder (`extension`, or `src` where a build bundles it).
|
|
58
|
+
* @param {string} options.file - The PNG to write (its folder is made if missing), e.g. `store/promo-440x280.png`.
|
|
59
|
+
* @param {string} options.line - The line under the name.
|
|
60
|
+
* @param {string} [options.glow] - The glow's colour (see {@link PromoTile}).
|
|
61
|
+
* @param {string} [options.name] - The name, if not the extension's `extShortName`.
|
|
62
|
+
* @param {string} [options.icon] - The icon, if not the extension's `icons/icon.svg`.
|
|
63
|
+
* @returns {Promise<string>} The file written.
|
|
64
|
+
* @example
|
|
65
|
+
* await storePromoTile({ browser, extension: 'extension', file: 'store/promo-440x280.png', line: 'Prices in your currency' });
|
|
66
|
+
*/
|
|
67
|
+
export declare function storePromoTile({ browser, extension, file, line, glow, name, icon }: {
|
|
68
|
+
browser: import('playwright').Browser | import('playwright').BrowserContext;
|
|
69
|
+
extension: string;
|
|
70
|
+
file: string;
|
|
71
|
+
line: string;
|
|
72
|
+
glow?: string;
|
|
73
|
+
name?: string;
|
|
74
|
+
icon?: string;
|
|
75
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Chrome Web Store's small promo tile (440×280) for the whole family, in one style: the
|
|
3
|
+
* extension's icon (drawn from its SVG, so it's sharp at any size) with a soft glow, its name
|
|
4
|
+
* and one short line, on the store screenshots' light background.
|
|
5
|
+
*
|
|
6
|
+
* {@link promoTileHtml} lays it out (pure); {@link storePromoTile} reads the icon and name from
|
|
7
|
+
* the extension folder and writes the PNG.
|
|
8
|
+
*
|
|
9
|
+
* @module build/promo-tile
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { esc } from '../frame/window.js';
|
|
15
|
+
import { THEMES } from './store-frame.js';
|
|
16
|
+
|
|
17
|
+
/** The promo tile's width. */
|
|
18
|
+
export const PROMO_W = 440;
|
|
19
|
+
/** The promo tile's height. */
|
|
20
|
+
export const PROMO_H = 280;
|
|
21
|
+
|
|
22
|
+
/** The glow under the icon when none is given: Chrome's blue. */
|
|
23
|
+
const GLOW = '#0b57d0';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One promo tile.
|
|
27
|
+
*
|
|
28
|
+
* @typedef {object} PromoTile
|
|
29
|
+
* @property {string} icon The icon: SVG markup, or an image's URL (a data: URL, say).
|
|
30
|
+
* @property {string} name The extension's name (`extShortName`).
|
|
31
|
+
* @property {string} line One short line under it, sentence case (“Prices in your currency”).
|
|
32
|
+
* @property {string} [glow] The colour of the glow under the icon, any CSS colour: the icon's own (default Chrome's blue).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The tile's page: the icon, the name and the line, centred, at 440×280.
|
|
37
|
+
*
|
|
38
|
+
* @param {PromoTile} tile - What it shows.
|
|
39
|
+
* @returns {string} A whole HTML document.
|
|
40
|
+
* @example
|
|
41
|
+
* promoTileHtml({ icon: svg, name: 'Better Prices', line: 'Prices in your currency', glow: '#146c2e' });
|
|
42
|
+
*/
|
|
43
|
+
export function promoTileHtml({ icon, name, line, glow = GLOW }) {
|
|
44
|
+
const art = icon.trimStart().startsWith('<') ? icon : `<img src="${icon}" alt="">`;
|
|
45
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
46
|
+
html, body { margin: 0; }
|
|
47
|
+
body { width: ${PROMO_W}px; height: ${PROMO_H}px; overflow: hidden; display: grid; place-items: center; background: linear-gradient(160deg, ${THEMES.light.bg}, #dde3ea); -webkit-font-smoothing: antialiased; font-family: system-ui; }
|
|
48
|
+
.wrap { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
49
|
+
.icon { width: 112px; height: 112px; filter: drop-shadow(0 6px 14px color-mix(in srgb, ${glow} 28%, transparent)); }
|
|
50
|
+
.icon svg, .icon img { display: block; width: 100%; height: 100%; }
|
|
51
|
+
h1 { margin: 0; font: 600 30px/1.1 system-ui; color: #1f1f1f; letter-spacing: -.3px; }
|
|
52
|
+
p { margin: 0; font: 400 17px system-ui; color: #444746; }
|
|
53
|
+
</style></head><body><div class="wrap"><div class="icon">${art}</div><h1>${esc(name)}</h1><p>${esc(line)}</p></div></body></html>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Write an extension's promo tile: its `icons/icon.svg` and English `extShortName` (unless
|
|
58
|
+
* `icon` or `name` is given), drawn by {@link promoTileHtml} in a page of the given browser.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} options - What and where.
|
|
61
|
+
* @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
|
|
62
|
+
* @param {string} options.extension - The extension's folder (`extension`, or `src` where a build bundles it).
|
|
63
|
+
* @param {string} options.file - The PNG to write (its folder is made if missing), e.g. `store/promo-440x280.png`.
|
|
64
|
+
* @param {string} options.line - The line under the name.
|
|
65
|
+
* @param {string} [options.glow] - The glow's colour (see {@link PromoTile}).
|
|
66
|
+
* @param {string} [options.name] - The name, if not the extension's `extShortName`.
|
|
67
|
+
* @param {string} [options.icon] - The icon, if not the extension's `icons/icon.svg`.
|
|
68
|
+
* @returns {Promise<string>} The file written.
|
|
69
|
+
* @example
|
|
70
|
+
* await storePromoTile({ browser, extension: 'extension', file: 'store/promo-440x280.png', line: 'Prices in your currency' });
|
|
71
|
+
*/
|
|
72
|
+
export async function storePromoTile({ browser, extension, file, line, glow, name, icon }) {
|
|
73
|
+
const svg = icon ?? await readFile(path.join(extension, 'icons', 'icon.svg'), 'utf8');
|
|
74
|
+
const title = name ?? JSON.parse(await readFile(path.join(extension, '_locales', 'en', 'messages.json'), 'utf8')).extShortName.message;
|
|
75
|
+
const context = 'newContext' in browser ? await browser.newContext({ viewport: { width: PROMO_W, height: PROMO_H }, deviceScaleFactor: 1 }) : null;
|
|
76
|
+
const page = context ? await context.newPage() : await /** @type {import('playwright').BrowserContext} */ (browser).newPage();
|
|
77
|
+
try {
|
|
78
|
+
await page.setViewportSize({ width: PROMO_W, height: PROMO_H });
|
|
79
|
+
await page.setContent(promoTileHtml({ icon: svg, name: String(title), line, glow }), { waitUntil: 'load' });
|
|
80
|
+
await page.evaluate(() => Promise.all([...document.images].map((img) => img.decode().catch(() => undefined))));
|
|
81
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
82
|
+
await writeFile(file, await page.screenshot({ type: 'png', scale: 'css', clip: { x: 0, y: 0, width: PROMO_W, height: PROMO_H } }));
|
|
83
|
+
return file;
|
|
84
|
+
} finally {
|
|
85
|
+
await (context ?? page).close();
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/build/vendor.js
CHANGED
|
@@ -31,7 +31,7 @@ const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
31
31
|
const KIT = '@arraypress/extension-kit';
|
|
32
32
|
|
|
33
33
|
/** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
|
|
34
|
-
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'ai', 'commands', 'content', 'dom', 'frame', 'panel', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
|
|
34
|
+
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'ai', 'commands', 'content', 'dom', 'files', 'frame', 'net', 'panel', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
|
|
35
35
|
|
|
36
36
|
/** The kit's stylesheet, an absolute path in the installed package. */
|
|
37
37
|
export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saving files a page has made: through Chrome's downloads when the person has allowed it (asked
|
|
3
|
+
* from the Download click: “Manage your downloads”), else as a link's download, which Chrome
|
|
4
|
+
* saves the same way, one file at a time.
|
|
5
|
+
*
|
|
6
|
+
* @module files/save
|
|
7
|
+
*/
|
|
8
|
+
/** How long a file's address is kept once Chrome has it (ms): long enough to finish saving it. */
|
|
9
|
+
export declare const KEEP_URL_MS = 60000;
|
|
10
|
+
export type SavedFile = {
|
|
11
|
+
/**
|
|
12
|
+
* Its name; with `chrome.downloads`, a path under the Downloads folder (`Trips/Lake.jpg`).
|
|
13
|
+
*/
|
|
14
|
+
name: string;
|
|
15
|
+
/**
|
|
16
|
+
* Its contents.
|
|
17
|
+
*/
|
|
18
|
+
blob: Blob;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A file to save.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} SavedFile
|
|
24
|
+
* @property {string} name Its name; with `chrome.downloads`, a path under the Downloads folder (`Trips/Lake.jpg`).
|
|
25
|
+
* @property {Blob} blob Its contents.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Save files, in order. Each gets a blob address, let go after {@link KEEP_URL_MS}. With
|
|
29
|
+
* `chrome.downloads`, a name already taken gets a number (“Lake (1).jpg”) and each download is
|
|
30
|
+
* started before the next; without, Chrome saves each link's download to the Downloads folder
|
|
31
|
+
* (and asks before a page saves several).
|
|
32
|
+
*
|
|
33
|
+
* @param {readonly SavedFile[]} files - The files.
|
|
34
|
+
* @param {Pick<typeof chrome.downloads, 'download'> | undefined} downloads - `chrome.downloads`, once granted (undefined until then).
|
|
35
|
+
* @param {Document} [doc] - The page, for a link's download. Default `document` (not read in a worker, which has `chrome.downloads` or nothing).
|
|
36
|
+
* @returns {Promise<void>} Once Chrome has every file's address.
|
|
37
|
+
* @example
|
|
38
|
+
* await saveFiles([{ name: 'Capture.zip', blob: await zip.finish() }], chrome.downloads);
|
|
39
|
+
*/
|
|
40
|
+
export declare function saveFiles(files: readonly SavedFile[], downloads: Pick<typeof chrome.downloads, 'download'> | undefined, doc?: Document): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saving files a page has made: through Chrome's downloads when the person has allowed it (asked
|
|
3
|
+
* from the Download click: “Manage your downloads”), else as a link's download, which Chrome
|
|
4
|
+
* saves the same way, one file at a time.
|
|
5
|
+
*
|
|
6
|
+
* @module files/save
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** How long a file's address is kept once Chrome has it (ms): long enough to finish saving it. */
|
|
10
|
+
export const KEEP_URL_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A file to save.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {object} SavedFile
|
|
16
|
+
* @property {string} name Its name; with `chrome.downloads`, a path under the Downloads folder (`Trips/Lake.jpg`).
|
|
17
|
+
* @property {Blob} blob Its contents.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Save files, in order. Each gets a blob address, let go after {@link KEEP_URL_MS}. With
|
|
22
|
+
* `chrome.downloads`, a name already taken gets a number (“Lake (1).jpg”) and each download is
|
|
23
|
+
* started before the next; without, Chrome saves each link's download to the Downloads folder
|
|
24
|
+
* (and asks before a page saves several).
|
|
25
|
+
*
|
|
26
|
+
* @param {readonly SavedFile[]} files - The files.
|
|
27
|
+
* @param {Pick<typeof chrome.downloads, 'download'> | undefined} downloads - `chrome.downloads`, once granted (undefined until then).
|
|
28
|
+
* @param {Document} [doc] - The page, for a link's download. Default `document` (not read in a worker, which has `chrome.downloads` or nothing).
|
|
29
|
+
* @returns {Promise<void>} Once Chrome has every file's address.
|
|
30
|
+
* @example
|
|
31
|
+
* await saveFiles([{ name: 'Capture.zip', blob: await zip.finish() }], chrome.downloads);
|
|
32
|
+
*/
|
|
33
|
+
export async function saveFiles(files, downloads, doc = globalThis.document) {
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
const url = URL.createObjectURL(file.blob);
|
|
36
|
+
setTimeout(() => URL.revokeObjectURL(url), KEEP_URL_MS);
|
|
37
|
+
if (downloads) {
|
|
38
|
+
await downloads.download({ url, filename: file.name, conflictAction: 'uniquify' });
|
|
39
|
+
} else {
|
|
40
|
+
const link = doc.createElement('a');
|
|
41
|
+
link.href = url;
|
|
42
|
+
link.download = file.name;
|
|
43
|
+
link.click();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ZIP written a file at a time, with nothing but the platform: each file's
|
|
3
|
+
* bytes go into the ZIP as they're added and from there into a Blob (which
|
|
4
|
+
* Chrome keeps outside the page's memory, on disk once large), so a page
|
|
5
|
+
* never holds the files and the ZIP together. Files already compressed
|
|
6
|
+
* (images, audio, video, PDFs, archives) are stored as they are; the rest
|
|
7
|
+
* (SVG, CSV, plain text) is deflated by `CompressionStream('deflate-raw')`
|
|
8
|
+
* (Chrome 103, Node 20.12). ZIP64 is written when a file, the ZIP or the
|
|
9
|
+
* number of files passes what a plain ZIP can say. Runs anywhere (page,
|
|
10
|
+
* worker, Node).
|
|
11
|
+
*
|
|
12
|
+
* How each entry is written, and why:
|
|
13
|
+
*
|
|
14
|
+
* - **Stored** entries are read twice: once for the CRC-32, then the local
|
|
15
|
+
* header is written with the real CRC and sizes, then the bytes. No data
|
|
16
|
+
* descriptor: a stored entry whose header says "sizes follow the data"
|
|
17
|
+
* can't be read forwards (nothing marks where its data ends), and readers
|
|
18
|
+
* that go by the local headers refuse it or stop there. A Blob is read in
|
|
19
|
+
* slices both times, so a 2 GB video never sits in memory whole.
|
|
20
|
+
* - **Deflated** entries are read once and followed by a data descriptor
|
|
21
|
+
* (general-purpose bit 3), as every streaming ZIP writer does: the
|
|
22
|
+
* compressed size is known only afterwards, and a deflate stream marks its
|
|
23
|
+
* own end, so every reader copes.
|
|
24
|
+
*
|
|
25
|
+
* @module files/zip
|
|
26
|
+
*/
|
|
27
|
+
/** ZIP bytes held in memory before they're handed to the Blob: 16 MiB. */
|
|
28
|
+
export declare const HELD_BYTES: number;
|
|
29
|
+
/** How much of a Blob is read at a time: 4 MiB. A smaller Blob is read whole. */
|
|
30
|
+
export declare const SLICE_BYTES: number;
|
|
31
|
+
/**
|
|
32
|
+
* The CRC-32 of some bytes (ZIP's, PNG's and gzip's), or of more bytes after others: give the
|
|
33
|
+
* CRC so far as `seed` and the result is the CRC of both together.
|
|
34
|
+
*
|
|
35
|
+
* @param {Uint8Array} bytes - The bytes.
|
|
36
|
+
* @param {number} [seed] - The CRC of what came before. Default 0 (nothing).
|
|
37
|
+
* @returns {number} The CRC, 0 to 0xFFFFFFFF.
|
|
38
|
+
* @example
|
|
39
|
+
* crc32(new TextEncoder().encode('123456789')); // 0xCBF43926
|
|
40
|
+
* crc32(rest, crc32(start)); // the CRC of start followed by rest
|
|
41
|
+
*/
|
|
42
|
+
export declare function crc32(bytes: Uint8Array, seed?: number): number;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a file of this name is stored in a ZIP as it is, going by its extension: formats that
|
|
45
|
+
* are compressed already (JPEG, PNG, WebP, MP4, PDF, ZIP, Office documents, WOFF…). Anything else
|
|
46
|
+
* (SVG, CSV, JSON, text, BMP, WAV) is deflated.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} name - The file's name or path.
|
|
49
|
+
* @returns {boolean} True for a compressed format.
|
|
50
|
+
* @example
|
|
51
|
+
* isStoredName('Lake.JPG'); // true
|
|
52
|
+
* isStoredName('icons/logo.svg'); // false
|
|
53
|
+
*/
|
|
54
|
+
export declare function isStoredName(name: string): boolean;
|
|
55
|
+
export type Record = {
|
|
56
|
+
/**
|
|
57
|
+
* Two bytes.
|
|
58
|
+
*/
|
|
59
|
+
u16: (value: number) => Record;
|
|
60
|
+
/**
|
|
61
|
+
* Four bytes.
|
|
62
|
+
*/
|
|
63
|
+
u32: (value: number) => Record;
|
|
64
|
+
/**
|
|
65
|
+
* Eight bytes (a whole number up to 2^53).
|
|
66
|
+
*/
|
|
67
|
+
u64: (value: number) => Record;
|
|
68
|
+
/**
|
|
69
|
+
* Bytes as they are.
|
|
70
|
+
*/
|
|
71
|
+
raw: (part: Uint8Array) => Record;
|
|
72
|
+
/**
|
|
73
|
+
* The record.
|
|
74
|
+
*/
|
|
75
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
76
|
+
};
|
|
77
|
+
export type Entry = {
|
|
78
|
+
/**
|
|
79
|
+
* The name, UTF-8.
|
|
80
|
+
*/
|
|
81
|
+
name: Uint8Array;
|
|
82
|
+
/**
|
|
83
|
+
* Stored (method 0) rather than deflated (method 8, with a data descriptor).
|
|
84
|
+
*/
|
|
85
|
+
stored: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Sizes and offset written as ZIP64.
|
|
88
|
+
*/
|
|
89
|
+
zip64: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Its time.
|
|
92
|
+
*/
|
|
93
|
+
when: {
|
|
94
|
+
time: number;
|
|
95
|
+
day: number;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Where its local header starts.
|
|
99
|
+
*/
|
|
100
|
+
offset: number;
|
|
101
|
+
/**
|
|
102
|
+
* Its CRC-32.
|
|
103
|
+
*/
|
|
104
|
+
crc: number;
|
|
105
|
+
/**
|
|
106
|
+
* Its size.
|
|
107
|
+
*/
|
|
108
|
+
size: number;
|
|
109
|
+
/**
|
|
110
|
+
* Its size in the ZIP.
|
|
111
|
+
*/
|
|
112
|
+
packed: number;
|
|
113
|
+
};
|
|
114
|
+
export type ZipOptions = {
|
|
115
|
+
/**
|
|
116
|
+
* Store every file as it is, deflating none (a ZIP of PNGs whatever
|
|
117
|
+
* their names, or when time matters more than size). Default: by name, see {@link isStoredName}.
|
|
118
|
+
*/
|
|
119
|
+
store?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Write ZIP64 records for every file and for the ZIP's end, however
|
|
122
|
+
* small. Default: only when needed (a file of about 4 GiB or more, a ZIP that has grown past
|
|
123
|
+
* 4 GiB, more than 65,535 files).
|
|
124
|
+
*/
|
|
125
|
+
zip64?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* ZIP bytes held in memory before they're handed to the Blob.
|
|
128
|
+
* Default {@link HELD_BYTES}, 16 MiB.
|
|
129
|
+
*/
|
|
130
|
+
heldBytes?: number;
|
|
131
|
+
/**
|
|
132
|
+
* The files' time in the ZIP (local time, as ZIP keeps it). Default now.
|
|
133
|
+
*/
|
|
134
|
+
date?: Date;
|
|
135
|
+
};
|
|
136
|
+
export type ZipEntryOptions = {
|
|
137
|
+
/**
|
|
138
|
+
* Store this file as it is (true), or go by its name (false) even
|
|
139
|
+
* if the ZIP stores everything.
|
|
140
|
+
*/
|
|
141
|
+
store?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* This file's time.
|
|
144
|
+
*/
|
|
145
|
+
date?: Date;
|
|
146
|
+
};
|
|
147
|
+
export type ZipWriter = {
|
|
148
|
+
/**
|
|
149
|
+
* Put a file in; resolves once it's written. Files are written in the order they're added,
|
|
150
|
+
* one at a time (an `add` made before the last has resolved waits for it). The name is used
|
|
151
|
+
* as it is, `/` between folders; a name already in the ZIP is refused (two files of one name
|
|
152
|
+
* extract as one), as is `add` after `finish`. A file that can't be read rejects and leaves
|
|
153
|
+
* the ZIP as it was before it. Bytes given as a `Uint8Array` are kept, not copied, until
|
|
154
|
+
* they're handed to the Blob: don't write into them again.
|
|
155
|
+
*/
|
|
156
|
+
add: (name: string, data: Blob | Uint8Array, options?: ZipEntryOptions) => Promise<void>;
|
|
157
|
+
/**
|
|
158
|
+
* How many files are in.
|
|
159
|
+
*/
|
|
160
|
+
count: () => number;
|
|
161
|
+
/**
|
|
162
|
+
* Their size in all, uncompressed.
|
|
163
|
+
*/
|
|
164
|
+
bytes: () => number;
|
|
165
|
+
/**
|
|
166
|
+
* Close the ZIP and give it, of type `application/zip` (once).
|
|
167
|
+
*/
|
|
168
|
+
finish: () => Promise<Blob>;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* How a ZIP is written.
|
|
172
|
+
*
|
|
173
|
+
* @typedef {object} ZipOptions
|
|
174
|
+
* @property {boolean} [store] Store every file as it is, deflating none (a ZIP of PNGs whatever
|
|
175
|
+
* their names, or when time matters more than size). Default: by name, see {@link isStoredName}.
|
|
176
|
+
* @property {boolean} [zip64] Write ZIP64 records for every file and for the ZIP's end, however
|
|
177
|
+
* small. Default: only when needed (a file of about 4 GiB or more, a ZIP that has grown past
|
|
178
|
+
* 4 GiB, more than 65,535 files).
|
|
179
|
+
* @property {number} [heldBytes] ZIP bytes held in memory before they're handed to the Blob.
|
|
180
|
+
* Default {@link HELD_BYTES}, 16 MiB.
|
|
181
|
+
* @property {Date} [date] The files' time in the ZIP (local time, as ZIP keeps it). Default now.
|
|
182
|
+
*/
|
|
183
|
+
/**
|
|
184
|
+
* How one file is written, where it differs from the ZIP's own options.
|
|
185
|
+
*
|
|
186
|
+
* @typedef {object} ZipEntryOptions
|
|
187
|
+
* @property {boolean} [store] Store this file as it is (true), or go by its name (false) even
|
|
188
|
+
* if the ZIP stores everything.
|
|
189
|
+
* @property {Date} [date] This file's time.
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* A ZIP being written.
|
|
193
|
+
*
|
|
194
|
+
* @typedef {object} ZipWriter
|
|
195
|
+
* @property {(name: string, data: Blob | Uint8Array, options?: ZipEntryOptions) => Promise<void>} add
|
|
196
|
+
* Put a file in; resolves once it's written. Files are written in the order they're added,
|
|
197
|
+
* one at a time (an `add` made before the last has resolved waits for it). The name is used
|
|
198
|
+
* as it is, `/` between folders; a name already in the ZIP is refused (two files of one name
|
|
199
|
+
* extract as one), as is `add` after `finish`. A file that can't be read rejects and leaves
|
|
200
|
+
* the ZIP as it was before it. Bytes given as a `Uint8Array` are kept, not copied, until
|
|
201
|
+
* they're handed to the Blob: don't write into them again.
|
|
202
|
+
* @property {() => number} count How many files are in.
|
|
203
|
+
* @property {() => number} bytes Their size in all, uncompressed.
|
|
204
|
+
* @property {() => Promise<Blob>} finish Close the ZIP and give it, of type `application/zip` (once).
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* Start a ZIP.
|
|
208
|
+
*
|
|
209
|
+
* @param {ZipOptions} [options] - How to write it.
|
|
210
|
+
* @returns {ZipWriter} The ZIP.
|
|
211
|
+
* @example
|
|
212
|
+
* const zip = createZip();
|
|
213
|
+
* await zip.add('Lake.jpg', blob); // stored
|
|
214
|
+
* await zip.add('notes/Read me.txt', new TextEncoder().encode('Hello')); // deflated
|
|
215
|
+
* const blob = await zip.finish();
|
|
216
|
+
*/
|
|
217
|
+
export declare function createZip(options?: ZipOptions): ZipWriter;
|