From 99d3b2a0d459be1c9e8041132804da84bb48bafa Mon Sep 17 00:00:00 2001 From: jz Date: Fri, 15 May 2026 12:47:14 -0400 Subject: [PATCH 1/3] support for firefox - claude did 2 days of work in 15m? --- .gitignore | 1 + .prettierignore | 1 + eslint.config.js | 12 ++++- package-lock.json | 45 +++++++------------ package.json | 7 ++- scripts/firefox-postbuild.mjs | 31 +++++++++++++ scripts/zip-extension.mjs | 8 ++-- src/background/service-worker.ts | 28 ++++++------ src/content/index.ts | 6 ++- .../panel/components/ComponentDetails.tsx | 3 +- src/content/panel/components/Header.tsx | 3 +- src/content/panel/components/NotesTab.tsx | 3 +- src/content/panel/components/PageInfo.tsx | 3 +- src/content/panel/components/RecentList.tsx | 3 +- .../panel/hooks/useKeyboardShortcuts.ts | 5 ++- src/lib/annotations.ts | 14 +++--- src/lib/recents.ts | 16 +++---- src/lib/screenshot.ts | 3 +- src/lib/storage.ts | 18 ++++---- src/manifest.ts | 19 +++++++- tests/lib/storage.test.ts | 2 +- tests/setup.ts | 11 +++++ tsconfig.json | 2 +- vite.config.ts | 1 + 24 files changed, 157 insertions(+), 88 deletions(-) create mode 100644 scripts/firefox-postbuild.mjs diff --git a/.gitignore b/.gitignore index 6167e65..b17593c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +dist-firefox coverage .DS_Store *.log diff --git a/.prettierignore b/.prettierignore index 31af4f8..26fda03 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ dist +dist-firefox coverage node_modules *.png diff --git a/eslint.config.js b/eslint.config.js index f86fa6e..9a550d5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,17 @@ import prettier from 'eslint-config-prettier'; import globals from 'globals'; export default tseslint.config( - { ignores: ['dist', 'coverage', 'node_modules', '*.config.js', '*.config.ts', 'scripts'] }, + { + ignores: [ + 'dist', + 'dist-firefox', + 'coverage', + 'node_modules', + '*.config.js', + '*.config.ts', + 'scripts', + ], + }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/package-lock.json b/package-lock.json index 66f896b..149965a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "react": "^19.2.6", "react-dom": "^19.2.6", + "webextension-polyfill": "^0.12.0", "zustand": "^5.0.13" }, "devDependencies": { @@ -20,6 +21,7 @@ "@types/node": "^24.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/webextension-polyfill": "^0.12.5", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.6", "eslint": "^9.39.4", @@ -797,9 +799,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +816,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -837,9 +833,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -857,9 +850,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -877,9 +867,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -897,9 +884,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1115,6 +1099,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/webextension-polyfill": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.12.5.tgz", + "integrity": "sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -4140,9 +4131,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4164,9 +4152,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4188,9 +4173,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4212,9 +4194,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6000,6 +5979,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webextension-polyfill": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.12.0.tgz", + "integrity": "sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==", + "license": "MPL-2.0" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/package.json b/package.json index 079dd4a..176afcd 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "build:firefox": "tsc --noEmit && TARGET=firefox vite build && node scripts/firefox-postbuild.mjs", "preview": "vite preview", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix", @@ -24,11 +25,14 @@ "typecheck": "tsc --noEmit", "validate": "npm run typecheck && npm run lint && npm run format:check && npm run test", "zip": "node scripts/zip-extension.mjs", - "release:dry": "npm run validate && npm run build && npm run zip" + "zip:firefox": "TARGET=firefox node scripts/zip-extension.mjs", + "release:dry": "npm run validate && npm run build && npm run zip", + "release:dry:firefox": "npm run validate && npm run build:firefox && npm run zip:firefox" }, "dependencies": { "react": "^19.2.6", "react-dom": "^19.2.6", + "webextension-polyfill": "^0.12.0", "zustand": "^5.0.13" }, "devDependencies": { @@ -38,6 +42,7 @@ "@types/node": "^24.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/webextension-polyfill": "^0.12.5", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.6", "eslint": "^9.39.4", diff --git a/scripts/firefox-postbuild.mjs b/scripts/firefox-postbuild.mjs new file mode 100644 index 0000000..1c33c10 --- /dev/null +++ b/scripts/firefox-postbuild.mjs @@ -0,0 +1,31 @@ +// Firefox does not yet enable `background.service_worker` in shipping +// builds (the flag is off by default), but crxjs only emits that field. +// After the Firefox build, rewrite the manifest to use the MV3-compatible +// `background.scripts` form pointing at the same loader. + +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const manifestPath = join(root, 'dist-firefox', 'manifest.json'); + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +const loader = manifest.background?.service_worker; +if (!loader) { + console.error('✖ dist-firefox/manifest.json has no background.service_worker to rewrite.'); + process.exit(1); +} + +manifest.background = { scripts: [loader], type: 'module' }; + +// `use_dynamic_url` is Chrome-only (CRX-7173); Firefox logs a manifest +// warning if it sees it. Strip from each web_accessible_resources entry. +if (Array.isArray(manifest.web_accessible_resources)) { + for (const entry of manifest.web_accessible_resources) { + delete entry.use_dynamic_url; + } +} + +await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); +console.log(`✓ rewrote background.service_worker → background.scripts (${loader})`); diff --git a/scripts/zip-extension.mjs b/scripts/zip-extension.mjs index 4b98cc4..9a7e19c 100644 --- a/scripts/zip-extension.mjs +++ b/scripts/zip-extension.mjs @@ -36,9 +36,10 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = dirname(dirname(fileURLToPath(import.meta.url))); -const distDir = join(root, 'dist'); +const isFirefox = process.env.TARGET === 'firefox'; +const distDir = join(root, isFirefox ? 'dist-firefox' : 'dist'); const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); -const outName = `clay-slip-v${pkg.version}.zip`; +const outName = `clay-slip-v${pkg.version}${isFirefox ? '-firefox' : ''}.zip`; const outPath = join(root, outName); const includeMaps = process.env.INCLUDE_SOURCEMAPS === '1'; @@ -149,8 +150,9 @@ function buildPowerShellCommand(zipName, keepMaps) { const filters = ["$_.Name -ne '.DS_Store'", "$_.FullName -notmatch '__MACOSX'"]; if (!keepMaps) filters.push("$_.Name -notlike '*.map'"); const where = filters.join(' -and '); + const srcDir = isFirefox ? 'dist-firefox' : 'dist'; return ` - $items = Get-ChildItem -Path 'dist' -Recurse -File | Where-Object { ${where} }; + $items = Get-ChildItem -Path '${srcDir}' -Recurse -File | Where-Object { ${where} }; Compress-Archive -Path $items.FullName -DestinationPath '${zipName}' -Force `.trim(); } diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index a7f0f7c..64d734f 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -1,3 +1,4 @@ +import browser from 'webextension-polyfill'; import type { CaptureResponse, RuntimeMessage } from '@/lib/types'; // Toolbar badge color — matches the unified inspector accent @@ -8,8 +9,8 @@ import type { CaptureResponse, RuntimeMessage } from '@/lib/types'; const BADGE_BG = '#60a5fa'; const POPUP_PATH = 'src/popup/index.html'; -chrome.runtime.onInstalled.addListener(() => { - chrome.action.setBadgeBackgroundColor({ color: BADGE_BG }); +browser.runtime.onInstalled.addListener(() => { + browser.action.setBadgeBackgroundColor({ color: BADGE_BG }); }); /** @@ -18,10 +19,10 @@ chrome.runtime.onInstalled.addListener(() => { * Clay page; otherwise the popup stays active and the icon click shows the * "Not a Clay page" message. */ -chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { +browser.tabs.onUpdated.addListener((tabId, changeInfo) => { if (changeInfo.status === 'loading') { - chrome.action.setPopup({ tabId, popup: POPUP_PATH }).catch(() => undefined); - chrome.action.setBadgeText({ tabId, text: '' }).catch(() => undefined); + browser.action.setPopup({ tabId, popup: POPUP_PATH }).catch(() => undefined); + browser.action.setBadgeText({ tabId, text: '' }).catch(() => undefined); } }); @@ -29,22 +30,23 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { * On Clay pages the popup is disabled (so this handler fires); on non-Clay * pages the default popup shows automatically and this never runs. */ -chrome.action.onClicked.addListener((tab) => { +browser.action.onClicked.addListener((tab) => { if (!tab.id) return; - chrome.tabs.sendMessage(tab.id, { type: 'PANEL_TOGGLE' } satisfies RuntimeMessage).catch(() => { + browser.tabs.sendMessage(tab.id, { type: 'PANEL_TOGGLE' } satisfies RuntimeMessage).catch(() => { // Content script not loaded (e.g. chrome:// pages). Ignore. }); }); -chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => { +browser.runtime.onMessage.addListener((rawMessage, sender, sendResponse) => { + const message = rawMessage as RuntimeMessage; switch (message.type) { case 'OPEN_TAB': { - chrome.tabs.create({ url: message.url, active: true }); + browser.tabs.create({ url: message.url, active: true }); sendResponse({ ok: true }); break; } case 'OPEN_OPTIONS': { - chrome.runtime.openOptionsPage().catch(() => undefined); + browser.runtime.openOptionsPage().catch(() => undefined); sendResponse({ ok: true }); break; } @@ -52,7 +54,7 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendRespo const tabId = message.tabId ?? sender.tab?.id; if (typeof tabId === 'number') { const text = message.count > 0 ? String(message.count) : ''; - chrome.action.setBadgeText({ text, tabId }).catch(() => undefined); + browser.action.setBadgeText({ text, tabId }).catch(() => undefined); } sendResponse({ ok: true }); break; @@ -60,7 +62,7 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendRespo case 'CLAY_DETECTED': { const tabId = sender.tab?.id; if (typeof tabId === 'number') { - chrome.action.setPopup({ tabId, popup: '' }).catch(() => undefined); + browser.action.setPopup({ tabId, popup: '' }).catch(() => undefined); } sendResponse({ ok: true }); break; @@ -71,7 +73,7 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendRespo sendResponse({ ok: false, error: 'No window id' } satisfies CaptureResponse); break; } - chrome.tabs + browser.tabs .captureVisibleTab(windowId, { format: 'png' }) .then((dataUrl) => sendResponse({ ok: true, dataUrl } satisfies CaptureResponse)) .catch((err: unknown) => diff --git a/src/content/index.ts b/src/content/index.ts index ab2f982..97ed142 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,3 +1,4 @@ +import browser from 'webextension-polyfill'; import { isClayDocument, isEditMode, parseShareTarget } from '@/lib/clay-uri'; import type { RuntimeMessage } from '@/lib/types'; import { @@ -12,7 +13,7 @@ import { isPanelMounted, mountPanel, unmountPanel } from './shadow-host'; import { useStore } from './panel/store'; function send(message: RuntimeMessage): void { - chrome.runtime.sendMessage(message).catch(() => undefined); + browser.runtime.sendMessage(message).catch(() => undefined); } /** @@ -125,7 +126,8 @@ function bootstrap(): void { } } -chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => { +browser.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => { + const message = rawMessage as RuntimeMessage; if (message.type === 'PANEL_TOGGLE') { if (!isClayDocument()) { sendResponse({ ok: false, reason: 'not-clay' }); diff --git a/src/content/panel/components/ComponentDetails.tsx b/src/content/panel/components/ComponentDetails.tsx index b2edf71..a38da6f 100644 --- a/src/content/panel/components/ComponentDetails.tsx +++ b/src/content/panel/components/ComponentDetails.tsx @@ -1,3 +1,4 @@ +import browser from 'webextension-polyfill'; import { buildCurlCommand, buildSchemaUrl, @@ -43,7 +44,7 @@ export function ComponentDetails() { } const open = (url: string) => { - chrome.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); + browser.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); }; const screenshot = async () => { diff --git a/src/content/panel/components/Header.tsx b/src/content/panel/components/Header.tsx index 543d281..c504dc0 100644 --- a/src/content/panel/components/Header.tsx +++ b/src/content/panel/components/Header.tsx @@ -1,4 +1,5 @@ import type { Ref } from 'react'; +import browser from 'webextension-polyfill'; import clayIconUrl from '@/assets/clay-icon.png?inline'; import type { RuntimeMessage } from '@/lib/types'; import { Icon } from './Icon'; @@ -16,7 +17,7 @@ export function Header({ ref }: HeaderProps) { const componentCount = useStore((s) => s.components.length); const openOptions = () => { - chrome.runtime + browser.runtime .sendMessage({ type: 'OPEN_OPTIONS' } satisfies RuntimeMessage) .catch(() => undefined); }; diff --git a/src/content/panel/components/NotesTab.tsx b/src/content/panel/components/NotesTab.tsx index 88743fa..9538196 100644 --- a/src/content/panel/components/NotesTab.tsx +++ b/src/content/panel/components/NotesTab.tsx @@ -1,3 +1,4 @@ +import browser from 'webextension-polyfill'; import type { RuntimeMessage } from '@/lib/types'; import { deleteAnnotation } from '@/lib/annotations'; import { useStore } from '../store'; @@ -20,7 +21,7 @@ export function NotesTab() { } const open = (url: string) => { - chrome.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); + browser.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); }; return ( diff --git a/src/content/panel/components/PageInfo.tsx b/src/content/panel/components/PageInfo.tsx index 2f12755..f358de0 100644 --- a/src/content/panel/components/PageInfo.tsx +++ b/src/content/panel/components/PageInfo.tsx @@ -1,3 +1,4 @@ +import browser from 'webextension-polyfill'; import { buildEditorUrl, buildUrl, unpublishedUri } from '@/lib/clay-uri'; import { findMappingForHost, rewriteUrlToEnv } from '@/lib/site-host'; import { SITE_ENV_LABELS, SITE_ENV_ORDER, type RuntimeMessage } from '@/lib/types'; @@ -12,7 +13,7 @@ export function PageInfo() { if (!page) return null; const open = (url: string) => { - chrome.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); + browser.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); }; // No envHost override: the helpers use the URI's embedded host, which diff --git a/src/content/panel/components/RecentList.tsx b/src/content/panel/components/RecentList.tsx index 3afb52e..afdc215 100644 --- a/src/content/panel/components/RecentList.tsx +++ b/src/content/panel/components/RecentList.tsx @@ -1,3 +1,4 @@ +import browser from 'webextension-polyfill'; import type { RuntimeMessage } from '@/lib/types'; import { useStore } from '../store'; import { setSelected } from '../../highlighter'; @@ -21,7 +22,7 @@ export function RecentList() { if (recents.length === 0) return null; const open = (url: string) => { - chrome.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); + browser.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); }; const visible = recents.slice(0, MAX_VISIBLE); diff --git a/src/content/panel/hooks/useKeyboardShortcuts.ts b/src/content/panel/hooks/useKeyboardShortcuts.ts index 0bb21bd..96dcb4e 100644 --- a/src/content/panel/hooks/useKeyboardShortcuts.ts +++ b/src/content/panel/hooks/useKeyboardShortcuts.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import browser from 'webextension-polyfill'; import { copyToClipboard } from '@/lib/clipboard'; import { buildUrl, ensureProtocol } from '@/lib/clay-uri'; import type { RuntimeMessage } from '@/lib/types'; @@ -90,14 +91,14 @@ export function useKeyboardShortcuts(): void { // No host override: buildUrl uses the URI's embedded host, which // is the page's actual host. Cross-env nav is handled by the // "View on…" pills (siteHosts) and the Diff tab. - chrome.runtime.sendMessage({ + browser.runtime.sendMessage({ type: 'OPEN_TAB', url: buildUrl(page.pageUri, ''), } satisfies RuntimeMessage); } else if (combo === 'oc' && (selected || page)) { const uri = selected?.uri ?? page?.pageUri; if (uri) { - chrome.runtime.sendMessage({ + browser.runtime.sendMessage({ type: 'OPEN_TAB', url: buildUrl(uri, ''), } satisfies RuntimeMessage); diff --git a/src/lib/annotations.ts b/src/lib/annotations.ts index 866595f..5e995f6 100644 --- a/src/lib/annotations.ts +++ b/src/lib/annotations.ts @@ -1,3 +1,4 @@ +import browser, { type Storage } from 'webextension-polyfill'; import type { Annotation } from './types'; const STORAGE_KEY = 'annotations'; @@ -5,12 +6,12 @@ const STORAGE_KEY = 'annotations'; type AnnotationMap = Record; async function loadMap(): Promise { - const stored = await chrome.storage.local.get(STORAGE_KEY); + const stored = await browser.storage.local.get(STORAGE_KEY); return (stored[STORAGE_KEY] as AnnotationMap | undefined) ?? {}; } async function saveMap(map: AnnotationMap): Promise { - await chrome.storage.local.set({ [STORAGE_KEY]: map }); + await browser.storage.local.set({ [STORAGE_KEY]: map }); } export async function listAnnotations(): Promise { @@ -48,15 +49,12 @@ export async function deleteAnnotation(uri: string): Promise { * etc.). Listener fires with the current full list. */ export function onAnnotationsChanged(listener: (next: Annotation[]) => void): () => void { - const handler = ( - changes: { [key: string]: chrome.storage.StorageChange }, - areaName: chrome.storage.AreaName - ) => { + const handler = (changes: Record, areaName: string) => { if (areaName !== 'local') return; if (!(STORAGE_KEY in changes)) return; const next = (changes[STORAGE_KEY]?.newValue as AnnotationMap | undefined) ?? {}; listener(Object.values(next).sort((a, b) => b.updatedAt - a.updatedAt)); }; - chrome.storage.onChanged.addListener(handler); - return () => chrome.storage.onChanged.removeListener(handler); + browser.storage.onChanged.addListener(handler); + return () => browser.storage.onChanged.removeListener(handler); } diff --git a/src/lib/recents.ts b/src/lib/recents.ts index bc6c94d..73caf2e 100644 --- a/src/lib/recents.ts +++ b/src/lib/recents.ts @@ -1,10 +1,11 @@ +import browser, { type Storage } from 'webextension-polyfill'; import type { RecentComponent } from './types'; const STORAGE_KEY = 'recentComponents'; const HARD_CAP = 100; export async function loadRecents(): Promise { - const stored = await chrome.storage.local.get(STORAGE_KEY); + const stored = await browser.storage.local.get(STORAGE_KEY); const list = stored[STORAGE_KEY] as RecentComponent[] | undefined; return list ?? []; } @@ -17,24 +18,21 @@ export async function pushRecent(entry: RecentComponent, cap = 20): Promise r.uri !== entry.uri); const next = [entry, ...filtered].slice(0, Math.min(cap, HARD_CAP)); - await chrome.storage.local.set({ [STORAGE_KEY]: next }); + await browser.storage.local.set({ [STORAGE_KEY]: next }); return next; } export async function clearRecents(): Promise { - await chrome.storage.local.remove(STORAGE_KEY); + await browser.storage.local.remove(STORAGE_KEY); } export function onRecentsChanged(listener: (next: RecentComponent[]) => void): () => void { - const handler = ( - changes: { [key: string]: chrome.storage.StorageChange }, - areaName: chrome.storage.AreaName - ) => { + const handler = (changes: Record, areaName: string) => { if (areaName !== 'local') return; if (!(STORAGE_KEY in changes)) return; const next = (changes[STORAGE_KEY]?.newValue as RecentComponent[] | undefined) ?? []; listener(next); }; - chrome.storage.onChanged.addListener(handler); - return () => chrome.storage.onChanged.removeListener(handler); + browser.storage.onChanged.addListener(handler); + return () => browser.storage.onChanged.removeListener(handler); } diff --git a/src/lib/screenshot.ts b/src/lib/screenshot.ts index 070780d..2a98e65 100644 --- a/src/lib/screenshot.ts +++ b/src/lib/screenshot.ts @@ -1,3 +1,4 @@ +import browser from 'webextension-polyfill'; import type { CaptureResponse, RuntimeMessage } from './types'; /** @@ -23,7 +24,7 @@ export async function captureElementToClipboard( await new Promise((r) => requestAnimationFrame(r)); try { - const response = (await chrome.runtime.sendMessage({ + const response = (await browser.runtime.sendMessage({ type: 'CAPTURE_TAB', } satisfies RuntimeMessage)) as CaptureResponse; diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 54ddb02..ac98dd9 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,29 +1,27 @@ +import browser, { type Storage } from 'webextension-polyfill'; import { DEFAULT_PREFERENCES, type UserPreferences } from './types'; const PREFS_KEY = 'preferences'; export async function loadPreferences(): Promise { - if (!chrome?.storage?.sync) return DEFAULT_PREFERENCES; - const stored = await chrome.storage.sync.get(PREFS_KEY); + if (!browser?.storage?.sync) return DEFAULT_PREFERENCES; + const stored = await browser.storage.sync.get(PREFS_KEY); return { ...DEFAULT_PREFERENCES, ...(stored[PREFS_KEY] ?? {}) }; } export async function savePreferences(prefs: Partial): Promise { - if (!chrome?.storage?.sync) return; + if (!browser?.storage?.sync) return; const current = await loadPreferences(); const merged = { ...current, ...prefs }; - await chrome.storage.sync.set({ [PREFS_KEY]: merged }); + await browser.storage.sync.set({ [PREFS_KEY]: merged }); } export function onPreferencesChanged(cb: (prefs: UserPreferences) => void): () => void { - const listener = ( - changes: { [key: string]: chrome.storage.StorageChange }, - area: chrome.storage.AreaName - ) => { + const listener = (changes: Record, area: string) => { if (area === 'sync' && changes[PREFS_KEY]) { cb({ ...DEFAULT_PREFERENCES, ...(changes[PREFS_KEY].newValue ?? {}) }); } }; - chrome.storage?.onChanged.addListener(listener); - return () => chrome.storage?.onChanged.removeListener(listener); + browser.storage?.onChanged.addListener(listener); + return () => browser.storage?.onChanged.removeListener(listener); } diff --git a/src/manifest.ts b/src/manifest.ts index 6122be6..f05c9c8 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -11,13 +11,30 @@ if (pkg.description.length > MAX_DESCRIPTION_CHARS) { ); } +const isFirefox = process.env.TARGET === 'firefox'; + +// Firefox needs a gecko block for installable signing. Service worker +// background scripts require Firefox 121+; below that, MV3 extensions +// must use `background.scripts` instead, which crxjs doesn't emit. +const firefoxExtras = isFirefox + ? { + browser_specific_settings: { + gecko: { + id: 'clay-slip@slate.com', + strict_min_version: '121.0', + data_collection_permissions: { required: ['none' as const] }, + }, + }, + } + : { minimum_chrome_version: '116' }; + export default defineManifest({ manifest_version: 3, name: 'Clay Slip', short_name: 'Slip', version: pkg.version, description: pkg.description, - minimum_chrome_version: '116', + ...firefoxExtras, icons: { 16: 'icons/icon-16.png', diff --git a/tests/lib/storage.test.ts b/tests/lib/storage.test.ts index b50b171..e28f851 100644 --- a/tests/lib/storage.test.ts +++ b/tests/lib/storage.test.ts @@ -19,7 +19,7 @@ interface MockChrome { } function getMockChrome(): MockChrome { - return globalThis.chrome as unknown as MockChrome; + return (globalThis as { chrome?: unknown }).chrome as MockChrome; } beforeEach(() => { diff --git a/tests/setup.ts b/tests/setup.ts index 7c95589..68a0a42 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,16 @@ import { afterEach, vi } from 'vitest'; +// Forward `import browser from 'webextension-polyfill'` to whatever each test +// sets on `globalThis.chrome`. The real polyfill throws at import time outside +// an extension context, which would break test files that pull in storage/ +// runtime helpers transitively. +vi.mock('webextension-polyfill', () => ({ + default: new Proxy( + {}, + { get: (_t, ns: string) => (globalThis as { chrome?: Record }).chrome?.[ns] } + ), +})); + afterEach(() => { vi.restoreAllMocks(); document.documentElement.removeAttribute('data-uri'); diff --git a/tsconfig.json b/tsconfig.json index 6234b7b..449971b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "isolatedModules": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["chrome", "node", "vite/client", "vitest/globals"], + "types": ["node", "vite/client", "vitest/globals"], "paths": { "@/*": ["./src/*"] } diff --git a/vite.config.ts b/vite.config.ts index f6dcd30..db9dbfa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ build: { target: 'esnext', sourcemap: true, + outDir: process.env.TARGET === 'firefox' ? 'dist-firefox' : 'dist', rollupOptions: { input: { popup: 'src/popup/index.html', From dcab1d83dd7e96dd31b470a39fbdd0a68661dea2 Mon Sep 17 00:00:00 2001 From: Jordan Paulino Date: Fri, 15 May 2026 14:30:31 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8D=95=20Polish=20Firefox=20support:?= =?UTF-8?q?=20tests,=20CI,=20docs,=20target-aware=20tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original commit got Firefox loading end-to-end (polyfill, gecko block, postbuild rewrite). This pass shores up everything around it so the second target is a first-class citizen, not a side experiment. Code / build: - Extract the Firefox manifest rewrite into a pure helper at `scripts/firefox-manifest.mjs` (in-place mutation that takes the Chromium-shaped manifest and emits the Firefox shape). `firefox-postbuild.mjs` is now a thin file-IO wrapper around it. Postbuild also throws loudly if `background.service_worker` is missing — a silent no-op there would ship a broken Firefox build. - `src/manifest.ts`: rewrite the comments to explain WHY each branch field exists (gecko id, strict_min_version=121.0, data_collection_permissions=['none'], minimum_chrome_version=116) and why we cap manifest description at 132 chars (CWS + AMO). - `scripts/zip-extension.mjs`: · Branch the "Next steps" output by target — Chromium gets "Open chrome://extensions / Load unpacked", Firefox gets "Open about:debugging / Load Temporary Add-on" + the temporary install caveat + the Developer-Edition-with-signatures-off path. · Verify-manifest-at-root error message now mentions both stores and both sideload flows, and points at the right rebuild command for the failing target. - `.github/workflows/release.yml`: build + zip BOTH targets and attach both to the draft GitHub release. CI now runs the same `npm run zip` / `npm run zip:firefox` scripts as locally so the manifest-at-root verification + sourcemap stripping run in CI. Tests (+9, total now 176): - `tests/scripts/firefox-manifest.test.ts` (5 tests): rewrite swaps service_worker → scripts (with type=module preserved); throws when there's no service_worker to rewrite; strips use_dynamic_url from every WAR entry; no-ops on missing WAR; mutates in place and returns the same reference. - `tests/manifest.test.ts` (4 tests): default build adds minimum_chrome_version + omits gecko; TARGET=firefox adds the full gecko block + omits minimum_chrome_version (mutual exclusion); shared MV3 fields (name, version, permissions, host_permissions, icons, description) stay identical between targets; description respects the 132-char store ceiling. Docs: - `README.md`: full Firefox install/update section parallel to the existing Chromium one — both Option A (temporary add-on, any FF 121+, resets on restart) and Option B (persistent on Developer Edition / Nightly with signatures off + .zip → .xpi rename). Troubleshooting table gains the Firefox "extension appears to be corrupt" row. Scripts table covers the Firefox build/zip/release-dry commands. Releasing section explains the dual-target CI flow. Architecture comment notes manifest.ts's TARGET branching. - `PRIVACY.md`: WebExtension storage terminology now spans Chromium + Firefox (chrome.storage / browser.storage), Screenshot permission justification mentions both APIs, remote- code section mentions both build commands. - Top-of-file comments in `firefox-manifest.mjs`, `firefox-postbuild.mjs`, and `zip-extension.mjs` document every rewrite, every fallback path, and the temporary-vs- persistent install matrix. Verified: validate (typecheck/lint/format/176 tests) green; both `npm run release:dry` and `npm run release:dry:firefox` produce zips with the right manifest shape (verified service_worker vs scripts, gecko presence, minimum_chrome_version mutual exclusion, no use_dynamic_url leftovers in the Firefox build). Co-authored-by: Cursor --- .github/workflows/release.yml | 59 +++++++----- PRIVACY.md | 28 +++--- README.md | 124 +++++++++++++++++-------- scripts/firefox-manifest.mjs | 66 +++++++++++++ scripts/firefox-postbuild.mjs | 38 ++++---- scripts/zip-extension.mjs | 58 ++++++++---- src/manifest.ts | 35 +++++-- tests/manifest.test.ts | 106 +++++++++++++++++++++ tests/scripts/firefox-manifest.test.ts | 92 ++++++++++++++++++ 9 files changed, 491 insertions(+), 115 deletions(-) create mode 100644 scripts/firefox-manifest.mjs create mode 100644 tests/manifest.test.ts create mode 100644 tests/scripts/firefox-manifest.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c648d3..6d49f38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,12 +6,14 @@ name: Release # What it does: # 1. Verifies the tag matches `package.json`'s version (fails fast otherwise). # 2. Runs the full validation suite (typecheck / lint / format / test). -# 3. Builds the extension into `dist/`. -# 4. Zips `dist/` as `clay-slip-vX.Y.Z.zip` with `manifest.json` at the -# root (so users can drop the unzipped folder straight into Chrome's -# "Load unpacked"; the same layout the Chrome Web Store would expect -# if we ever published there). -# 5. Creates a *draft* GitHub release with the zip attached and auto- +# 3. Builds the extension twice — once for Chromium-family browsers +# (Chrome / Edge / Brave / Arc / Vivaldi / Opera) into `dist/`, and +# once for Firefox into `dist-firefox/` — and zips each as +# `clay-slip-vX.Y.Z.zip` (Chromium) and +# `clay-slip-vX.Y.Z-firefox.zip` with `manifest.json` at the root +# so users can sideload directly ("Load unpacked" on Chromium, +# "Load Temporary Add-on" on Firefox). +# 4. Creates a *draft* GitHub release with both zips attached and auto- # generated release notes. A human reviews and publishes it. on: @@ -58,10 +60,12 @@ jobs: echo "::error title=Version mismatch::Tag $TAG does not match package.json version $PKG_VERSION. Bump package.json and re-tag." exit 1 fi - ZIP="clay-slip-${TAG}.zip" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "zip=$ZIP" >> "$GITHUB_OUTPUT" + ZIP_CHROMIUM="clay-slip-${TAG}.zip" + ZIP_FIREFOX="clay-slip-${TAG}-firefox.zip" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "zip_chromium=$ZIP_CHROMIUM" >> "$GITHUB_OUTPUT" + echo "zip_firefox=$ZIP_FIREFOX" >> "$GITHUB_OUTPUT" - name: Install dependencies run: npm ci @@ -69,21 +73,33 @@ jobs: - name: Validate (typecheck / lint / format / test) run: npm run validate - - name: Build + # We use the project's own zip script for both targets so the + # manifest-at-root check + sourcemap stripping run in CI exactly + # as they do locally with `npm run release:dry[:firefox]`. + - name: Build Chromium extension run: npm run build - - name: Package extension - run: | - cd dist - zip -r "../${{ steps.meta.outputs.zip }}" . - cd .. - ls -lh "${{ steps.meta.outputs.zip }}" + - name: Package Chromium extension + run: npm run zip + + - name: Build Firefox extension + run: npm run build:firefox + + - name: Package Firefox extension + run: npm run zip:firefox + + - name: Upload Chromium zip as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.meta.outputs.zip_chromium }} + path: ${{ steps.meta.outputs.zip_chromium }} + retention-days: 30 - - name: Upload zip as workflow artifact + - name: Upload Firefox zip as workflow artifact uses: actions/upload-artifact@v4 with: - name: ${{ steps.meta.outputs.zip }} - path: ${{ steps.meta.outputs.zip }} + name: ${{ steps.meta.outputs.zip_firefox }} + path: ${{ steps.meta.outputs.zip_firefox }} retention-days: 30 - name: Create draft GitHub release @@ -91,7 +107,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ steps.meta.outputs.tag }}" \ - "${{ steps.meta.outputs.zip }}" \ + "${{ steps.meta.outputs.zip_chromium }}" \ + "${{ steps.meta.outputs.zip_firefox }}" \ --draft \ --generate-notes \ --title "Clay Slip ${{ steps.meta.outputs.tag }}" diff --git a/PRIVACY.md b/PRIVACY.md index da1c3e5..7f75b23 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -14,20 +14,20 @@ This document is the canonical privacy disclosure for the extension. It's distri - **No remote code.** All JavaScript is bundled at build time. The extension never loads scripts from the network. - **No accounts.** The extension does not have any concept of "user" or "session"; nothing is signed in. - **No third-party endpoints.** The only network calls the extension makes are to the same Clay site you are already viewing. -- **No data leaves your device.** Preferences and notes are stored using `chrome.storage`, which keeps them on your machine (or, for `sync` storage, in your own Google account). The Clay Slip developers never see them. +- **No data leaves your device.** Preferences and notes are stored using the standard WebExtension `storage` APIs — `chrome.storage` on Chromium, `browser.storage` on Firefox — which keep them on your machine (or, for `sync` storage on Chrome, in your own Google account; on Firefox, in your own Mozilla account if Sync is enabled). The Clay Slip developers never see them. --- ## What the extension stores locally -Clay Slip uses the standard Chrome storage APIs. Stored data never leaves the user's device or Google account. +Clay Slip uses the standard WebExtension storage APIs (`chrome.storage` on Chromium, `browser.storage` on Firefox — same shape, same data, same guarantees). Stored data never leaves the user's device or browser-vendor account. -| Storage area | Contents | Why | -| ---------------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `chrome.storage.sync` | UI preferences (theme, panel position/size, site host mappings, highlight mode + intensity, shortcut toggle) | Carries your settings across browsers when you're signed in to Chrome. | -| `chrome.storage.local` | Sticky-note annotations pinned to component URIs; "recently viewed components" history (capped, configurable) | Keeps notes and history available offline; not synced because they may include page-specific context. | +| Storage area | Contents | Why | +| --------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `storage.sync` | UI preferences (theme, panel position/size, site host mappings, highlight mode + intensity, shortcut toggle) | Carries your settings across browsers when you're signed in to Chrome / Firefox Sync. | +| `storage.local` | Sticky-note annotations pinned to component URIs; "recently viewed components" history (capped, configurable) | Keeps notes and history available offline; not synced because they may include page-specific context. | -You can clear everything from the extension's **Options** page (Reset preferences, Clear history) or via Chrome → _Manage extensions_ → _Site access / storage_. +You can clear everything from the extension's **Options** page (Reset preferences, Clear history) or via your browser's _Manage extensions_ → _Site access / storage_ controls (Chromium) or `about:addons` → Clay Slip → _Remove_ (Firefox). --- @@ -59,12 +59,12 @@ All of these requests target the Clay site you are already browsing (or another ## Permissions and why each is requested -| Permission | Why it's requested | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `activeTab` | Used by the Screenshot feature: when you click _Screenshot_ on a selected component, the service worker calls `chrome.tabs.captureVisibleTab` and crops the result to the component's bounding box. The PNG is written to your clipboard and discarded — never uploaded. | -| `storage` | Persists the user-controlled state described in the table above. Local-only. | -| `clipboardWrite` | Implements the panel's _Copy URI_, _Copy as cURL/fetch()/CSS_, _Share_, _Export_, and _Screenshot_ actions. Each clipboard write is initiated by an explicit user click. | -| `` | The content script must run on every page so it can detect Clay-rendered pages by reading the `data-uri` attribute on ``. On non-Clay pages the extension exits immediately without reading or modifying anything else. | +| Permission | Why it's requested | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `activeTab` | Used by the Screenshot feature: when you click _Screenshot_ on a selected component, the service worker calls `tabs.captureVisibleTab` (the cross-browser equivalent — `chrome.tabs.captureVisibleTab` on Chromium, `browser.tabs.captureVisibleTab` on Firefox) and crops the result to the component's bounding box. The PNG is written to your clipboard and discarded — never uploaded. | +| `storage` | Persists the user-controlled state described in the table above. Local-only. | +| `clipboardWrite` | Implements the panel's _Copy URI_, _Copy as cURL/fetch()/CSS_, _Share_, _Export_, and _Screenshot_ actions. Each clipboard write is initiated by an explicit user click. | +| `` | The content script must run on every page so it can detect Clay-rendered pages by reading the `data-uri` attribute on ``. On non-Clay pages the extension exits immediately without reading or modifying anything else. | The extension does **not** request `cookies`, `webRequest`, `webNavigation`, `history`, `bookmarks`, `identity`, `notifications`, `geolocation`, or any other sensitive permission. @@ -74,7 +74,7 @@ The extension does **not** request `cookies`, `webRequest`, `webNavigation`, `hi Clay Slip does **not** execute remote code. -- All JavaScript ships in the `.zip` attached to each [GitHub Release](https://github.com/clay/clay-devtools/releases), bundled at build time by Vite/Rollup. Anyone can verify by checking out the matching `vX.Y.Z` tag and rebuilding with `npm install && npm run build`. +- All JavaScript ships in the `.zip` files attached to each [GitHub Release](https://github.com/clay/clay-devtools/releases) — one for Chromium browsers, one for Firefox — bundled at build time by Vite/Rollup. Anyone can verify by checking out the matching `vX.Y.Z` tag and rebuilding with `npm install && npm run build` (Chromium) or `npm run build:firefox` (Firefox). - The extension contains no `eval()` or `new Function(string)` calls of remote payloads. - The extension does not load scripts from any CDN or remote host at runtime. diff --git a/README.md b/README.md index 2515b90..1009096 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ # Clay Slip -> A modern Chrome extension for exploring [Clay](https://github.com/clay) CMS pages. +> A modern Chromium + Firefox extension for exploring [Clay](https://github.com/clay) CMS pages. Clay annotates rendered HTML with `data-uri` attributes on every component, page, and layout. Clay Slip reads those attributes and gives you a powerful developer overlay — visualize component boundaries, inspect data, jump between published and draft versions, and copy URIs without ever opening DevTools. +The same source builds for both browser families: + +- **Chromium-family** (Chrome, Edge, Brave, Arc, Vivaldi, Opera, …) +- **Firefox** 121+ (uses the [`webextension-polyfill`](https://github.com/mozilla/webextension-polyfill) at runtime; manifest is post-processed to swap in the `background.scripts` form Firefox MV3 expects) + ![Clay Slip panel inspecting a page](docs/screenshots/inspect.png) ## Highlights -- **Manifest V3** Chrome extension built with TypeScript, React, Vite, and `@crxjs/vite-plugin` +- **Manifest V3** extension built with TypeScript, React, Vite, and `@crxjs/vite-plugin` — single source, dual build for Chromium and Firefox - **Shadow-DOM panel** that never collides with host page styles - **Component tree + find-on-page** — live filter dims non-matches on the page, Enter cycles through them, Esc clears - **Inline JSON preview** so you don't need to open a new tab to read component data @@ -36,11 +41,18 @@ Clay annotates rendered HTML with `data-uri` attributes on every component, page ## Install -Clay Slip is distributed as a Chromium extension `.zip` attached to every release on this repo. There is **no Chrome Web Store listing** — installation is sideloaded ("Load unpacked"), which works the same way in every Chromium-based browser: Chrome, Edge, Brave, Arc, Vivaldi, Opera. +Clay Slip is distributed as `.zip` files attached to every release on this repo. There is **no Chrome Web Store or AMO listing** — installation is sideloaded. Each release ships two zips: + +- `clay-slip-vX.Y.Z.zip` — for Chrome, Edge, Brave, Arc, Vivaldi, Opera, and any other Chromium-based browser. +- `clay-slip-vX.Y.Z-firefox.zip` — for Firefox 121+. + +Pick the matching zip for your browser from the [latest release](https://github.com/clay/clay-devtools/releases/latest), then follow the section below for that browser family. -### First-time install +### Chromium browsers (Chrome / Edge / Brave / Arc / Vivaldi / Opera) -1. Open the [latest release](https://github.com/clay/clay-devtools/releases/latest) and download the `clay-slip-vX.Y.Z.zip` asset (under **Assets**, near the bottom of the release notes). +#### First-time install + +1. Download `clay-slip-vX.Y.Z.zip` from the [latest release](https://github.com/clay/clay-devtools/releases/latest) (under **Assets**, near the bottom of the release notes). 2. **Unzip it** to a stable folder on your machine — e.g. `~/Applications/clay-slip/`, `~/Documents/clay-slip/`, or wherever you like to keep developer tooling. **Don't move or delete this folder later.** Chrome reads the extension from it on every browser start; if the folder disappears, the extension stops working until you reinstall. 3. Open the extensions page in your browser: - Chrome → `chrome://extensions` @@ -55,27 +67,53 @@ That's it — visit any Clay-rendered page and the floating Clay button appears > **About the "Developer mode" warning.** Chrome shows a yellow banner reminding you that extensions are loaded in developer mode. This is normal for any sideloaded (non–Web-Store) extension and can be ignored. It does **not** mean the extension is unsafe; it's the same code attached to the GitHub release. Closing the warning popup that appears on each Chrome startup keeps the extension active. -### Updating to a new version +#### Updating to a new version 1. Download the new `clay-slip-vX.Y.Z.zip` from the [Releases page](https://github.com/clay/clay-devtools/releases). 2. Unzip it **over the existing folder** (replace the old contents) so the path Chrome remembers is still valid. 3. Open the extensions page, find Clay Slip, and click the circular **↻ Reload** icon. Or restart the browser — same effect. -Your settings, notes, and "recently viewed" history are preserved across updates; they live in `chrome.storage`, not in the extension folder. +Your settings, notes, and "recently viewed" history are preserved across updates; they live in browser storage, not in the extension folder. + +### Firefox 121+ + +Firefox doesn't allow permanently installing unsigned extensions on the standard release / ESR channels — you have to either load it as a temporary add-on (which lives until you restart Firefox), or use Firefox Developer Edition / Nightly with signature checks disabled. Both flows are documented below. + +#### Option A — temporary add-on (any Firefox 121+, but resets on restart) + +1. Download `clay-slip-vX.Y.Z-firefox.zip` from the [latest release](https://github.com/clay/clay-devtools/releases/latest) and **unzip it** to a stable folder. +2. Open `about:debugging#/runtime/this-firefox` in a new tab. +3. Click **Load Temporary Add-on…** and select the `manifest.json` file inside the unzipped folder. +4. The Clay icon now appears next to your other extension icons. Visit any Clay-rendered page and the floating Clay button appears in the corner. + +> Firefox **unloads temporary add-ons on browser restart** — you'll need to repeat steps 2–3 each time you launch Firefox. For a persistent install, use Option B. + +#### Option B — persistent install on Developer Edition / Nightly + +1. Install [Firefox Developer Edition](https://www.mozilla.org/firefox/developer/) or [Firefox Nightly](https://www.mozilla.org/firefox/channel/desktop/#nightly) — both let you turn off signature checks. (The standard Firefox release does not.) +2. Open `about:config` and set `xpinstall.signatures.required` to `false`. +3. Download `clay-slip-vX.Y.Z-firefox.zip`, **rename the file extension** from `.zip` to `.xpi` (Firefox installs XPI bundles, which are the same zip format with a different extension). +4. Drag the `.xpi` onto a Firefox window, or open it via **About Firefox → Add-ons → Install Add-on From File…**, and confirm the install. + +Updates: download the new `-firefox.zip`, rename to `.xpi`, and re-install — Firefox prompts to upgrade in place. Settings, notes, and recents persist across updates because they live in `browser.storage`, not in the add-on bundle. ### Removing the extension -Open the extensions page → find Clay Slip → **Remove**. You can then delete the unzipped folder. Stored preferences/notes can also be cleared from the Options page (**Clear recents**) or via your browser's _Manage extensions_ → _Site access / storage_ controls. +- **Chromium**: open the extensions page → find Clay Slip → **Remove**, then delete the unzipped folder. +- **Firefox**: `about:addons` → Clay Slip → **Remove**. + +Stored preferences/notes can also be cleared from the Options page (**Clear recents**) or via your browser's _Manage extensions_ → _Site access / storage_ controls. ### Troubleshooting -| Symptom | Fix | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| _"Manifest file is missing or unreadable"_ | The folder you picked doesn't contain `manifest.json` at its top level. Look one level deeper inside the unzipped folder (some unzippers wrap the contents in an extra folder). | -| Extension disappeared after restart | The unzipped folder was moved or deleted. Re-unzip the release zip to the same path and click **Load unpacked** again, or pick the new path. | -| Toolbar icon greyed out on a page | That page isn't a Clay page (no `data-uri` attributes detected). The extension stays out of the way on non-Clay pages by design. | -| Clicking on the page doesn't select any component | The page has `?edit=true` in the URL — by design the extension goes passive in Clay edit mode and doesn't capture page clicks. The panel is still available; pick the component from the **Tree** tab. | -| Hot reload after update doesn't pick up new code | Click **↻ Reload** on the extensions page _then_ refresh the tab. Service-worker-based extensions need both. | +| Symptom | Fix | +| --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _"Manifest file is missing or unreadable"_ | The folder you picked doesn't contain `manifest.json` at its top level. Look one level deeper inside the unzipped folder (some unzippers wrap the contents in an extra folder). | +| Extension disappeared after restart | **Chromium**: the unzipped folder was moved or deleted — re-unzip the release zip to the same path and **Load unpacked** again. **Firefox**: temporary add-ons unload on restart by design (see Option A above) — use Option B for a persistent install. | +| Toolbar icon greyed out on a page | That page isn't a Clay page (no `data-uri` attributes detected). The extension stays out of the way on non-Clay pages by design. | +| Clicking on the page doesn't select any component | The page has `?edit=true` in the URL — by design the extension goes passive in Clay edit mode and doesn't capture page clicks. The panel is still available; pick the component from the **Tree** tab. | +| Hot reload after update doesn't pick up new code | Click **↻ Reload** on the extensions page _then_ refresh the tab. Service-worker-based extensions need both. | +| _"This add-on could not be installed because it appears to be corrupt"_ (Firefox) | Firefox's signature check rejected the unsigned XPI. Either use Option A (temporary add-on, no signing required) or follow Option B exactly — make sure `xpinstall.signatures.required = false` is set **and** you're on Developer Edition / Nightly. | ## Usage @@ -155,7 +193,7 @@ When switching between `dev` and `build` outputs, click the **↻ Reload** icon ``` src/ -├── manifest.ts # MV3 manifest defined in TypeScript +├── manifest.ts # MV3 manifest in TypeScript; branches on TARGET=firefox to add gecko block ├── background/ │ └── service-worker.ts # MV3 service worker (open tabs, badge counts) ├── content/ @@ -188,25 +226,28 @@ src/ ## Scripts -| Command | What it does | -| ----------------------- | ---------------------------------------- | -| `npm run dev` | Vite dev server with HMR | -| `npm run build` | Typecheck + production build → `dist/` | -| `npm run lint` | ESLint with zero-warning policy | -| `npm run lint:fix` | ESLint auto-fix | -| `npm run format` | Prettier write | -| `npm run format:check` | Prettier check (used in CI) | -| `npm run test` | Run Vitest | -| `npm run test:watch` | Vitest in watch mode | -| `npm run test:coverage` | Vitest with coverage | -| `npm run typecheck` | `tsc --noEmit` | -| `npm run validate` | Typecheck + lint + format check + tests | -| `npm run zip` | Pack `dist/` into `clay-slip-vX.Y.Z.zip` | -| `npm run release:dry` | Validate + build + zip (mirrors CI) | +| Command | What it does | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `npm run dev` | Vite dev server with HMR | +| `npm run build` | Typecheck + Chromium production build → `dist/` | +| `npm run build:firefox` | Typecheck + Firefox production build → `dist-firefox/` (runs `scripts/firefox-postbuild.mjs` to rewrite the MV3 background) | +| `npm run lint` | ESLint with zero-warning policy | +| `npm run lint:fix` | ESLint auto-fix | +| `npm run format` | Prettier write | +| `npm run format:check` | Prettier check (used in CI) | +| `npm run test` | Run Vitest | +| `npm run test:watch` | Vitest in watch mode | +| `npm run test:coverage` | Vitest with coverage | +| `npm run typecheck` | `tsc --noEmit` | +| `npm run validate` | Typecheck + lint + format check + tests | +| `npm run zip` | Pack `dist/` into `clay-slip-vX.Y.Z.zip` (Chromium) | +| `npm run zip:firefox` | Pack `dist-firefox/` into `clay-slip-vX.Y.Z-firefox.zip` | +| `npm run release:dry` | Validate + Chromium build + zip (mirrors the Chromium half of CI) | +| `npm run release:dry:firefox` | Validate + Firefox build + zip (mirrors the Firefox half of CI) | ## Releasing -Releases are automated by `.github/workflows/release.yml`. The published GitHub Release is the **only** distribution channel — there is no Chrome Web Store listing. Users follow the [Install](#install) section above to grab and load the zip. +Releases are automated by `.github/workflows/release.yml`. The published GitHub Release is the **only** distribution channel — there is no Chrome Web Store or AMO listing. Users follow the [Install](#install) section above to grab the matching zip for their browser family. The flow is: @@ -222,16 +263,21 @@ The flow is: 2. The push of the tag triggers the **Release** workflow, which: - Verifies the tag matches `package.json` (fails fast on mismatch). - Runs the full validation suite (typecheck / lint / format / test). - - Builds the extension. - - Zips `dist/` as `clay-slip-vX.Y.Z.zip`. - - Creates a **draft** GitHub release with the zip attached and auto-generated release notes. + - Builds the extension twice — once for Chromium (`npm run build`), once for Firefox (`npm run build:firefox`). + - Zips each build using the same script as locally: `clay-slip-vX.Y.Z.zip` and `clay-slip-vX.Y.Z-firefox.zip`. + - Creates a **draft** GitHub release with **both** zips attached and auto-generated release notes. + +3. Open the draft release on GitHub, polish the notes (call out the highlights, breaking changes, install/update instructions if anything changed in those flows), and click **Publish**. Both zips become available under **Assets** for users to download. + +You can also kick off the workflow manually from the Actions tab against an existing tag (useful if a release run fails midway). To package locally without going through CI: -3. Open the draft release on GitHub, polish the notes (call out the highlights, breaking changes, install/update instructions if anything changed in those flows), and click **Publish**. The zip becomes available under **Assets** for users to download. +- `npm run release:dry` → produces `clay-slip-vX.Y.Z.zip` (Chromium). +- `npm run release:dry:firefox` → produces `clay-slip-vX.Y.Z-firefox.zip` (Firefox). -You can also kick off the workflow manually from the Actions tab against an existing tag (useful if a release run fails midway). To package locally without going through CI, `npm run release:dry` produces an identical `clay-slip-vX.Y.Z.zip` next to the repo — handy for smoke-testing the install flow before tagging. +Run both before tagging if you want to smoke-test in both browsers before users see them. -> **⚠️ Always use `npm run zip` (or `release:dry`) to package — never zip `dist/` from Finder / Explorer.** -> Right-clicking the folder produces a zip with a `dist/` wrapper, which means users would have to drill into a subfolder to find `manifest.json` when loading unpacked (and it's the layout Chrome Web Store rejects with _"No manifest found in package."_ if you ever do publish there). Our script zips the **contents** of `dist/` (so `manifest.json` is at the root), strips source maps and macOS metadata, and verifies the zip layout before declaring success. Pass `INCLUDE_SOURCEMAPS=1` if you need maps for debugging a sideloaded build. +> **⚠️ Always use the project's zip scripts — never zip `dist/` (or `dist-firefox/`) from Finder / Explorer.** +> Right-clicking the folder produces a zip with a `dist/` wrapper, which means users would have to drill into a subfolder to find `manifest.json` when sideloading (and it's the layout both stores reject with _"No manifest found in package."_ if we ever publish there). Our script zips the **contents** of the build folder (so `manifest.json` is at the root), strips source maps and macOS metadata, and verifies the zip layout before declaring success. Pass `INCLUDE_SOURCEMAPS=1` if you need maps for debugging a sideloaded build. ## Migration notes (1.0 → 2.0) diff --git a/scripts/firefox-manifest.mjs b/scripts/firefox-manifest.mjs new file mode 100644 index 0000000..a0daebe --- /dev/null +++ b/scripts/firefox-manifest.mjs @@ -0,0 +1,66 @@ +// Pure helper that takes the Chromium-shaped manifest crxjs emits and +// rewrites the handful of fields that Firefox needs in a different form. +// +// Kept separate from `firefox-postbuild.mjs` so it can be unit-tested +// in isolation (the postbuild script is a one-shot file mutation). +// +// What we rewrite and why: +// +// 1. `background.service_worker` → `background.scripts` (array form). +// crxjs always emits the Chromium MV3 shape (`service_worker`), +// but Firefox 121+ implements MV3 backgrounds via ES-module +// `background.scripts`. Loading a Firefox build with the original +// `service_worker` field surfaces no errors — Firefox just never +// runs the background, which makes every panel→service-worker +// message (open tab, capture screenshot, badge update) silently +// time out. The rewrite preserves `type: 'module'`, which is +// required for `import` to work inside the background. +// +// 2. `web_accessible_resources[].use_dynamic_url`. This field is +// Chromium-only (CRX-7173, ships obfuscated UUID URLs at runtime). +// Firefox logs a manifest warning for unknown fields, which clutters +// the console for users on `about:debugging`. We drop it from every +// entry rather than special-case it. +// +// If you need to add another rewrite, do it here and add a corresponding +// case to `tests/scripts/firefox-manifest.test.ts`. + +/** + * @typedef {object} ChromiumManifest + * @property {object} [background] + * @property {string} [background.service_worker] + * @property {string} [background.type] + * @property {string[]} [background.scripts] + * @property {Array<{ resources?: string[]; matches?: string[]; use_dynamic_url?: boolean }>} [web_accessible_resources] + */ + +/** + * Mutates the given manifest in place and returns it. Mutating in place + * matches the postbuild script's intent (overwrite the file we just + * read), and avoids the cost of a structuredClone of a large object. + * + * Throws if the manifest doesn't have a `background.service_worker` to + * rewrite — that's the only way the postbuild step could be a no-op, + * and silently no-oping would mean shipping a broken Firefox build. + * + * @param {ChromiumManifest} manifest + * @returns {ChromiumManifest} + */ +export function rewriteForFirefox(manifest) { + const loader = manifest.background?.service_worker; + if (!loader) { + throw new Error( + 'Firefox postbuild: manifest.background.service_worker is missing. The build is empty or already rewritten.' + ); + } + + manifest.background = { scripts: [loader], type: 'module' }; + + if (Array.isArray(manifest.web_accessible_resources)) { + for (const entry of manifest.web_accessible_resources) { + delete entry.use_dynamic_url; + } + } + + return manifest; +} diff --git a/scripts/firefox-postbuild.mjs b/scripts/firefox-postbuild.mjs index 1c33c10..c96fe57 100644 --- a/scripts/firefox-postbuild.mjs +++ b/scripts/firefox-postbuild.mjs @@ -1,31 +1,35 @@ -// Firefox does not yet enable `background.service_worker` in shipping -// builds (the flag is off by default), but crxjs only emits that field. -// After the Firefox build, rewrite the manifest to use the MV3-compatible -// `background.scripts` form pointing at the same loader. +// Runs after `vite build` with `TARGET=firefox`. Reads the freshly +// emitted `dist-firefox/manifest.json`, hands it to `rewriteForFirefox` +// to apply the Chromium→Firefox field translations, and writes it back. +// +// All of the actual rewriting logic lives in `firefox-manifest.mjs` so +// it can be unit-tested without touching the filesystem or spawning a +// build. See that file for the per-field rationale. +// +// Failure modes: +// - Missing `dist-firefox/manifest.json` → exit 1 (build never ran). +// - `rewriteForFirefox` throws (e.g. no service_worker to rewrite) → +// surface the error and exit 1. import { readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { rewriteForFirefox } from './firefox-manifest.mjs'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const manifestPath = join(root, 'dist-firefox', 'manifest.json'); const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); -const loader = manifest.background?.service_worker; -if (!loader) { - console.error('✖ dist-firefox/manifest.json has no background.service_worker to rewrite.'); - process.exit(1); -} - -manifest.background = { scripts: [loader], type: 'module' }; -// `use_dynamic_url` is Chrome-only (CRX-7173); Firefox logs a manifest -// warning if it sees it. Strip from each web_accessible_resources entry. -if (Array.isArray(manifest.web_accessible_resources)) { - for (const entry of manifest.web_accessible_resources) { - delete entry.use_dynamic_url; - } +try { + rewriteForFirefox(manifest); +} catch (err) { + console.error(`✖ ${err.message}`); + process.exit(1); } await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + +const loader = manifest.background.scripts[0]; console.log(`✓ rewrote background.service_worker → background.scripts (${loader})`); +console.log(' stripped Chromium-only `use_dynamic_url` from web_accessible_resources'); diff --git a/scripts/zip-extension.mjs b/scripts/zip-extension.mjs index 9a7e19c..f1122e5 100644 --- a/scripts/zip-extension.mjs +++ b/scripts/zip-extension.mjs @@ -1,19 +1,21 @@ -// Pack the built `dist/` directory into `clay-slip-vX.Y.Z.zip`, ready to -// attach to a GitHub Release (the project's only distribution channel) or -// to hand off for direct sideloading via "Load unpacked". +// Pack a built extension directory into `clay-slip-vX.Y.Z[-firefox].zip`, +// ready to attach to a GitHub Release (the project's only distribution +// channel) or to hand off for direct sideloading. // -// npm run zip (assumes `dist/` already exists) -// npm run release:dry (validate + build + zip in one shot) +// npm run zip (Chromium build → dist/) +// npm run zip:firefox (Firefox build → dist-firefox/) +// npm run release:dry (validate + Chrome build + zip) +// npm run release:dry:firefox (validate + Firefox build + zip) // INCLUDE_SOURCEMAPS=1 npm run zip (keep .map files; useful for debugging) // // Why this script exists instead of `cd dist && zip -r ../slip.zip .`: -// - When users sideload the extension via "Load unpacked", they have to -// point Chrome at a folder containing `manifest.json` at the *top* -// level. Right-clicking `dist/` in Finder → Compress produces a zip -// with a `dist/` folder wrapper, which forces every user to drill in -// one extra level after unzipping (and is the same layout the Chrome -// Web Store rejects with "No manifest found in package." if we ever -// do publish there). +// - When users sideload the extension ("Load unpacked" in Chromium, +// "Load Temporary Add-on" in Firefox), they have to point the browser +// at a `manifest.json` at the *top* level of the unzipped folder. +// Right-clicking `dist/` in Finder → Compress produces a zip with a +// `dist/` folder wrapper, which forces every user to drill in one +// extra level after unzipping (and is the layout both stores reject +// with "No manifest found in package." if we ever publish there). // - macOS adds `__MACOSX/` resource forks and `.DS_Store` files to zips // made by Finder. Both clutter the unzipped folder users see. // - Sideloaded builds don't need source maps; stripping them halves the @@ -94,8 +96,26 @@ child.on('exit', (code) => { console.log('Next steps:'); console.log(' Local smoke-test:'); console.log(` 1. Unzip ${outName} into a stable folder.`); - console.log(' 2. Open chrome://extensions → enable Developer mode.'); - console.log(' 3. Click "Load unpacked" and select the unzipped folder.'); + if (isFirefox) { + // Firefox sideload: the only built-in install path is "Load Temporary + // Add-on" via about:debugging, which lives until the next browser + // restart. There's no permanent unsigned-extension install on + // standard Firefox; for that the user needs Firefox Developer + // Edition / Nightly with `xpinstall.signatures.required = false`, + // or an AMO-signed XPI. We point at the temporary path because it + // covers the smoke-test workflow. + console.log(' 2. Open about:debugging#/runtime/this-firefox.'); + console.log(' 3. Click "Load Temporary Add-on…" and pick manifest.json'); + console.log(' inside the unzipped folder.'); + console.log(' Note: Firefox unloads temporary add-ons when you restart'); + console.log(' the browser. For a persistent install, use Firefox Developer'); + console.log(' Edition / Nightly with xpinstall.signatures.required=false,'); + console.log(' or an AMO-signed XPI.'); + } else { + console.log(' 2. Open chrome://extensions (or edge://, brave://, …)'); + console.log(' and enable Developer mode.'); + console.log(' 3. Click "Load unpacked" and select the unzipped folder.'); + } console.log(''); console.log(' Publishing:'); console.log(' Tag a release (`npm version` then `git push --follow-tags`)'); @@ -137,10 +157,14 @@ function verifyManifestAtRoot(zipPath) { console.error(''); console.error(`✖ ${outName} does not contain manifest.json at the root.`); console.error(' Users would need to drill into a subfolder after unzipping'); - console.error(' before "Load unpacked" would accept the folder, and the'); - console.error(' Chrome Web Store would reject this layout outright.'); + console.error(' before "Load unpacked" / "Load Temporary Add-on" would accept'); + console.error(' the folder, and both the Chrome Web Store and AMO would reject'); + console.error(' this layout outright.'); console.error(' Likely cause: the script ran outside dist/ or with a folder wrapper.'); - console.error(' Re-run `npm run build && npm run zip`.'); + const buildCmd = isFirefox + ? 'npm run build:firefox && npm run zip:firefox' + : 'npm run build && npm run zip'; + console.error(` Re-run \`${buildCmd}\`.`); process.exit(2); } diff --git a/src/manifest.ts b/src/manifest.ts index f05c9c8..80a8fb8 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,21 +1,42 @@ import { defineManifest } from '@crxjs/vite-plugin'; import pkg from '../package.json' with { type: 'json' }; -// Chrome Web Store rejects uploads with `manifest.description` > 132 chars. -// Catch the regression at build time, where it's easy to fix, instead of at -// upload time, where it bricks a release. +// 132-char ceiling on `manifest.description` is enforced by both Chrome +// Web Store *and* AMO. We don't currently publish on either store +// (releases ship as zips on GitHub), but keeping the field within the +// stricter store limit means we'd never have to truncate at submit time +// if that ever changes — and 132 is plenty for an honest one-liner. const MAX_DESCRIPTION_CHARS = 132; if (pkg.description.length > MAX_DESCRIPTION_CHARS) { throw new Error( - `package.json "description" is ${pkg.description.length} chars; Chrome Web Store limit is ${MAX_DESCRIPTION_CHARS}.` + `package.json "description" is ${pkg.description.length} chars; the manifest "description" field is capped at ${MAX_DESCRIPTION_CHARS} (Chrome Web Store + AMO).` ); } +// We build the same source twice: once for Chromium-family browsers +// (default) and once for Firefox (`TARGET=firefox`). The differences are +// confined to `firefoxExtras` below + a tiny postbuild step in +// `scripts/firefox-postbuild.mjs` that rewrites a couple of MV3 fields +// crxjs emits in the Chromium-only shape. const isFirefox = process.env.TARGET === 'firefox'; -// Firefox needs a gecko block for installable signing. Service worker -// background scripts require Firefox 121+; below that, MV3 extensions -// must use `background.scripts` instead, which crxjs doesn't emit. +// Firefox-specific manifest fields: +// - `browser_specific_settings.gecko.id` is required for any extension +// that wants to install (signed or temporary) on Firefox; no default +// synthesis like Chromium has. +// - `strict_min_version: 121.0` is the floor we test against. Firefox +// shipped MV3 in 121 (Dec 2023), and that's also the first stable +// release where ES-module background scripts (`background.scripts` +// with `type: 'module'`) work — the form we rewrite to in the +// postbuild step. Older Firefox would silently fail to load the +// background. +// - `data_collection_permissions.required: ['none']` is Firefox's +// newer disclosure mechanism (AMO uses it for the listing labels); +// we collect nothing, so this is the only honest value. +// +// Chromium gets `minimum_chrome_version: 116` for the same reason — +// MV3 + the storage/clipboard/scripting features we depend on are all +// stable from there. const firefoxExtras = isFirefox ? { browser_specific_settings: { diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..8994874 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,106 @@ +// Lock in the cross-browser branching contract of `src/manifest.ts`. The +// file is read once by Vite at build time and turns `process.env.TARGET` +// into either the Chromium or the Firefox manifest shape. Importing it +// twice in the same vitest run would normally cache the first result — +// we use `vi.resetModules()` between imports so each test gets a fresh +// evaluation under a different env var. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface ChromiumOnlyManifest { + manifest_version: number; + name: string; + version: string; + description: string; + minimum_chrome_version?: string; + background?: { service_worker?: string; type?: string }; + permissions?: string[]; + host_permissions?: string[]; + icons?: Record; +} + +interface FirefoxBranchManifest extends ChromiumOnlyManifest { + browser_specific_settings?: { + gecko?: { + id?: string; + strict_min_version?: string; + data_collection_permissions?: { required?: string[] }; + }; + }; +} + +async function loadManifest(target: string | undefined): Promise { + // Reset the module graph so the next import re-evaluates the top-level + // `process.env.TARGET` check. Without this, the second import would + // return whatever the first import cached. + vi.resetModules(); + if (target === undefined) delete process.env.TARGET; + else process.env.TARGET = target; + const mod = await import('@/manifest'); + return mod.default as T; +} + +describe('src/manifest.ts cross-browser branching', () => { + const originalTarget = process.env.TARGET; + + beforeEach(() => { + delete process.env.TARGET; + }); + + afterEach(() => { + if (originalTarget === undefined) delete process.env.TARGET; + else process.env.TARGET = originalTarget; + }); + + it('default (Chromium) build adds minimum_chrome_version and NO gecko block', async () => { + const m = await loadManifest(undefined); + expect(m.minimum_chrome_version).toBe('116'); + expect(m.browser_specific_settings).toBeUndefined(); + // Chromium gets the standard MV3 service worker shape — the Firefox + // postbuild step is what rewrites this to background.scripts. + expect(m.background?.service_worker).toBeTruthy(); + }); + + it('TARGET=firefox build adds the gecko block and drops minimum_chrome_version', async () => { + const m = await loadManifest('firefox'); + // Mutual exclusion — these two fields exist for opposite browser + // families and should never co-exist in the same manifest. AMO and + // CWS would both flag the cross-browser field as "unknown key". + expect(m.minimum_chrome_version).toBeUndefined(); + expect(m.browser_specific_settings?.gecko?.id).toBe('clay-slip@slate.com'); + expect(m.browser_specific_settings?.gecko?.strict_min_version).toBe('121.0'); + // We don't collect anything; the only honest value Firefox accepts + // here is `['none']`, and shipping with a different value would be + // both inaccurate AND surface a misleading AMO listing. + expect(m.browser_specific_settings?.gecko?.data_collection_permissions?.required).toEqual([ + 'none', + ]); + }); + + it('shared MV3 fields are identical between targets (single source of truth)', async () => { + // Anything that's not the gecko / minimum_chrome_version branch + // must come out identical from both builds. Locking it down here + // catches a future "I added this field for Chrome only" regression + // before Firefox users hit it (or vice versa). + const chromium = await loadManifest(undefined); + const firefox = await loadManifest('firefox'); + + expect(firefox.manifest_version).toBe(chromium.manifest_version); + expect(firefox.name).toBe(chromium.name); + expect(firefox.version).toBe(chromium.version); + expect(firefox.description).toBe(chromium.description); + expect(firefox.permissions).toEqual(chromium.permissions); + expect(firefox.host_permissions).toEqual(chromium.host_permissions); + expect(firefox.icons).toEqual(chromium.icons); + }); + + it('manifest description stays under the 132-char store ceiling', async () => { + // Both Chrome Web Store and AMO cap manifest.description at 132. + // The `src/manifest.ts` module throws at import if package.json + // exceeds this limit, which is what we lean on here — passing the + // import means the limit is honored. We assert the length too so + // a future bump that drops the guard would still fail loudly. + const m = await loadManifest(undefined); + expect(m.description.length).toBeGreaterThan(0); + expect(m.description.length).toBeLessThanOrEqual(132); + }); +}); diff --git a/tests/scripts/firefox-manifest.test.ts b/tests/scripts/firefox-manifest.test.ts new file mode 100644 index 0000000..bf51f9a --- /dev/null +++ b/tests/scripts/firefox-manifest.test.ts @@ -0,0 +1,92 @@ +// Unit test for `scripts/firefox-manifest.mjs`. The actual postbuild +// script wraps this helper with file IO; we test the pure transformation +// here so the shape contract is locked in without spawning a build. +import { describe, expect, it } from 'vitest'; +// @ts-expect-error — .mjs sibling without types; the helper is intentionally JS-only. +import { rewriteForFirefox } from '../../scripts/firefox-manifest.mjs'; + +interface ChromiumManifest { + background?: { service_worker?: string; type?: string; scripts?: string[] }; + web_accessible_resources?: Array<{ + matches?: string[]; + resources?: string[]; + use_dynamic_url?: boolean; + }>; +} + +describe('rewriteForFirefox', () => { + it('rewrites background.service_worker → background.scripts (array, type: module)', () => { + // The crxjs-shape we receive: a single service_worker entry with type module. + // The Firefox-shape we want: a `scripts` array containing that loader, + // type still 'module' so ES imports inside the background work on FF 121+. + const m: ChromiumManifest = { + background: { service_worker: 'service-worker-loader.js', type: 'module' }, + }; + rewriteForFirefox(m); + expect(m.background).toEqual({ + scripts: ['service-worker-loader.js'], + type: 'module', + }); + expect(m.background?.service_worker).toBeUndefined(); + }); + + it('throws when there is no background.service_worker to rewrite', () => { + // Defensive contract: a no-op rewrite would mean a silent broken + // build (background never starts on Firefox), so the script fails + // loudly instead. + expect(() => rewriteForFirefox({})).toThrow(/manifest\.background\.service_worker is missing/); + expect(() => rewriteForFirefox({ background: {} })).toThrow( + /manifest\.background\.service_worker is missing/ + ); + }); + + it('strips Chromium-only `use_dynamic_url` from every web_accessible_resources entry', () => { + // Firefox warns on unknown manifest keys (clutters about:debugging + // for users), and use_dynamic_url is a Chromium-only switch that + // toggles obfuscated UUID URLs at runtime. + const m: ChromiumManifest = { + background: { service_worker: 'sw.js', type: 'module' }, + web_accessible_resources: [ + { + matches: [''], + resources: ['assets/a.js'], + use_dynamic_url: false, + }, + { + matches: [''], + resources: ['assets/b.js'], + use_dynamic_url: true, + }, + ], + }; + rewriteForFirefox(m); + for (const entry of m.web_accessible_resources ?? []) { + expect(entry).not.toHaveProperty('use_dynamic_url'); + // Other fields untouched — the rewrite is targeted, not a rebuild. + expect(entry.matches).toEqual(['']); + expect(entry.resources?.length).toBe(1); + } + }); + + it('is a no-op on web_accessible_resources when the field is absent', () => { + // We must not synthesize an empty array — that would change the + // generated manifest in a way Firefox sees as "explicit empty list". + const m: ChromiumManifest = { + background: { service_worker: 'sw.js', type: 'module' }, + }; + rewriteForFirefox(m); + expect(m.web_accessible_resources).toBeUndefined(); + }); + + it('mutates in place AND returns the same reference', () => { + // The postbuild script relies on the in-place mutation (it writes + // `manifest` back to disk after calling the helper). Returning the + // same reference keeps it ergonomic for tests + future callers + // that prefer the expression form. + const m: ChromiumManifest = { + background: { service_worker: 'sw.js', type: 'module' }, + }; + const out = rewriteForFirefox(m); + expect(out).toBe(m); + }); +}); From e0b03a657964423f0b580b840f29c875c53cae5c Mon Sep 17 00:00:00 2001 From: Jordan Paulino Date: Fri, 15 May 2026 14:42:04 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8D=95=20Add=20`npm=20run=20release:d?= =?UTF-8?q?ry:both`=20=E2=80=94=20single=20command=20for=20both=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes validate (once) + Chromium build + Chromium zip + Firefox build + Firefox zip. Mirrors the full release workflow exactly, which was previously only reachable by running both per-target release:dry scripts back-to-back (and revalidating each time, ~7s of duplicated typecheck/lint/format/tests). This is now the recommended local smoke-test before tagging a release. The single-target scripts stay around for the case where you only care about one browser family. README: scripts table gains the new entry; Releasing section recommends `release:dry:both` first, with the per-target scripts as the narrower-scope alternatives. Co-authored-by: Cursor --- README.md | 8 ++++---- package.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1009096..8510a8d 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ src/ | `npm run zip:firefox` | Pack `dist-firefox/` into `clay-slip-vX.Y.Z-firefox.zip` | | `npm run release:dry` | Validate + Chromium build + zip (mirrors the Chromium half of CI) | | `npm run release:dry:firefox` | Validate + Firefox build + zip (mirrors the Firefox half of CI) | +| `npm run release:dry:both` | Validate **once** + both builds + both zips (mirrors the full release workflow; preferred before tagging a release) | ## Releasing @@ -271,10 +272,9 @@ The flow is: You can also kick off the workflow manually from the Actions tab against an existing tag (useful if a release run fails midway). To package locally without going through CI: -- `npm run release:dry` → produces `clay-slip-vX.Y.Z.zip` (Chromium). -- `npm run release:dry:firefox` → produces `clay-slip-vX.Y.Z-firefox.zip` (Firefox). - -Run both before tagging if you want to smoke-test in both browsers before users see them. +- `npm run release:dry:both` → validates once + builds + zips **both** targets, producing `clay-slip-vX.Y.Z.zip` and `clay-slip-vX.Y.Z-firefox.zip`. Best smoke-test before tagging. +- `npm run release:dry` → just the Chromium half. Faster when you only care about that target. +- `npm run release:dry:firefox` → just the Firefox half. > **⚠️ Always use the project's zip scripts — never zip `dist/` (or `dist-firefox/`) from Finder / Explorer.** > Right-clicking the folder produces a zip with a `dist/` wrapper, which means users would have to drill into a subfolder to find `manifest.json` when sideloading (and it's the layout both stores reject with _"No manifest found in package."_ if we ever publish there). Our script zips the **contents** of the build folder (so `manifest.json` is at the root), strips source maps and macOS metadata, and verifies the zip layout before declaring success. Pass `INCLUDE_SOURCEMAPS=1` if you need maps for debugging a sideloaded build. diff --git a/package.json b/package.json index 176afcd..81a8923 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "zip": "node scripts/zip-extension.mjs", "zip:firefox": "TARGET=firefox node scripts/zip-extension.mjs", "release:dry": "npm run validate && npm run build && npm run zip", - "release:dry:firefox": "npm run validate && npm run build:firefox && npm run zip:firefox" + "release:dry:firefox": "npm run validate && npm run build:firefox && npm run zip:firefox", + "release:dry:both": "npm run validate && npm run build && npm run zip && npm run build:firefox && npm run zip:firefox" }, "dependencies": { "react": "^19.2.6",