Scaffold browser capture extension

This commit is contained in:
mirivlad 2026-06-27 13:44:19 +08:00
parent fecf61a375
commit 44ca183f50
15 changed files with 599 additions and 1 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
node_modules/

View File

@ -1,3 +1,66 @@
# verstak-browser-extension
Verstak Browser Extension — Firefox/Chromium, page/text capture, link sending, pending queue, domain bindings
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: <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" }
```

15
chromium/manifest.json Normal file
View File

@ -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"
}
}

14
firefox/manifest.json Normal file
View File

@ -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"
}
}

13
package-lock.json generated Normal file
View File

@ -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": {}
}
}
}

11
package.json Normal file
View File

@ -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": {}
}

55
scripts/build-extension.js Executable file
View File

@ -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');

66
scripts/test-protocol.js Executable file
View File

@ -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);
});

26
shared/api.js Normal file
View File

@ -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);

81
shared/background.js Normal file
View File

@ -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));
});
});
})();

32
shared/popup/popup.css Normal file
View File

@ -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;
}

16
shared/popup/popup.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<main>
<button id="capture-page">Send Page</button>
<button id="capture-selection">Send Selection</button>
<button id="retry">Retry Pending</button>
<p id="status"></p>
</main>
<script src="popup.js"></script>
</body>
</html>

32
shared/popup/popup.js Normal file
View File

@ -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' });
});
})();

87
shared/protocol.js Normal file
View File

@ -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);

85
shared/queue.js Normal file
View File

@ -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);