diff --git a/apps/desktop/src/main/capture/auth.ts b/apps/desktop/src/main/capture/auth.ts index fa51e4349..51311c0db 100644 --- a/apps/desktop/src/main/capture/auth.ts +++ b/apps/desktop/src/main/capture/auth.ts @@ -8,6 +8,14 @@ export interface CaptureAuthHeaders { type Result = { ok: true } | { ok: false; reason: string } +// Capture pairing accepts browser-extension origins only — Chromium (chrome-extension://) +// and Firefox (moz-extension://). Single source of truth so the two pairing guards can't drift. +const EXTENSION_ORIGIN_PREFIXES = ['chrome-extension://', 'moz-extension://'] + +export function isExtensionOrigin(origin: string | undefined): boolean { + return !!origin && EXTENSION_ORIGIN_PREFIXES.some((p) => origin.startsWith(p)) +} + function tokenEquals(a: string, b: string): boolean { const ab = Buffer.from(a) const bb = Buffer.from(b) diff --git a/apps/desktop/src/main/capture/pairing.test.ts b/apps/desktop/src/main/capture/pairing.test.ts index 3293fa9e0..2dd73759e 100644 --- a/apps/desktop/src/main/capture/pairing.test.ts +++ b/apps/desktop/src/main/capture/pairing.test.ts @@ -41,6 +41,14 @@ describe('pairing', () => { expect(isOriginAllowed('https://evil.com')).toBe(false) }) + it('claims pairing for a Firefox moz-extension origin', async () => { + const { openPairingWindow, claimPairing, isOriginAllowed } = await import('./pairing') + openPairingWindow() + const res = await claimPairing('moz-extension://abc') + expect(res?.token).toHaveLength(64) + expect(isOriginAllowed('moz-extension://abc')).toBe(true) + }) + it('rotate clears the allowlist and changes the token', async () => { const { openPairingWindow, claimPairing, rotateCaptureToken, isOriginAllowed } = await import('./pairing') diff --git a/apps/desktop/src/main/capture/pairing.ts b/apps/desktop/src/main/capture/pairing.ts index 0851303b8..919981211 100644 --- a/apps/desktop/src/main/capture/pairing.ts +++ b/apps/desktop/src/main/capture/pairing.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto' import keytar from 'keytar' import { store } from '../store' +import { isExtensionOrigin } from './auth' const SERVICE = 'com.memry.capture' const ACCOUNT = 'pairing-token' @@ -71,7 +72,7 @@ export function isPairingWindowOpen(now = Date.now()): boolean { export async function claimPairing(origin: string): Promise<{ token: string } | null> { if (!isPairingWindowOpen()) return null - if (!origin.startsWith('chrome-extension://')) return null + if (!isExtensionOrigin(origin)) return null const list = allowlist() if (!list.includes(origin)) store.set(ALLOWLIST_KEY, [...list, origin]) claimWindowUntil = 0 // single claim closes the window diff --git a/apps/desktop/src/main/capture/server.test.ts b/apps/desktop/src/main/capture/server.test.ts index 59b20da76..7bd1e245d 100644 --- a/apps/desktop/src/main/capture/server.test.ts +++ b/apps/desktop/src/main/capture/server.test.ts @@ -168,6 +168,17 @@ describe('capture server', () => { expect((await r.json()).error).toBe('origin-not-allowed') }) + it('/pair/request for an allowlisted Firefox moz-extension origin → 200 already-paired', async () => { + origins.add('moz-extension://ext-firefox') + const r = await req(port, '/pair/request', { + method: 'POST', + headers: { Origin: 'moz-extension://ext-firefox', 'X-Memry-Capture': '1' } + }) + expect(r.status).toBe(200) + expect((await r.json()).status).toBe('already-paired') + expect(openPairingWindowMock).toHaveBeenCalledTimes(1) + }) + it('/pair/request missing X-Memry-Capture header → 401 missing-capture-header', async () => { const r = await req(port, '/pair/request', { method: 'POST', diff --git a/apps/desktop/src/main/capture/server.ts b/apps/desktop/src/main/capture/server.ts index 93d4cfa2f..2ef9f4c68 100644 --- a/apps/desktop/src/main/capture/server.ts +++ b/apps/desktop/src/main/capture/server.ts @@ -9,7 +9,7 @@ import { openPairingWindow, unpairCapture } from './pairing' -import { validateCaptureRequest } from './auth' +import { validateCaptureRequest, isExtensionOrigin } from './auth' import { createLogger } from '../lib/logger' const log = createLogger('Capture:Server') @@ -77,7 +77,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom json(res, 400, { error: 'missing-origin' }) return } - if (!origin.startsWith('chrome-extension://')) { + if (!isExtensionOrigin(origin)) { json(res, 403, { error: 'origin-not-allowed' }) return } diff --git a/apps/docs/src/user-guide/inbox/capturing.md b/apps/docs/src/user-guide/inbox/capturing.md index 40c9bfea9..70f2097cf 100644 --- a/apps/docs/src/user-guide/inbox/capturing.md +++ b/apps/docs/src/user-guide/inbox/capturing.md @@ -64,6 +64,11 @@ If voice transcription setup is incomplete, memrynote takes you to AI settings b The memrynote browser extension captures the page you're reading straight into your inbox. Web clips appear under the **clips** content type filter. +### Supported browsers + +The extension is a single cross-browser build that runs on Chrome, Edge, and Firefox (version 140 or +newer). Pairing and all capture modes work the same on each. + ### Pairing The extension talks to the desktop app over a local loopback connection — nothing leaves your diff --git a/apps/extension/README.md b/apps/extension/README.md index 190789578..0432ae51e 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -20,6 +20,37 @@ pnpm --filter @memry/extension lint 3. **Load unpacked** → select `apps/extension/.output/chrome-mv3`. 4. Keep the load path stable so the extension ID (and pairing) survives reloads. +## Load temporary add-on in Firefox + +Same WXT source, MV3 build (`--mv3` keeps `browser.action`/`host_permissions` +matching the Chrome source). Stable id comes from `browser_specific_settings.gecko`. + +1. `pnpm --filter @memry/extension build:firefox` +2. Firefox → `about:debugging#/runtime/this-firefox`. +3. **Load Temporary Add-on…** → select `apps/extension/.output/firefox-mv3/manifest.json`. +4. Requires Firefox ≥ 140 (`strict_min_version`, the floor for AMO's + `data_collection_permissions`). The `Ctrl+Shift+S` command may + clash with Firefox's screenshot shortcut — rebind it under + `about:addons → ⚙ → Manage Extension Shortcuts` if it doesn't fire; the popup + button works regardless. + +`pnpm --filter @memry/extension dev:firefox` launches a dev Firefox with +auto-reload (via web-ext). `zip:firefox` produces a signable artifact when you +take it to AMO. + +## Load unpacked in Edge + +Edge is Chromium, so the Chrome build already runs on it (same +`chrome-extension://` origin → capture pairing works unchanged). The `:edge` +scripts just produce a separately-named artifact for the Edge Add-ons store. + +1. `pnpm --filter @memry/extension build:edge` +2. Edge → `edge://extensions` → enable **Developer mode**. +3. **Load unpacked** → select `apps/extension/.output/edge-mv3`. + +`zip:edge` produces `.output/*-edge.zip` for submission to the Microsoft Edge +Add-ons store. + ## Manual QA (the Phase-3 acceptance gate — human-required) Run the desktop app first: `pnpm dev`. diff --git a/apps/extension/package.json b/apps/extension/package.json index 591b7e04c..5d73f8467 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -5,8 +5,14 @@ "type": "module", "scripts": { "dev": "wxt", + "dev:firefox": "wxt -b firefox --mv3", + "dev:edge": "wxt -b edge", "build": "wxt build", + "build:firefox": "wxt build -b firefox --mv3", + "build:edge": "wxt build -b edge", "zip": "wxt zip", + "zip:firefox": "wxt zip -b firefox --mv3", + "zip:edge": "wxt zip -b edge", "submit": "wxt submit --chrome-zip .output/*-chrome.zip", "release": "pnpm version --tag-version-prefix=extension-v --message \"chore(extension): release v%s\" && git push --follow-tags", "typecheck": "wxt prepare && tsc --noEmit", diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 889592e06..161a109f2 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -15,6 +15,22 @@ export default defineConfig({ suggested_key: { default: 'Ctrl+Shift+S', mac: 'Command+Shift+S' }, description: 'Capture this page to MemryNote' } + }, + // ponytail: stable gecko id → storage.local pairing token survives reloads; required for AMO. + // Firefox builds target MV3 (--mv3) so browser.action/host_permissions match the Chrome source unchanged. + browser_specific_settings: { + // min versions = where data_collection_permissions is honored (FF 140 / Android 142), + // not where MV3 starts — avoids the AMO "key ignored below this version" warning. + gecko: { + id: 'web-clipper@memrynote.com', + strict_min_version: '140.0', + // AMO requires this for new add-ons (since 2025-11-03). Captures go only to the + // user's own desktop app on 127.0.0.1 — nothing is collected by the developer. + data_collection_permissions: { required: ['none'] } + }, + gecko_android: { + strict_min_version: '142.0' + } } }, vite: () => ({