From 44ca183f50a70cee5094feaf89af805ef2e087cd Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 27 Jun 2026 13:44:19 +0800 Subject: [PATCH] Scaffold browser capture extension --- .gitignore | 2 + README.md | 65 +++++++++++++++++++++++++++- chromium/manifest.json | 15 +++++++ firefox/manifest.json | 14 ++++++ package-lock.json | 13 ++++++ package.json | 11 +++++ scripts/build-extension.js | 55 ++++++++++++++++++++++++ scripts/test-protocol.js | 66 +++++++++++++++++++++++++++++ shared/api.js | 26 ++++++++++++ shared/background.js | 81 +++++++++++++++++++++++++++++++++++ shared/popup/popup.css | 32 ++++++++++++++ shared/popup/popup.html | 16 +++++++ shared/popup/popup.js | 32 ++++++++++++++ shared/protocol.js | 87 ++++++++++++++++++++++++++++++++++++++ shared/queue.js | 85 +++++++++++++++++++++++++++++++++++++ 15 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 chromium/manifest.json create mode 100644 firefox/manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/build-extension.js create mode 100755 scripts/test-protocol.js create mode 100644 shared/api.js create mode 100644 shared/background.js create mode 100644 shared/popup/popup.css create mode 100644 shared/popup/popup.html create mode 100644 shared/popup/popup.js create mode 100644 shared/protocol.js create mode 100644 shared/queue.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/README.md b/README.md index 9054e46..01dddca 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,66 @@ # verstak-browser-extension -Verstak Browser Extension — Firefox/Chromium, page/text capture, link sending, pending queue, domain bindings \ No newline at end of file +Verstak Browser Extension captures pages, selected text, and links and sends +them to a local Verstak browser inbox receiver. + +The extension does not know Notes, Files, Activity, or Journal internals. It +only sends capture events through the public local receiver protocol. If the +receiver is offline, captures stay in the extension pending queue. + +## Build + +```bash +npm ci +npm test +npm run build +``` + +Build output: + +- `dist/chromium` +- `dist/firefox` + +## Local Receiver Protocol + +Default endpoint: + +```text +POST http://127.0.0.1:47731/api/browser-inbox/v1/captures +``` + +Headers: + +- `Content-Type: application/json` +- `X-Verstak-Receiver-Token: ` optional, once pairing is implemented + +Payload: + +```json +{ + "schemaVersion": 1, + "captureId": "uuid-or-generated-id", + "capturedAt": "2026-06-27T00:00:00.000Z", + "source": "verstak-browser-extension", + "kind": "page", + "page": { + "url": "https://example.com/article", + "title": "Example Article", + "domain": "example.com" + }, + "browser": { + "name": "" + } +} +``` + +Supported `kind` values: + +- `page` +- `selection`, with `selection.text` +- `link`, with `link.url` and optional `link.text` + +Expected success response: + +```json +{ "status": "accepted", "captureId": "uuid-or-generated-id" } +``` diff --git a/chromium/manifest.json b/chromium/manifest.json new file mode 100644 index 0000000..b904a94 --- /dev/null +++ b/chromium/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 3, + "name": "Verstak Capture", + "version": "0.1.0", + "description": "Send pages, selections, and links to the local Verstak browser inbox.", + "permissions": ["contextMenus", "storage", "tabs"], + "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup/popup.html", + "default_title": "Verstak Capture" + } +} diff --git a/firefox/manifest.json b/firefox/manifest.json new file mode 100644 index 0000000..3b60466 --- /dev/null +++ b/firefox/manifest.json @@ -0,0 +1,14 @@ +{ + "manifest_version": 2, + "name": "Verstak Capture", + "version": "0.1.0", + "description": "Send pages, selections, and links to the local Verstak browser inbox.", + "permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"], + "background": { + "scripts": ["protocol.js", "api.js", "queue.js", "background.js"] + }, + "browser_action": { + "default_popup": "popup/popup.html", + "default_title": "Verstak Capture" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..458db5b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "verstak-browser-extension", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "verstak-browser-extension", + "version": "0.1.0", + "devDependencies": {} + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a49f378 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "verstak-browser-extension", + "version": "0.1.0", + "private": true, + "description": "Verstak browser capture extension for Chromium and Firefox", + "scripts": { + "build": "node scripts/build-extension.js", + "test": "node scripts/test-protocol.js" + }, + "devDependencies": {} +} diff --git a/scripts/build-extension.js b/scripts/build-extension.js new file mode 100755 index 0000000..de02959 --- /dev/null +++ b/scripts/build-extension.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const dist = path.join(root, 'dist'); +const shared = path.join(root, 'shared'); + +function rm(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function mkdir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function copy(src, dest) { + mkdir(path.dirname(dest)); + fs.copyFileSync(src, dest); +} + +function concat(files, dest) { + mkdir(path.dirname(dest)); + fs.writeFileSync(dest, files.map((file) => fs.readFileSync(file, 'utf8')).join('\n\n'), 'utf8'); +} + +function copyPopup(destRoot) { + const popupDir = path.join(shared, 'popup'); + for (const name of ['popup.html', 'popup.css', 'popup.js']) { + copy(path.join(popupDir, name), path.join(destRoot, 'popup', name)); + } +} + +rm(dist); + +const chromiumDist = path.join(dist, 'chromium'); +mkdir(chromiumDist); +copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json')); +concat([ + path.join(shared, 'protocol.js'), + path.join(shared, 'api.js'), + path.join(shared, 'queue.js'), + path.join(shared, 'background.js'), +], path.join(chromiumDist, 'background.js')); +copyPopup(chromiumDist); + +const firefoxDist = path.join(dist, 'firefox'); +mkdir(firefoxDist); +copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json')); +for (const name of ['protocol.js', 'api.js', 'queue.js', 'background.js']) { + copy(path.join(shared, name), path.join(firefoxDist, name)); +} +copyPopup(firefoxDist); + +console.log('built dist/chromium and dist/firefox'); diff --git a/scripts/test-protocol.js b/scripts/test-protocol.js new file mode 100755 index 0000000..74e1c52 --- /dev/null +++ b/scripts/test-protocol.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +const assert = require('assert'); + +const protocol = require('../shared/protocol'); +require('../shared/api'); +const queueApi = require('../shared/queue'); + +const page = protocol.buildCapture({ + kind: 'page', + captureId: 'test-capture-id', + url: 'https://example.com/docs', + title: 'Example Docs' +}); +assert.equal(page.schemaVersion, 1); +assert.equal(page.captureId, 'test-capture-id'); +assert.equal(page.page.domain, 'example.com'); +assert.equal(protocol.validateCapture(page), true); + +const selection = protocol.buildCapture({ + kind: 'selection', + url: 'https://example.com/docs', + title: 'Example Docs', + selectionText: ' selected text ' +}); +assert.equal(selection.selection.text, 'selected text'); +assert.equal(protocol.validateCapture(selection), true); + +assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'link', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' } }), /link.url/); + +let request; +const fetchOk = (url, options) => { + request = { url, options }; + return Promise.resolve({ status: 202, json: () => Promise.resolve({ status: 'accepted' }) }); +}; + +globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', page, fetchOk) + .then((result) => { + assert.equal(result.status, 'accepted'); + assert.equal(request.url, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'); + assert.equal(request.options.headers['X-Verstak-Receiver-Token'], 'token'); + assert.equal(JSON.parse(request.options.body).captureId, 'test-capture-id'); + }) + .then(() => { + const queue = new queueApi.CaptureQueue(queueApi.createMemoryStorage()); + return queue.enqueue(page) + .then(() => queue.enqueue(selection)) + .then(() => queue.retry((payload) => { + if (payload.kind === 'page') return Promise.resolve(); + return Promise.reject(new Error('offline')); + })) + .then((result) => { + assert.deepEqual(result, { sent: 1, pending: 1 }); + return queue.list(); + }) + .then((items) => { + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'selection'); + }); + }) + .then(() => { + console.log('browser extension protocol tests passed'); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/shared/api.js b/shared/api.js new file mode 100644 index 0000000..54c53dc --- /dev/null +++ b/shared/api.js @@ -0,0 +1,26 @@ +(function (root) { + 'use strict'; + + function sendCapture(receiverUrl, token, payload, fetchImpl) { + var protocol = root.VerstakBrowser || {}; + protocol.validateCapture(payload); + var fetchFn = fetchImpl || root.fetch; + if (typeof fetchFn !== 'function') return Promise.reject(new Error('fetch unavailable')); + return fetchFn(receiverUrl || protocol.DEFAULT_RECEIVER_URL, { + method: 'POST', + headers: Object.assign({ + 'Content-Type': 'application/json' + }, token ? { 'X-Verstak-Receiver-Token': token } : {}), + body: JSON.stringify(payload) + }).then(function (response) { + if (!response || response.status < 200 || response.status >= 300) { + throw new Error('receiver rejected capture: HTTP ' + (response && response.status)); + } + return response.json ? response.json() : { status: 'accepted', captureId: payload.captureId }; + }); + } + + var api = { sendCapture: sendCapture }; + root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api); + if (typeof module !== 'undefined') module.exports = api; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/shared/background.js b/shared/background.js new file mode 100644 index 0000000..43b7968 --- /dev/null +++ b/shared/background.js @@ -0,0 +1,81 @@ +(function () { + 'use strict'; + + var ext = typeof browser !== 'undefined' ? browser : chrome; + var protocol = globalThis.VerstakBrowser; + var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext)); + var DEFAULT_SETTINGS = { + receiverUrl: protocol.DEFAULT_RECEIVER_URL, + receiverToken: '' + }; + + function getSettings() { + return ext.storage.local.get('settings').then(function (result) { + return Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {}); + }); + } + + function activeTab() { + return ext.tabs.query({ active: true, currentWindow: true }).then(function (tabs) { + return tabs && tabs[0] || {}; + }); + } + + function captureFromInfo(kind, info, tab) { + return protocol.buildCapture({ + kind: kind, + url: tab && tab.url || info.pageUrl || info.frameUrl || '', + title: tab && tab.title || '', + selectionText: info.selectionText || '', + linkUrl: info.linkUrl || '', + linkText: info.selectionText || '' + }); + } + + function sendOrQueue(payload) { + return getSettings().then(function (settings) { + return protocol.sendCapture(settings.receiverUrl, settings.receiverToken, payload).catch(function () { + return queue.enqueue(payload).then(function () { + return { status: 'queued', captureId: payload.captureId }; + }); + }); + }); + } + + function retryPending() { + return getSettings().then(function (settings) { + return queue.retry(function (payload) { + return protocol.sendCapture(settings.receiverUrl, settings.receiverToken, payload); + }); + }); + } + + function setupContextMenus() { + if (!ext.contextMenus) return; + ext.contextMenus.removeAll(function () { + ext.contextMenus.create({ id: 'verstak-capture-page', title: 'Send page to Verstak', contexts: ['page'] }); + ext.contextMenus.create({ id: 'verstak-capture-selection', title: 'Send selection to Verstak', contexts: ['selection'] }); + ext.contextMenus.create({ id: 'verstak-capture-link', title: 'Send link to Verstak', contexts: ['link'] }); + }); + } + + ext.runtime.onInstalled.addListener(setupContextMenus); + if (ext.contextMenus && ext.contextMenus.onClicked) { + ext.contextMenus.onClicked.addListener(function (info, tab) { + var kind = info.menuItemId === 'verstak-capture-selection' ? 'selection' + : info.menuItemId === 'verstak-capture-link' ? 'link' + : 'page'; + sendOrQueue(captureFromInfo(kind, info, tab || {})); + }); + } + + ext.runtime.onMessage.addListener(function (message) { + if (!message || message.type !== 'verstak.capture') return undefined; + if (message.action === 'retryPending') { + return retryPending(); + } + return activeTab().then(function (tab) { + return sendOrQueue(captureFromInfo(message.kind || 'page', message, tab)); + }); + }); +})(); diff --git a/shared/popup/popup.css b/shared/popup/popup.css new file mode 100644 index 0000000..476f684 --- /dev/null +++ b/shared/popup/popup.css @@ -0,0 +1,32 @@ +body { + min-width: 220px; + margin: 0; + font: 13px system-ui, sans-serif; + background: #111827; + color: #e5e7eb; +} + +main { + display: grid; + gap: 8px; + padding: 12px; +} + +button { + border: 1px solid #374151; + border-radius: 4px; + padding: 8px 10px; + background: #1f2937; + color: #f9fafb; + cursor: pointer; +} + +button:hover { + border-color: #10b981; +} + +#status { + min-height: 18px; + margin: 0; + color: #9ca3af; +} diff --git a/shared/popup/popup.html b/shared/popup/popup.html new file mode 100644 index 0000000..c44bd3d --- /dev/null +++ b/shared/popup/popup.html @@ -0,0 +1,16 @@ + + + + + + + +
+ + + +

