Scaffold browser capture extension

This commit is contained in:
2026-06-27 13:44:19 +08:00
parent fecf61a375
commit 44ca183f50
15 changed files with 599 additions and 1 deletions
+26
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
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
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
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
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
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
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);