diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index f682387..686b398 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -52,6 +52,13 @@ '.browser-inbox-meta-value{color:var(--vt-color-text-secondary,#b7c0d4);min-width:0;overflow-wrap:anywhere}', '.browser-inbox-text{border:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface,#15152c);border-radius:var(--vt-radius-lg,8px);padding:.75rem;font-size:.85rem;line-height:1.5;color:var(--vt-color-text-primary,#f4f7fb);white-space:pre-wrap;overflow-wrap:anywhere}', '.browser-inbox-detail-actions{display:flex;gap:.5rem;flex-wrap:wrap}', + '.browser-inbox-settings{display:grid;gap:.85rem;padding:1rem;max-width:560px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:var(--vt-color-text-primary,#f4f7fb)}', + '.browser-inbox-settings-field{display:grid;gap:.3rem}', + '.browser-inbox-settings-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}', + '.browser-inbox-settings-input{width:100%;box-sizing:border-box;padding:.48rem .58rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem}', + '.browser-inbox-settings-actions{display:flex;gap:.5rem;flex-wrap:wrap}', + '.browser-inbox-settings-status{min-height:1.15rem;font-size:.76rem;color:var(--vt-color-text-muted,#7f8aa3)}', + '.browser-inbox-settings-status.error{color:#ffc6ce}', '@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'); @@ -766,9 +773,145 @@ } }; + var BrowserInboxSettings = { + mount: function (containerEl, props, api) { + injectStyles(); + containerEl.innerHTML = ''; + containerEl.className = 'browser-inbox-settings'; + + var receiverURLInput = el('input', { + className: 'browser-inbox-settings-input', + type: 'text', + readonly: 'readonly', + spellcheck: 'false', + autocomplete: 'off', + 'data-browser-inbox-pairing-url': '' + }); + var receiverTokenInput = el('input', { + className: 'browser-inbox-settings-input', + type: 'text', + readonly: 'readonly', + spellcheck: 'false', + autocomplete: 'off', + 'data-browser-inbox-pairing-token': '' + }); + var statusEl = el('div', { className: 'browser-inbox-settings-status' }); + var copyURLButton = el('button', { + className: 'browser-inbox-btn', + type: 'button', + 'data-browser-inbox-settings-action': 'copy-url', + textContent: 'Copy URL' + }); + var copyTokenButton = el('button', { + className: 'browser-inbox-btn', + type: 'button', + 'data-browser-inbox-settings-action': 'copy-token', + textContent: 'Copy Token' + }); + var rotateTokenButton = el('button', { + className: 'browser-inbox-btn danger', + type: 'button', + 'data-browser-inbox-settings-action': 'rotate-token', + textContent: 'Rotate Token' + }); + + containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [ + el('label', { className: 'browser-inbox-settings-label', textContent: 'Receiver URL' }), + receiverURLInput + ])); + containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [ + el('label', { className: 'browser-inbox-settings-label', textContent: 'Pairing Token' }), + receiverTokenInput + ])); + containerEl.appendChild(el('div', { className: 'browser-inbox-settings-actions' }, [ + copyURLButton, + copyTokenButton, + rotateTokenButton + ])); + containerEl.appendChild(statusEl); + + function setStatus(message, isError) { + statusEl.textContent = message || ''; + statusEl.className = 'browser-inbox-settings-status' + (isError ? ' error' : ''); + } + + function setBusy(busy) { + copyURLButton.disabled = busy; + copyTokenButton.disabled = busy; + rotateTokenButton.disabled = busy; + } + + function pairingAPI() { + if (!api || !api.browserReceiver) throw new Error('Browser receiver API is unavailable'); + return api.browserReceiver; + } + + function applyPairing(pairing) { + receiverURLInput.value = text(pairing && pairing.receiverUrl).trim(); + receiverTokenInput.value = text(pairing && pairing.receiverToken).trim(); + } + + function loadPairing() { + setBusy(true); + setStatus('Loading...', false); + return Promise.resolve().then(function () { + return pairingAPI().pairing(); + }).then(function (pairing) { + applyPairing(pairing); + setStatus('', false); + }).catch(function (err) { + setStatus(text(err && err.message ? err.message : err), true); + }).then(function () { + setBusy(false); + }); + } + + function copyValue(value, label) { + if (!value) return; + if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') { + setStatus('Clipboard unavailable', true); + return; + } + navigator.clipboard.writeText(value).then(function () { + setStatus(label + ' copied', false); + }).catch(function (err) { + setStatus(text(err && err.message ? err.message : err), true); + }); + } + + copyURLButton.addEventListener('click', function () { + copyValue(receiverURLInput.value, 'URL'); + }); + copyTokenButton.addEventListener('click', function () { + copyValue(receiverTokenInput.value, 'Token'); + }); + rotateTokenButton.addEventListener('click', function () { + if (typeof window.confirm === 'function' && !window.confirm('Rotate pairing token?')) return; + setBusy(true); + setStatus('Rotating...', false); + Promise.resolve().then(function () { + return pairingAPI().rotateToken(); + }).then(function (pairing) { + applyPairing(pairing); + setStatus('Token rotated', false); + }).catch(function (err) { + setStatus(text(err && err.message ? err.message : err), true); + }).then(function () { + setBusy(false); + }); + }); + + loadPairing(); + }, + unmount: function (containerEl) { + if (containerEl) containerEl.innerHTML = ''; + } + }; + window.VerstakPluginRegister(PLUGIN_ID, { components: { - BrowserInboxView: BrowserInboxView + BrowserInboxView: BrowserInboxView, + BrowserInboxSettings: BrowserInboxSettings } }); })(); diff --git a/plugins/browser-inbox/plugin.json b/plugins/browser-inbox/plugin.json index 9a618c7..7f7ca7e 100644 --- a/plugins/browser-inbox/plugin.json +++ b/plugins/browser-inbox/plugin.json @@ -15,6 +15,7 @@ "permissions": [ "events.subscribe", "events.publish", + "browser.receiver.manage", "files.write", "storage.namespace", "ui.register" @@ -47,6 +48,13 @@ "icon": "inbox", "component": "BrowserInboxView" } + ], + "settingsPanels": [ + { + "id": "verstak.browser-inbox.settings", + "title": "Browser Inbox", + "component": "BrowserInboxSettings" + } ] } } diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index 36ab91b..eeebd6b 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -110,7 +110,7 @@ function makeDocument() { }; } -function loadComponent(document) { +function loadComponents(document) { const registry = {}; const sandbox = { console, @@ -125,7 +125,13 @@ function loadComponent(document) { 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; + const components = registry['verstak.browser-inbox']; + if (!components) throw new Error('Browser Inbox components were not registered'); + return components; +} + +function loadComponent(document) { + const component = loadComponents(document).BrowserInboxView; if (!component) throw new Error('BrowserInboxView was not registered'); return component; } @@ -137,6 +143,10 @@ function makeApi(initialSettings = {}) { const fileWrites = []; const fileByteWrites = []; const publishedEvents = []; + const receiverPairing = { + receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures', + receiverToken: 'initial-browser-token', + }; let nextWriteError = null; return { settings, @@ -185,6 +195,13 @@ function makeApi(initialSettings = {}) { fileByteWrites.push({ relativePath, dataBase64, options }); }, }, + browserReceiver: { + pairing: async () => ({ ...receiverPairing }), + rotateToken: async () => { + receiverPairing.receiverToken = 'rotated-browser-token'; + return { ...receiverPairing }; + }, + }, getStoredCaptures(key = 'captures') { return settings[key] || []; }, @@ -203,8 +220,37 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w return { component, container, document }; } +async function mountSettingsWithApi(api, document = makeDocument()) { + const component = loadComponents(document).BrowserInboxSettings; + const container = new FakeNode('div'); + component.mount(container, {}, api); + await flush(); + return { component, container, document }; +} + (async () => { + const settingsComponents = loadComponents(makeDocument()); + if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered'); + const api = makeApi(); + const settingsView = await mountSettingsWithApi(makeApi()); + const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === ''); + const receiverTokenInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-token') === ''); + if (!receiverURLInput || receiverURLInput.value !== 'http://127.0.0.1:47731/api/browser-inbox/v1/captures') { + throw new Error('Browser Inbox settings did not render receiver URL'); + } + if (!receiverTokenInput || receiverTokenInput.value !== 'initial-browser-token') { + throw new Error('Browser Inbox settings did not render pairing token'); + } + const rotateTokenButton = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-settings-action') === 'rotate-token'); + if (!rotateTokenButton) throw new Error('Browser Inbox settings rotate token action missing'); + rotateTokenButton.click(); + await flush(); + if (receiverTokenInput.value !== 'rotated-browser-token') { + throw new Error('Browser Inbox settings did not update token after rotation'); + } + settingsView.component.unmount && settingsView.component.unmount(settingsView.container); + const { component, container } = await mountWithApi(api); for (const name of ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link', 'browser.capture.file']) {