feat: capture binary files for browser inbox

This commit is contained in:
mirivlad 2026-06-29 10:12:26 +08:00
parent 8970333d13
commit 7b4db61a9c
8 changed files with 43 additions and 15 deletions

View File

@ -1,7 +1,7 @@
# verstak-browser-extension # verstak-browser-extension
Verstak Browser Extension captures pages, selected text, links, and selected Verstak Browser Extension captures pages, selected text, links, and selected
text files and sends them to a local Verstak browser inbox receiver. files and sends them to a local Verstak browser inbox receiver.
The extension does not know Notes, Files, Activity, or Journal internals. It The extension does not know Notes, Files, Activity, or Journal internals. It
only sends capture events through the public local receiver protocol. If the only sends capture events through the public local receiver protocol. If the
@ -84,7 +84,8 @@ Supported `kind` values:
- `page` - `page`
- `selection`, with `selection.text` - `selection`, with `selection.text`
- `link`, with `link.url` and optional `link.text` - `link`, with `link.url` and optional `link.text`
- `file`, with `file.name` and `file.text` for selected text files up to 2 MB - `file`, with `file.name` and either `file.dataBase64` for selected files up to
8 MB or `file.text` for text-compatible captures
Expected success response: Expected success response:

View File

@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.1",
"description": "Send pages, selections, links, and text files to the local Verstak browser inbox.", "description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
"permissions": ["contextMenus", "storage", "tabs"], "permissions": ["contextMenus", "storage", "tabs"],

View File

@ -2,7 +2,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.1",
"description": "Send pages, selections, links, and text files to the local Verstak browser inbox.", "description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
"browser_specific_settings": { "browser_specific_settings": {

View File

@ -32,16 +32,18 @@ const file = protocol.buildCapture({
fileName: 'notes.txt', fileName: 'notes.txt',
fileMime: 'text/plain', fileMime: 'text/plain',
fileSize: 12, fileSize: 12,
fileText: 'hello file' fileText: 'hello file',
fileDataBase64: 'aGVsbG8gZmlsZQ=='
}); });
assert.equal(file.file.name, 'notes.txt'); assert.equal(file.file.name, 'notes.txt');
assert.equal(file.file.mime, 'text/plain'); assert.equal(file.file.mime, 'text/plain');
assert.equal(file.file.size, 12); assert.equal(file.file.size, 12);
assert.equal(file.file.text, 'hello file'); assert.equal(file.file.text, 'hello file');
assert.equal(file.file.dataBase64, 'aGVsbG8gZmlsZQ==');
assert.equal(protocol.validateCapture(file), true); assert.equal(protocol.validateCapture(file), true);
assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'link', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' } }), /link.url/); assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'link', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' } }), /link.url/);
assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'file', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' }, file: { name: 'notes.txt' } }), /file.text/); assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'file', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' }, file: { name: 'notes.txt' } }), /file.text or file.dataBase64/);
let request; let request;
const fetchOk = (url, options) => { const fetchOk = (url, options) => {

View File

@ -50,7 +50,8 @@
fileName: info.fileName || '', fileName: info.fileName || '',
fileMime: info.fileMime || '', fileMime: info.fileMime || '',
fileSize: info.fileSize || 0, fileSize: info.fileSize || 0,
fileText: info.fileText || '' fileText: info.fileText || '',
fileDataBase64: info.fileDataBase64 || ''
}); });
} }

View File

@ -37,7 +37,7 @@
</section> </section>
<section class="file-picker"> <section class="file-picker">
<label for="file-input">Text file</label> <label for="file-input">File</label>
<input id="file-input" type="file"> <input id="file-input" type="file">
</section> </section>

View File

