@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 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.11.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",
@@ -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';
@@ -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
+ }
@@ -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;