From 9928094b77c97cb23b4ef751a70f9ac865d391a3 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 27 Jun 2026 19:07:37 +0800 Subject: [PATCH] Add browser inbox plugin --- plugins/browser-inbox/frontend/src/index.js | 352 ++++++++++++++++++++ plugins/browser-inbox/plugin.json | 31 ++ scripts/check.sh | 2 + scripts/smoke-browser-inbox-plugin.js | 244 ++++++++++++++ 4 files changed, 629 insertions(+) create mode 100644 plugins/browser-inbox/frontend/src/index.js create mode 100644 plugins/browser-inbox/plugin.json create mode 100644 scripts/smoke-browser-inbox-plugin.js diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js new file mode 100644 index 0000000..e4df8ed --- /dev/null +++ b/plugins/browser-inbox/frontend/src/index.js @@ -0,0 +1,352 @@ +/* =========================================================== + Browser Inbox Plugin — Verstak v2 Frontend Bundle + Contract: window.VerstakPluginRegister(id, { components }) + =========================================================== */ + +(function () { + 'use strict'; + + var PLUGIN_ID = 'verstak.browser-inbox'; + var CAPTURE_EVENTS = ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link']; + var MAX_CAPTURES = 100; + + function injectStyles() { + if (document.getElementById('browser-inbox-style-injected')) return; + var style = document.createElement('style'); + style.id = 'browser-inbox-style-injected'; + style.textContent = STYLES; + document.head.appendChild(style); + } + + var STYLES = [ + '.browser-inbox-root{display:flex;flex-direction:column;height:100%;min-height:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}', + '.browser-inbox-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-shrink:0;flex-wrap:wrap}', + '.browser-inbox-title{font-size:.82rem;font-weight:600;color:#e0e0e0}', + '.browser-inbox-count{font-size:.72rem;color:#8b8ba8}', + '.browser-inbox-spacer{flex:1}', + '.browser-inbox-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}', + '.browser-inbox-btn:hover{background:#2a2a4e;border-color:#4ecca3}', + '.browser-inbox-btn:disabled{opacity:.45;cursor:default}', + '.browser-inbox-btn.danger{border-color:#633;color:#ff9a9a}', + '.browser-inbox-status{font-size:.72rem;color:#8b8ba8;white-space:nowrap}', + '.browser-inbox-status.error{color:#e74c3c}', + '.browser-inbox-body{display:grid;grid-template-columns:minmax(260px,360px) minmax(0,1fr);flex:1;min-height:0}', + '.browser-inbox-list{min-height:0;overflow:auto;border-right:1px solid #16213e;background:#101020}', + '.browser-inbox-empty{height:100%;display:flex;align-items:center;justify-content:center;color:#666;font-size:.86rem;padding:2rem;text-align:center}', + '.browser-inbox-row{display:flex;flex-direction:column;gap:.22rem;padding:.65rem .75rem;border-bottom:1px solid rgba(22,33,62,.6);cursor:pointer}', + '.browser-inbox-row:hover{background:#17172d}', + '.browser-inbox-row.selected{background:#1a2a3a}', + '.browser-inbox-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}', + '.browser-inbox-kind{font-size:.68rem;color:#4ecca3;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}', + '.browser-inbox-row-title{font-size:.86rem;color:#e0e0e0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', + '.browser-inbox-row-url{font-size:.72rem;color:#8b8ba8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', + '.browser-inbox-row-text{font-size:.76rem;color:#aaa;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}', + '.browser-inbox-detail{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:auto;padding:1rem;gap:.75rem}', + '.browser-inbox-detail-empty{margin:auto;color:#666;font-size:.86rem}', + '.browser-inbox-detail-title{font-size:1rem;font-weight:600;color:#f0f0f0;word-break:break-word}', + '.browser-inbox-meta{display:grid;grid-template-columns:7rem minmax(0,1fr);gap:.35rem .75rem;font-size:.78rem}', + '.browser-inbox-meta-label{color:#777}', + '.browser-inbox-meta-value{color:#ccc;min-width:0;overflow-wrap:anywhere}', + '.browser-inbox-text{border:1px solid #24304f;background:#101020;border-radius:6px;padding:.75rem;font-size:.85rem;line-height:1.5;color:#ddd;white-space:pre-wrap;overflow-wrap:anywhere}', + '.browser-inbox-detail-actions{display:flex;gap:.5rem;flex-wrap:wrap}', + '@media(max-width:760px){.browser-inbox-body{grid-template-columns:1fr}.browser-inbox-list{border-right:0;border-bottom:1px solid #16213e;max-height:45vh}.browser-inbox-meta{grid-template-columns:1fr}}' + ].join('\n'); + + function el(tag, attrs, children) { + var elem = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function (k) { + if (k === 'className') elem.className = attrs[k]; + else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(elem.style, attrs[k]); + else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]); + else if (k === 'textContent') elem.textContent = attrs[k]; + else elem.setAttribute(k, attrs[k]); + }); + } + if (children) { + (Array.isArray(children) ? children : [children]).forEach(function (child) { + if (child == null) return; + elem.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); + }); + } + return elem; + } + + function text(value) { + return String(value == null ? '' : value); + } + + function cleanKind(value) { + value = text(value).trim(); + return value === 'selection' || value === 'link' || value === 'page' ? value : 'page'; + } + + function displayTitle(capture) { + return capture.title || capture.url || capture.captureId || 'Untitled capture'; + } + + function eventPayload(event) { + if (!event || !event.payload) return {}; + return event.payload; + } + + function captureFromEvent(event) { + var payload = eventPayload(event); + var captureId = text(payload.captureId).trim(); + if (!captureId) { + captureId = 'capture-' + Date.now() + '-' + Math.random().toString(36).slice(2); + } + return { + captureId: captureId, + capturedAt: text(payload.capturedAt || event.timestamp || new Date().toISOString()), + receivedAt: new Date().toISOString(), + kind: cleanKind(payload.kind || text(event.name).replace('browser.capture.', '')), + url: text(payload.url).trim(), + title: text(payload.title).trim(), + domain: text(payload.domain).trim(), + text: text(payload.text).trim(), + source: text(payload.source).trim(), + browserName: text(payload.browserName).trim() + }; + } + + function normalizeStoredCaptures(value) { + if (!Array.isArray(value)) return []; + return value.filter(function (item) { + return item && typeof item === 'object' && item.captureId; + }).map(function (item) { + return { + captureId: text(item.captureId), + capturedAt: text(item.capturedAt), + receivedAt: text(item.receivedAt), + kind: cleanKind(item.kind), + url: text(item.url), + title: text(item.title), + domain: text(item.domain), + text: text(item.text), + source: text(item.source), + browserName: text(item.browserName) + }; + }).slice(0, MAX_CAPTURES); + } + + function formatDate(value) { + if (!value) return ''; + var date = new Date(value); + if (isNaN(date.getTime())) return text(value); + return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + } + + function BrowserInboxView() {} + + BrowserInboxView.mount = function (containerEl, props, api) { + injectStyles(); + containerEl.innerHTML = ''; + containerEl.className = 'browser-inbox-root'; + containerEl.setAttribute('data-plugin-id', PLUGIN_ID); + + var captures = []; + var selectedId = ''; + var statusText = 'Connecting to receiver events...'; + var statusClass = ''; + var disposed = false; + var unsubscribers = []; + + var toolbar = el('div', { className: 'browser-inbox-toolbar' }); + var titleEl = el('span', { className: 'browser-inbox-title', textContent: 'Browser Inbox' }); + var countEl = el('span', { className: 'browser-inbox-count' }); + var statusEl = el('span', { className: 'browser-inbox-status' }); + var clearBtn = el('button', { + className: 'browser-inbox-btn danger', + 'data-browser-inbox-action': 'clear', + textContent: 'Clear', + onClick: function () { + captures = []; + selectedId = ''; + persist().then(render); + } + }); + toolbar.appendChild(titleEl); + toolbar.appendChild(countEl); + toolbar.appendChild(el('span', { className: 'browser-inbox-spacer' })); + toolbar.appendChild(statusEl); + toolbar.appendChild(clearBtn); + + var body = el('div', { className: 'browser-inbox-body' }); + var listEl = el('div', { className: 'browser-inbox-list' }); + var detailEl = el('div', { className: 'browser-inbox-detail' }); + body.appendChild(listEl); + body.appendChild(detailEl); + containerEl.appendChild(toolbar); + containerEl.appendChild(body); + + function persist() { + if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); + return api.settings.write('captures', captures).catch(function (err) { + statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + + function selectedCapture() { + for (var i = 0; i < captures.length; i += 1) { + if (captures[i].captureId === selectedId) return captures[i]; + } + return captures[0] || null; + } + + function addCapture(capture) { + var existing = captures.some(function (item) { + return item.captureId === capture.captureId; + }); + if (existing) return Promise.resolve(); + captures = [capture].concat(captures).slice(0, MAX_CAPTURES); + selectedId = capture.captureId; + statusText = 'Capture received'; + statusClass = ''; + return persist().then(render); + } + + function removeCapture(captureId) { + captures = captures.filter(function (item) { + return item.captureId !== captureId; + }); + if (selectedId === captureId) selectedId = captures[0] ? captures[0].captureId : ''; + return persist().then(render); + } + + function renderList() { + listEl.innerHTML = ''; + if (captures.length === 0) { + listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.' })); + return; + } + captures.forEach(function (capture) { + var row = el('div', { + className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : ''), + 'data-browser-capture-id': capture.captureId, + onClick: function () { + selectedId = capture.captureId; + render(); + } + }, [ + el('div', { className: 'browser-inbox-row-head' }, [ + el('span', { className: 'browser-inbox-kind', textContent: capture.kind }), + el('span', { className: 'browser-inbox-row-title', textContent: displayTitle(capture) }) + ]), + el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId }) + ]); + if (capture.text) { + row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text })); + } + listEl.appendChild(row); + }); + } + + function renderDetail() { + detailEl.innerHTML = ''; + var capture = selectedCapture(); + if (!capture) { + detailEl.appendChild(el('div', { className: 'browser-inbox-detail-empty', textContent: 'Select a capture to inspect it.' })); + return; + } + selectedId = capture.captureId; + detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: displayTitle(capture) })); + detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [ + el('div', { className: 'browser-inbox-meta-label', textContent: 'Kind' }), + el('div', { className: 'browser-inbox-meta-value', textContent: capture.kind }), + el('div', { className: 'browser-inbox-meta-label', textContent: 'URL' }), + el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }), + el('div', { className: 'browser-inbox-meta-label', textContent: 'Domain' }), + el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }), + el('div', { className: 'browser-inbox-meta-label', textContent: 'Captured' }), + el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }), + el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }), + el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }) + ])); + if (capture.text) { + detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text })); + } + detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, [ + el('button', { + className: 'browser-inbox-btn danger', + 'data-browser-inbox-action': 'remove', + textContent: 'Remove', + onClick: function () { + removeCapture(capture.captureId); + } + }) + ])); + } + + function render() { + countEl.textContent = captures.length + ' item' + (captures.length === 1 ? '' : 's'); + clearBtn.disabled = captures.length === 0; + statusEl.textContent = statusText; + statusEl.className = 'browser-inbox-status' + (statusClass ? ' ' + statusClass : ''); + renderList(); + renderDetail(); + } + + function loadStored() { + if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); + return api.settings.read('captures').then(function (stored) { + captures = normalizeStoredCaptures(stored); + if (!selectedId && captures[0]) selectedId = captures[0].captureId; + }).catch(function (err) { + statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + + function subscribeEvents() { + if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve(); + return Promise.all(CAPTURE_EVENTS.map(function (eventName) { + return api.events.subscribe(eventName, function (event) { + return addCapture(captureFromEvent(event)); + }).then(function (unsubscribe) { + if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); + }); + })).then(function () { + statusText = 'Receiver ready'; + statusClass = ''; + }).catch(function (err) { + statusText = 'Receiver unavailable: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + + render(); + loadStored().then(function () { + if (disposed) return; + render(); + return subscribeEvents(); + }).then(function () { + if (!disposed) render(); + }); + + containerEl.__browserInboxUnmount = function () { + disposed = true; + while (unsubscribers.length > 0) { + try { + unsubscribers.pop()(); + } catch (err) { + console.error('[BrowserInbox] unsubscribe error:', err); + } + } + }; + }; + + BrowserInboxView.unmount = function (containerEl) { + if (containerEl && typeof containerEl.__browserInboxUnmount === 'function') { + containerEl.__browserInboxUnmount(); + delete containerEl.__browserInboxUnmount; + } + }; + + window.VerstakPluginRegister(PLUGIN_ID, { + components: { + BrowserInboxView: BrowserInboxView + } + }); +})(); diff --git a/plugins/browser-inbox/plugin.json b/plugins/browser-inbox/plugin.json new file mode 100644 index 0000000..1557521 --- /dev/null +++ b/plugins/browser-inbox/plugin.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "id": "verstak.browser-inbox", + "name": "Browser Inbox", + "version": "0.1.0", + "apiVersion": "0.1.0", + "description": "Workspace-scoped inbox for browser captures delivered through the local receiver event protocol.", + "source": "official", + "icon": "inbox", + "provides": [ + "browser.inbox" + ], + "permissions": [ + "events.subscribe", + "storage.namespace", + "ui.register" + ], + "frontend": { + "entry": "frontend/src/index.js" + }, + "contributes": { + "workspaceItems": [ + { + "id": "verstak.browser-inbox.workspace", + "title": "Browser Inbox", + "icon": "inbox", + "component": "BrowserInboxView" + } + ] + } +} diff --git a/scripts/check.sh b/scripts/check.sh index 406e485..3a787a1 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -215,6 +215,8 @@ if command -v node &>/dev/null; then report "notes frontend behavior" $? node "$ROOT/scripts/smoke-files-plugin.js" report "files frontend behavior" $? + node "$ROOT/scripts/smoke-browser-inbox-plugin.js" + report "browser inbox frontend behavior" $? else echo " ⚠️ node not available — skipping frontend smoke" fi diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js new file mode 100644 index 0000000..4ed6b4d --- /dev/null +++ b/scripts/smoke-browser-inbox-plugin.js @@ -0,0 +1,244 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js'); +const source = fs.readFileSync(sourcePath, 'utf8'); + +class FakeNode { + constructor(tagName) { + this.tagName = String(tagName || '').toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = {}; + this.style = {}; + this.className = ''; + this.id = ''; + this.value = ''; + this.parentNode = null; + this._innerHTML = ''; + this._textContent = ''; + } + + appendChild(node) { + if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode'); + this.children.push(node); + node.parentNode = this; + return node; + } + + removeChild(node) { + this.children = this.children.filter((child) => child !== node); + node.parentNode = null; + return node; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'id') this.id = String(value); + } + + getAttribute(name) { + return this.attributes[name]; + } + + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + } + + dispatchEvent(type, event = {}) { + const handlers = this.listeners[type] || []; + handlers.forEach((handler) => handler({ + stopPropagation() {}, + preventDefault() {}, + target: this, + ...event, + })); + } + + click() { + this.dispatchEvent('click'); + } + + set innerHTML(value) { + this._innerHTML = String(value || ''); + this.children = []; + } + + get innerHTML() { + return this._innerHTML; + } + + set textContent(value) { + this._textContent = String(value || ''); + this.children = []; + } + + get textContent() { + if (this.tagName === '#TEXT') return this._textContent; + return this._textContent + this.children.map((child) => child.textContent).join(''); + } +} + +function walk(node, fn) { + if (fn(node)) return node; + for (const child of node.children) { + const found = walk(child, fn); + if (found) return found; + } + return null; +} + +function makeDocument() { + return { + body: new FakeNode('body'), + head: new FakeNode('head'), + createElement(tagName) { + return new FakeNode(tagName); + }, + createTextNode(text) { + const node = new FakeNode('#text'); + node.textContent = text; + return node; + }, + getElementById() { + return null; + }, + }; +} + +function loadComponent(document) { + const registry = {}; + const sandbox = { + console, + Date, + document, + window: { + VerstakPluginRegister(pluginId, bundle) { + registry[pluginId] = bundle.components || {}; + }, + }, + }; + sandbox.window.window = sandbox.window; + sandbox.window.document = document; + vm.runInNewContext(source, sandbox, { filename: sourcePath }); + const component = registry['verstak.browser-inbox'] && registry['verstak.browser-inbox'].BrowserInboxView; + if (!component) throw new Error('BrowserInboxView was not registered'); + return component; +} + +function makeApi(initialSettings = {}) { + const settings = { ...initialSettings }; + const handlers = {}; + const unsubscribed = []; + return { + settings, + handlers, + unsubscribed, + events: { + subscribe: async (name, handler) => { + handlers[name] = handler; + return () => { + unsubscribed.push(name); + delete handlers[name]; + }; + }, + }, + settings: { + read: async (key) => (key ? settings[key] : { ...settings }), + write: async (key, value) => { + settings[key] = value; + return { ...settings }; + }, + }, + getStoredCaptures() { + return settings.captures || []; + }, + }; +} + +async function flush() { + for (let i = 0; i < 8; i += 1) await Promise.resolve(); +} + +async function mountWithApi(api, document = makeDocument()) { + const component = loadComponent(document); + const container = new FakeNode('div'); + component.mount(container, { workspaceNode: { name: 'Project' } }, api); + await flush(); + return { component, container, document }; +} + +(async () => { + const api = makeApi(); + const { component, container } = await mountWithApi(api); + + for (const name of ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link']) { + if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`); + } + + await api.handlers['browser.capture.selection']({ + name: 'browser.capture.selection', + timestamp: '2026-06-27T00:00:00Z', + payload: { + captureId: 'capture-1', + capturedAt: '2026-06-27T00:00:00.000Z', + kind: 'selection', + url: 'https://example.com/article', + title: 'Example Article', + domain: 'example.com', + text: 'Selected text from the page', + browserName: 'Firefox', + }, + }); + await flush(); + + const captures = api.getStoredCaptures(); + if (captures.length !== 1) throw new Error(`expected one stored capture, got ${captures.length}`); + if (captures[0].captureId !== 'capture-1') throw new Error('stored capture id mismatch'); + + const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1'); + if (!row) throw new Error('capture row was not rendered'); + if (!container.textContent.includes('Selected text from the page')) { + throw new Error('selection text was not rendered'); + } + + await api.handlers['browser.capture.selection']({ + name: 'browser.capture.selection', + timestamp: '2026-06-27T00:00:00Z', + payload: { + captureId: 'capture-1', + capturedAt: '2026-06-27T00:00:00.000Z', + kind: 'selection', + url: 'https://example.com/article', + title: 'Example Article', + domain: 'example.com', + text: 'Duplicate selected text', + }, + }); + await flush(); + if (api.getStoredCaptures().length !== 1) throw new Error('duplicate capture was stored'); + + const clearButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear'); + if (!clearButton) throw new Error('clear button not found'); + clearButton.click(); + await flush(); + if (api.getStoredCaptures().length !== 0) throw new Error('clear action did not empty stored captures'); + + component.unmount && component.unmount(container); + if (api.unsubscribed.length !== 3) throw new Error('component did not unsubscribe all capture handlers'); + + const persistedApi = makeApi({ captures: [captures[0]] }); + const persisted = await mountWithApi(persistedApi); + if (!walk(persisted.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) { + throw new Error('persisted capture was not rendered on mount'); + } + + console.log('browser inbox plugin smoke passed'); +})().catch((err) => { + console.error(err); + process.exit(1); +});