Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/desktop/src/main/capture/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/capture/pairing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/capture/pairing.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/capture/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/capture/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions apps/docs/src/user-guide/inbox/capturing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions apps/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 6 additions & 0 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions apps/extension/wxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({
Expand Down
Loading