+
+ + + diff --git a/shared/popup/popup.js b/shared/popup/popup.js new file mode 100644 index 0000000..560916c --- /dev/null +++ b/shared/popup/popup.js @@ -0,0 +1,32 @@ +(function () { + 'use strict'; + + var ext = typeof browser !== 'undefined' ? browser : chrome; + var statusEl = document.getElementById('status'); + + function setStatus(text) { + statusEl.textContent = text; + } + + function send(message) { + setStatus('Sending...'); + Promise.resolve(ext.runtime.sendMessage(message)).then(function (result) { + if (result && result.status === 'queued') setStatus('Queued until Verstak is available'); + else setStatus('Sent'); + }).catch(function (err) { + setStatus(err && err.message ? err.message : String(err)); + }); + } + + document.getElementById('capture-page').addEventListener('click', function () { + send({ type: 'verstak.capture', kind: 'page' }); + }); + + document.getElementById('capture-selection').addEventListener('click', function () { + send({ type: 'verstak.capture', kind: 'selection' }); + }); + + document.getElementById('retry').addEventListener('click', function () { + send({ type: 'verstak.capture', action: 'retryPending' }); + }); +})(); diff --git a/shared/protocol.js b/shared/protocol.js new file mode 100644 index 0000000..9887ed0 --- /dev/null +++ b/shared/protocol.js @@ -0,0 +1,87 @@ +(function (root) { + 'use strict'; + + var CAPTURE_SCHEMA_VERSION = 1; + var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; + + function nowIso() { + return new Date().toISOString(); + } + + function randomId() { + var cryptoObj = root.crypto || (root.require && root.require('crypto')); + if (cryptoObj && cryptoObj.randomUUID) return cryptoObj.randomUUID(); + return 'cap_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2); + } + + function cleanString(value, maxLength) { + var text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim(); + if (maxLength && text.length > maxLength) return text.slice(0, maxLength); + return text; + } + + function hostname(url) { + try { + return new URL(url).hostname; + } catch (_) { + return ''; + } + } + + function buildCapture(input) { + input = input || {}; + var kind = input.kind || 'page'; + var pageURL = cleanString(input.url || input.pageUrl || '', 4096); + var payload = { + schemaVersion: CAPTURE_SCHEMA_VERSION, + captureId: input.captureId || randomId(), + capturedAt: input.capturedAt || nowIso(), + source: 'verstak-browser-extension', + kind: kind, + page: { + url: pageURL, + title: cleanString(input.title || '', 512), + domain: hostname(pageURL) + }, + browser: { + name: cleanString(input.browserName || '', 64) + } + }; + + if (kind === 'selection') { + payload.selection = { + text: cleanString(input.selectionText || input.text || '', 20000) + }; + } + if (kind === 'link') { + payload.link = { + url: cleanString(input.linkUrl || '', 4096), + text: cleanString(input.linkText || input.selectionText || '', 512) + }; + } + if (input.context) payload.context = input.context; + return payload; + } + + function validateCapture(payload) { + if (!payload || typeof payload !== 'object') throw new Error('payload must be an object'); + if (payload.schemaVersion !== CAPTURE_SCHEMA_VERSION) throw new Error('unsupported schemaVersion'); + if (!payload.captureId) throw new Error('captureId is required'); + if (!payload.capturedAt) throw new Error('capturedAt is required'); + if (['page', 'selection', 'link'].indexOf(payload.kind) === -1) throw new Error('unsupported kind'); + if (!payload.page || !payload.page.url) throw new Error('page.url is required'); + if (payload.kind === 'selection' && (!payload.selection || !payload.selection.text)) throw new Error('selection.text is required'); + if (payload.kind === 'link' && (!payload.link || !payload.link.url)) throw new Error('link.url is required'); + return true; + } + + var api = { + CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION, + DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL, + buildCapture: buildCapture, + validateCapture: validateCapture + }; + + root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api); + if (typeof module !== 'undefined') module.exports = api; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/shared/queue.js b/shared/queue.js new file mode 100644 index 0000000..2c1a3a0 --- /dev/null +++ b/shared/queue.js @@ -0,0 +1,85 @@ +(function (root) { + 'use strict'; + + var QUEUE_KEY = 'verstak.pendingCaptures'; + + function createMemoryStorage(seed) { + var state = Object.assign({}, seed || {}); + return { + get: function (key) { + return Promise.resolve(Object.prototype.hasOwnProperty.call(state, key) ? state[key] : undefined); + }, + set: function (key, value) { + state[key] = value; + return Promise.resolve(); + } + }; + } + + function browserStorageAdapter(browserApi) { + var storage = browserApi && browserApi.storage && browserApi.storage.local; + if (!storage) return createMemoryStorage(); + return { + get: function (key) { + return storage.get(key).then(function (result) { return result && result[key]; }); + }, + set: function (key, value) { + var patch = {}; + patch[key] = value; + return storage.set(patch); + } + }; + } + + function CaptureQueue(storage) { + this.storage = storage || createMemoryStorage(); + } + + CaptureQueue.prototype.list = function () { + return this.storage.get(QUEUE_KEY).then(function (items) { + return Array.isArray(items) ? items : []; + }); + }; + + CaptureQueue.prototype.enqueue = function (payload) { + var self = this; + return this.list().then(function (items) { + items.push(payload); + return self.storage.set(QUEUE_KEY, items).then(function () { return items; }); + }); + }; + + CaptureQueue.prototype.replace = function (items) { + return this.storage.set(QUEUE_KEY, Array.isArray(items) ? items : []); + }; + + CaptureQueue.prototype.retry = function (sender) { + var self = this; + return this.list().then(function (items) { + var sent = 0; + var pending = []; + return items.reduce(function (chain, item) { + return chain.then(function () { + return sender(item).then(function () { + sent += 1; + }).catch(function () { + pending.push(item); + }); + }); + }, Promise.resolve()).then(function () { + return self.replace(pending).then(function () { + return { sent: sent, pending: pending.length }; + }); + }); + }); + }; + + var api = { + QUEUE_KEY: QUEUE_KEY, + CaptureQueue: CaptureQueue, + browserStorageAdapter: browserStorageAdapter, + createMemoryStorage: createMemoryStorage + }; + root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api); + if (typeof module !== 'undefined') module.exports = api; +})(typeof globalThis !== 'undefined' ? globalThis : this);