@ -10,6 +10,24 @@
var pendingCountEl = document.getElementById('pending-count'); var pendingCountEl = document.getElementById('pending-count');
var statusDotEl = document.getElementById('status-dot'); var statusDotEl = document.getElementById('status-dot');
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
var MAX_FILE_BYTES = 8 * 1024 * 1024;
function arrayBufferToBase64(buffer) {
var bytes = new Uint8Array(buffer);
var chunkSize = 0x8000;
var binary = '';
for (var i = 0; i < bytes.length; i += chunkSize) {
var chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
function readOptionalText(file) {
if (file.size > MAX_FILE_TEXT_LENGTH) return Promise.resolve('');
if (file.type && file.type.indexOf('text/') !== 0 && file.type !== 'application/json') return Promise.resolve('');
return file.text().catch(function () { return ''; });
}
function setStatus(text) { function setStatus(text) {
statusEl.textContent = text; statusEl.textContent = text;
@ -75,19 +93,21 @@
setStatus('Choose a text file first'); setStatus('Choose a text file first');
return; return;
} }
if (file.size > MAX_FILE_TEXT_LENGTH) { if (file.size > MAX_FILE_BYTES) {
setStatus('File is too large for text capture'); setStatus('File is too large for browser capture');
return; return;
} }
setStatus('Reading file...'); setStatus('Reading file...');
file.text().then(function (content) { Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) {
var content = results[1] || '';
send({ send({
type: 'verstak.capture', type: 'verstak.capture',
kind: 'file', kind: 'file',
fileName: file.name, fileName: file.name,
fileMime: file.type || 'text/plain', fileMime: file.type || '',
fileSize: file.size, fileSize: file.size,
fileText: content fileText: content,
fileDataBase64: arrayBufferToBase64(results[0])
}); });
}).catch(function (err) { }).catch(function (err) {
setStatus(err && err.message ? err.message : String(err)); setStatus(err && err.message ? err.message : String(err));

View File

@ -4,6 +4,7 @@
var CAPTURE_SCHEMA_VERSION = 1; var CAPTURE_SCHEMA_VERSION = 1;
var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
var MAX_FILE_BYTES = 8 * 1024 * 1024;
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@ -73,7 +74,8 @@
name: cleanString(input.fileName || '', 255), name: cleanString(input.fileName || '', 255),
mime: cleanString(input.fileMime || '', 128), mime: cleanString(input.fileMime || '', 128),
size: Number.isFinite(fileSize) && fileSize >= 0 ? fileSize : fileText.length, size: Number.isFinite(fileSize) && fileSize >= 0 ? fileSize : fileText.length,
text: fileText text: fileText,
dataBase64: cleanString(input.fileDataBase64 || '', Math.ceil(MAX_FILE_BYTES * 4 / 3) + 4)
}; };
} }
if (input.context) payload.context = input.context; if (input.context) payload.context = input.context;
@ -90,7 +92,8 @@
if (payload.kind === 'selection' && (!payload.selection || !payload.selection.text)) throw new Error('selection.text 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'); if (payload.kind === 'link' && (!payload.link || !payload.link.url)) throw new Error('link.url is required');
if (payload.kind === 'file' && (!payload.file || !payload.file.name)) throw new Error('file.name is required'); if (payload.kind === 'file' && (!payload.file || !payload.file.name)) throw new Error('file.name is required');
if (payload.kind === 'file' && (!payload.file || !payload.file.text)) throw new Error('file.text is required'); if (payload.kind === 'file' && Number(payload.file.size || 0) > MAX_FILE_BYTES) throw new Error('file.size exceeds limit');
if (payload.kind === 'file' && (!payload.file || (!payload.file.text && !payload.file.dataBase64))) throw new Error('file.text or file.dataBase64 is required');
return true; return true;
} }
@ -98,6 +101,7 @@
CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION, CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION,
DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL, DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL,
MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH, MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH,
MAX_FILE_BYTES: MAX_FILE_BYTES,
buildCapture: buildCapture, buildCapture: buildCapture,
validateCapture: validateCapture validateCapture: validateCapture
}; };