Skip to content
Draft
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
18 changes: 17 additions & 1 deletion apps/desktop/src/main/capture/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { validateCaptureRequest } from './auth'
import { validateCaptureRequest, isExtensionOrigin } from './auth'

const TOKEN = 'a'.repeat(64)
const allow = (o: string | undefined) => o === 'chrome-extension://abc'
Expand Down Expand Up @@ -40,3 +40,19 @@ describe('validateCaptureRequest', () => {
).toBe(false)
})
})

describe('isExtensionOrigin', () => {
it('accepts a Safari web-extension origin', () => {
expect(isExtensionOrigin('safari-web-extension://A1B2C3D4-0000-0000-0000-000000000000')).toBe(
true
)
})
it('accepts chrome and firefox extension origins', () => {
expect(isExtensionOrigin('chrome-extension://abc')).toBe(true)
expect(isExtensionOrigin('moz-extension://abc')).toBe(true)
})
it('rejects a web origin and undefined', () => {
expect(isExtensionOrigin('https://evil.com')).toBe(false)
expect(isExtensionOrigin(undefined)).toBe(false)
})
})
14 changes: 14 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,20 @@ export interface CaptureAuthHeaders {

type Result = { ok: true } | { ok: false; reason: string }

// Capture pairing accepts browser-extension origins only — Chromium (chrome-extension://),
// Firefox (moz-extension://), and Safari (safari-web-extension://). Single source of truth
// so the two pairing guards can't drift.
const EXTENSION_ORIGIN_PREFIXES = [
'chrome-extension://',
'moz-extension://',
'safari-web-extension://'
]

export function isExtensionOrigin(origin: string | undefined): boolean {
if (!origin) return false
return EXTENSION_ORIGIN_PREFIXES.some((prefix) => origin.startsWith(prefix))
}

function tokenEquals(a: string, b: string): boolean {
const ab = Buffer.from(a)
const bb = Buffer.from(b)
Expand Down
11 changes: 11 additions & 0 deletions apps/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ 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.

## Build for Safari (macOS)

Safari ships the extension inside a native macOS app. WXT builds the bundle; Xcode wraps it.

1. `pnpm --filter @memry/extension build:safari` → `.output/safari-mv3` (MV3, matching the
Chrome/Firefox source).
2. Wrap it into the macOS container app (see the Xcode project at `apps/extension/safari/`).

`build:safari` only produces the web bundle — the native app, signing, and App Store
submission are covered by the Safari release runbook below.

## Manual QA (the Phase-3 acceptance gate — human-required)

Run the desktop app first: `pnpm dev`.
Expand Down
3 changes: 3 additions & 0 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip",
"dev:safari": "wxt -b safari --mv3",
"build:safari": "wxt build -b safari --mv3",
"zip:safari": "wxt zip -b safari --mv3",
"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
10 changes: 10 additions & 0 deletions apps/extension/safari/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Xcode per-user state
xcuserdata/
*.xcuserstate
.DS_Store

# Web extension bundle copied in by the converter (--copy-resources).
# Regenerate before archiving:
# pnpm --filter @memry/extension build:safari
# xcrun safari-web-extension-converter --rebuild-project "MemryNote Web Clipper" --macos-only
MemryNote Web Clipper/MemryNote Web Clipper Extension/Resources/
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.Safari.web-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
</dict>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// SafariWebExtensionHandler.swift
// MemryNote Web Clipper Extension
//
// Created by Kaan Karaca on 1.07.2026.
//

import SafariServices
import os.log

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {

func beginRequest(with context: NSExtensionContext) {
let request = context.inputItems.first as? NSExtensionItem

let profile: UUID?
if #available(iOS 17.0, macOS 14.0, *) {
profile = request?.userInfo?[SFExtensionProfileKey] as? UUID
} else {
profile = request?.userInfo?["profile"] as? UUID
}

let message: Any?
if #available(iOS 15.0, macOS 11.0, *) {
message = request?.userInfo?[SFExtensionMessageKey]
} else {
message = request?.userInfo?["message"]
}

os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none")

let response = NSExtensionItem()
if #available(iOS 15.0, macOS 11.0, *) {
response.userInfo = [ SFExtensionMessageKey: [ "echo": message ] ]
} else {
response.userInfo = [ "message": [ "echo": message ] ]
}

context.completeRequest(returningItems: [ response ], completionHandler: nil)
}

}
Loading