@stacksjs/browser-extension 0.70.246 → 0.70.247

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ import type { ExtensionConfig } from './types';
2
+ /**
3
+ * Pixel dimensions of a PNG or JPEG, read from its header.
4
+ *
5
+ * Enough to answer "does the listing already show these images", which is the
6
+ * only reason this file needs to know. Decoding them properly would mean
7
+ * making the whole codec stack a dependency of the publisher, to compare two
8
+ * pairs of integers.
9
+ */
10
+ export declare function imageSize(bytes: Uint8Array): { width: number, height: number } | undefined;
11
+ /**
12
+ * A short-lived HS256 token, which is what AMO's external API takes.
13
+ *
14
+ * Signed here rather than pulled from a JWT library: the payload is four
15
+ * claims and the signature is one HMAC, and `web-ext` — the only other AMO
16
+ * caller in this package — does not expose its own.
17
+ */
18
+ export declare function amoToken(issuer: string, secret: string, now?: number): string;
19
+ /** Read the previews a listing currently shows. */
20
+ export declare function listFirefoxPreviews(addonId: string, token: string): Promise<FirefoxPreview[]>;
21
+ /**
22
+ * Bring the listing's screenshots in line with the config.
23
+ *
24
+ * Skips the whole exchange when the listing already shows the same images at
25
+ * the same sizes in the same order — a publish should not churn a public
26
+ * listing's preview ids just because it ran again.
27
+ */
28
+ export declare function syncFirefoxPreviews(config: ExtensionConfig, options?: FirefoxPreviewSyncOptions): Promise<FirefoxPreviewSyncResult>;
29
+ /**
30
+ * Listing screenshots on addons.mozilla.org.
31
+ *
32
+ * Of the three stores this package publishes to, AMO is the middle case:
33
+ * Apple's API takes screenshots as part of a version's metadata, Chrome's has
34
+ * no endpoint for them at all, and AMO has a writable previews collection that
35
+ * nothing was using. So a Firefox listing kept whatever was dragged into the
36
+ * Developer Hub the first time — for very-good-adblock, two images at mismatched
37
+ * sizes and captions that said "A screenshot showcasing the extension" — while
38
+ * every other surface regenerated from the product.
39
+ *
40
+ * Replacement is upload-then-delete rather than delete-then-upload. Either
41
+ * order is fine when it works; only one of them leaves a public listing with no
42
+ * screenshots at all if the process dies in the middle.
43
+ */
44
+ export declare interface FirefoxPreviewAuth {
45
+ issuer?: string
46
+ secret?: string
47
+ }
48
+ export declare interface FirefoxPreviewSyncOptions extends FirefoxPreviewAuth {
49
+ cwd?: string
50
+ dryRun?: boolean
51
+ }
52
+ export declare interface FirefoxPreview {
53
+ id: number
54
+ position: number
55
+ size: [number, number]
56
+ }
57
+ export declare interface FirefoxPreviewSyncResult {
58
+ unchanged: boolean
59
+ uploaded: FirefoxPreview[]
60
+ removed: number[]
61
+ }
@@ -0,0 +1,100 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, resolve } from "node:path";
4
+ import process from "node:process";
5
+ const AMO_API = "https://addons.mozilla.org/api/v5";
6
+ export function imageSize(bytes) {
7
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
8
+ if (bytes.length > 24 && view.getUint32(0) === 2303741511)
9
+ return { width: view.getUint32(16), height: view.getUint32(20) };
10
+ if (bytes.length > 4 && view.getUint16(0) === 65496) {
11
+ let offset = 2;
12
+ while (offset + 9 < bytes.length) {
13
+ if (view.getUint8(offset) !== 255) {
14
+ offset++;
15
+ continue;
16
+ }
17
+ const marker = view.getUint8(offset + 1);
18
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
19
+ return { height: view.getUint16(offset + 5), width: view.getUint16(offset + 7) };
20
+ offset += 2 + view.getUint16(offset + 2);
21
+ }
22
+ }
23
+ return;
24
+ }
25
+ export function amoToken(issuer, secret, now = Date.now()) {
26
+ const base64url = (value) => Buffer.from(value).toString("base64url"), issuedAt = Math.floor(now / 1000), header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" })), payload = base64url(JSON.stringify({
27
+ iss: issuer,
28
+ jti: randomUUID(),
29
+ iat: issuedAt,
30
+ exp: issuedAt + 240
31
+ })), signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
32
+ return `${header}.${payload}.${signature}`;
33
+ }
34
+ function resolveAuth(options) {
35
+ const issuer = options.issuer ?? process.env.AMO_JWT_ISSUER ?? process.env.WEB_EXT_API_KEY, secret = options.secret ?? process.env.AMO_JWT_SECRET ?? process.env.WEB_EXT_API_SECRET, missing = [!issuer && "AMO_JWT_ISSUER", !secret && "AMO_JWT_SECRET"].filter(Boolean);
36
+ if (missing.length)
37
+ throw Error(`[browser-extension] missing Firefox Add-ons credentials: ${missing.join(", ")}`);
38
+ return { issuer, secret };
39
+ }
40
+ async function amo(path, token, init = {}) {
41
+ const response = await fetch(`${AMO_API}${path}`, {
42
+ ...init,
43
+ headers: { Authorization: `JWT ${token}`, ...init.headers }
44
+ });
45
+ if (!response.ok && response.status !== 404) {
46
+ const detail = await response.text().catch(() => "");
47
+ throw Error(`[browser-extension] AMO ${init.method ?? "GET"} ${path} failed (${response.status})${detail ? `: ${detail.slice(0, 300)}` : ""}`);
48
+ }
49
+ return response;
50
+ }
51
+ export async function listFirefoxPreviews(addonId, token) {
52
+ const response = await amo(`/addons/addon/${encodeURIComponent(addonId)}/`, token);
53
+ if (response.status === 404)
54
+ return [];
55
+ return ((await response.json()).previews ?? []).map((preview, index) => ({
56
+ id: preview.id,
57
+ position: preview.position ?? index,
58
+ size: preview.image_size ?? [0, 0]
59
+ }));
60
+ }
61
+ export async function syncFirefoxPreviews(config, options = {}) {
62
+ const store = config.firefoxAddons ?? {}, screenshots = store.screenshots ?? [];
63
+ if (!screenshots.length)
64
+ return { unchanged: !0, uploaded: [], removed: [] };
65
+ if (!config.geckoId)
66
+ throw Error("[browser-extension] Firefox previews need geckoId in config/extension.ts");
67
+ const cwd = options.cwd ?? process.cwd(), files = await Promise.all(screenshots.map(async (path) => {
68
+ const file = isAbsolute(path) ? path : resolve(cwd, path), bytes = new Uint8Array(await readFile(file).catch(() => {
69
+ throw Error(`[browser-extension] Firefox screenshot not found: ${path}`);
70
+ })), size = imageSize(bytes);
71
+ return { path: file, bytes, width: size?.width ?? 0, height: size?.height ?? 0 };
72
+ })), { issuer, secret } = resolveAuth(options), existing = await listFirefoxPreviews(config.geckoId, amoToken(issuer, secret));
73
+ if (files.every((file) => file.width > 0 && file.height > 0) && existing.length === files.length && existing.slice().sort((a, b) => a.position - b.position).every((preview, index) => preview.size[0] === files[index].width && preview.size[1] === files[index].height))
74
+ return { unchanged: !0, uploaded: [], removed: [] };
75
+ if (options.dryRun)
76
+ return { unchanged: !1, uploaded: [], removed: existing.map((preview) => preview.id) };
77
+ const uploaded = [];
78
+ for (const [index, file] of files.entries()) {
79
+ const form = new FormData;
80
+ form.append("image", new Blob([file.bytes], { type: "image/png" }), basename(file.path));
81
+ form.append("position", String(index));
82
+ const created = await (await amo(`/addons/addon/${encodeURIComponent(config.geckoId)}/previews/`, amoToken(issuer, secret), {
83
+ method: "POST",
84
+ body: form
85
+ })).json(), caption = store.screenshotCaptions?.[index];
86
+ if (caption)
87
+ await amo(`/addons/addon/${encodeURIComponent(config.geckoId)}/previews/${created.id}/`, amoToken(issuer, secret), {
88
+ method: "PATCH",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({ caption: { "en-US": caption } })
91
+ });
92
+ uploaded.push({ id: created.id, position: index, size: [file.width, file.height] });
93
+ }
94
+ const removed = [];
95
+ for (const preview of existing) {
96
+ await amo(`/addons/addon/${encodeURIComponent(config.geckoId)}/previews/${preview.id}/`, amoToken(issuer, secret), { method: "DELETE" });
97
+ removed.push(preview.id);
98
+ }
99
+ return { unchanged: !1, uploaded, removed };
100
+ }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export * from './app-store-submission';
13
13
  export * from './chrome-web-store';
14
14
  export * from './config';
15
15
  export * from './firefox-addons';
16
+ export * from './firefox-previews';
16
17
  export * from './manifest';
17
18
  export * from './package';
18
19
  export * from './safari';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from "./app-store-submission";
4
4
  export * from "./chrome-web-store";
5
5
  export * from "./config";
6
6
  export * from "./firefox-addons";
7
+ export * from "./firefox-previews";
7
8
  export * from "./manifest";
8
9
  export * from "./package";
9
10
  export * from "./safari";
package/dist/types.d.ts CHANGED
@@ -67,6 +67,8 @@ export declare interface FirefoxAddonsConfig {
67
67
  supportEmail?: string
68
68
  requiresPayment?: boolean
69
69
  artifactsDir?: string
70
+ screenshots?: string[]
71
+ screenshotCaptions?: string[]
70
72
  }
71
73
  export declare interface SafariAppStoreReviewContact {
72
74
  firstName: string
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/browser-extension",
3
3
  "type": "module",
4
- "version": "0.70.246",
4
+ "version": "0.70.247",
5
5
  "description": "Build MV3 browser extensions (Chrome, Firefox, Safari) the Stacks way — manifest, content/background scripts, DNR rules, packaging, Safari container app, all config-driven.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [