From 7b4db61a9c49b4d46e0656fb13997b83441911f1 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 29 Jun 2026 10:12:26 +0800 Subject: [PATCH] feat: capture binary files for browser inbox --- README.md | 5 +++-- chromium/manifest.json | 2 +- firefox/manifest.json | 2 +- scripts/test-protocol.js | 6 ++++-- shared/background.js | 3 ++- shared/popup/popup.html | 2 +- shared/popup/popup.js | 30 +++++++++++++++++++++++++----- shared/protocol.js | 8 ++++++-- 8 files changed, 43 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6e4a6b1..98a5be8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # verstak-browser-extension 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 only sends capture events through the public local receiver protocol. If the @@ -84,7 +84,8 @@ Supported `kind` values: - `page` - `selection`, with `selection.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: diff --git a/chromium/manifest.json b/chromium/manifest.json index e67d814..59639bd 100644 --- a/chromium/manifest.json +++ b/chromium/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Verstak Bridge", "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", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "permissions": ["contextMenus", "storage", "tabs"], diff --git a/firefox/manifest.json b/firefox/manifest.json index 550f9bd..28c30c2 100644 --- a/firefox/manifest.json +++ b/firefox/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "Verstak Bridge", "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", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "browser_specific_settings": { diff --git a/scripts/test-protocol.js b/scripts/test-protocol.js index f278238..81a9ca0 100755 --- a/scripts/test-protocol.js +++ b/scripts/test-protocol.js @@ -32,16 +32,18 @@ const file = protocol.buildCapture({ fileName: 'notes.txt', fileMime: 'text/plain', fileSize: 12, - fileText: 'hello file' + fileText: 'hello file', + fileDataBase64: 'aGVsbG8gZmlsZQ==' }); assert.equal(file.file.name, 'notes.txt'); assert.equal(file.file.mime, 'text/plain'); assert.equal(file.file.size, 12); assert.equal(file.file.text, 'hello file'); +assert.equal(file.file.dataBase64, 'aGVsbG8gZmlsZQ=='); 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: '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; const fetchOk = (url, options) => { diff --git a/shared/background.js b/shared/background.js index 6972a94..b08f676 100644 --- a/shared/background.js +++ b/shared/background.js @@ -50,7 +50,8 @@ fileName: info.fileName || '', fileMime: info.fileMime || '', fileSize: info.fileSize || 0, - fileText: info.fileText || '' + fileText: info.fileText || '', + fileDataBase64: info.fileDataBase64 || '' }); } diff --git a/shared/popup/popup.html b/shared/popup/popup.html index 7c5701b..6d8207a 100644 --- a/shared/popup/popup.html +++ b/shared/popup/popup.html @@ -37,7 +37,7 @@
- +
diff --git a/shared/popup/popup.js b/shared/popup/popup.js index e666b34..490e618 100644 --- a/shared/popup/popup.js +++ b/shared/popup/popup.js @@ -10,6 +10,24 @@ var pendingCountEl = document.getElementById('pending-count'); var statusDotEl = document.getElementById('status-dot'); 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) { statusEl.textContent = text; @@ -75,19 +93,21 @@ setStatus('Choose a text file first'); return; } - if (file.size > MAX_FILE_TEXT_LENGTH) { - setStatus('File is too large for text capture'); + if (file.size > MAX_FILE_BYTES) { + setStatus('File is too large for browser capture'); return; } setStatus('Reading file...'); - file.text().then(function (content) { + Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) { + var content = results[1] || ''; send({ type: 'verstak.capture', kind: 'file', fileName: file.name, - fileMime: file.type || 'text/plain', + fileMime: file.type || '', fileSize: file.size, - fileText: content + fileText: content, + fileDataBase64: arrayBufferToBase64(results[0]) }); }).catch(function (err) { setStatus(err && err.message ? err.message : String(err)); diff --git a/shared/protocol.js b/shared/protocol.js index 98cd2d2..dc01f95 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -4,6 +4,7 @@ var CAPTURE_SCHEMA_VERSION = 1; 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_BYTES = 8 * 1024 * 1024; function nowIso() { return new Date().toISOString(); @@ -73,7 +74,8 @@ name: cleanString(input.fileName || '', 255), mime: cleanString(input.fileMime || '', 128), 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; @@ -90,7 +92,8 @@ 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 === '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; } @@ -98,6 +101,7 @@ CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION, DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL, MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH, + MAX_FILE_BYTES: MAX_FILE_BYTES, buildCapture: buildCapture, validateCapture: validateCapture };