From 6a7214f3a50a23ef0b8835759a98a620128a01c8 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:45:37 +0000 Subject: [PATCH] Consolidate Persimmons source --- extension/human-popup.mjs | 267 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 extension/human-popup.mjs diff --git a/extension/human-popup.mjs b/extension/human-popup.mjs new file mode 100644 index 0000000..534fdfc --- /dev/null +++ b/extension/human-popup.mjs @@ -0,0 +1,267 @@ +import {CONNECTION} from './connection-state.mjs'; +import {safeMetadata, isHttpsOrigin} from './human-crypto.mjs'; +import {vaultRequest, fillAccount, captureAccount, VaultUnavailableError} from './human-api.mjs'; +import {AUTO_LOCK, policies} from './vault-session.mjs'; +import {updateState} from './version.mjs'; + +const $ = id => document.getElementById(id); +const port = chrome.runtime.connect({name: 'human-vault'}); +let sequence = 0, owner = null, target = null, epoch = 0, busy = false, key = false, currentOrigin = null; +let items = []; +const pending = new Map(); + +function session(op, data = {}) { + return new Promise((resolve, reject) => { + const id = ++sequence; + const timer = setTimeout(() => { pending.delete(id); reject(new Error()); }, 20000); + pending.set(id, {resolve, reject, timer}); + port.postMessage({id, op, owner, ...data}); + }); +} + +port.onMessage.addListener(message => { + const task = pending.get(message.id); if (!task) return; + clearTimeout(task.timer); pending.delete(message.id); + message.ok ? task.resolve(message.result) : task.reject(message.unavailable ? new VaultUnavailableError() : new Error()); +}); + +const status = text => { + $('human-status').textContent = text; + $('human-status').hidden = !text; +}; + +function controls() { + const inventoryMode = currentOrigin === 'https://persimmons.app'; + const visibleItems = inventoryMode ? items : items.filter(item => item.origin === target?.origin); + for (const id of ['human-connect', 'human-unlock', 'human-capture', 'human-fill', 'human-delete']) $(id).disabled = busy; + $('auto-lock').disabled = busy; + $('human-unlock').disabled ||= !owner || key; + $('human-capture').disabled ||= !key || !target; + $('human-fill').disabled ||= !key || !target || !$('human-items').value; + $('human-delete').disabled ||= !key || !$('human-items').value; + $('human-state').textContent = !owner ? 'Not connected' : key ? 'Unlocked' : 'Locked'; + $('human-connect-panel').hidden = !!owner; + $('human-unlock-panel').hidden = !owner || key; + $('human-actions').hidden = !key; + $('human-capture').hidden = !target; + $('human-accounts').hidden = !key || !visibleItems.length; + $('settings-panel').hidden = !owner || !key; + $('auto-lock-panel').hidden = !owner || !key; + $('recovery').hidden = !owner || !key; + $('updates').hidden = !owner || !$('updates').dataset.available; +} + +function clearPassphrase() { $('human-passphrase').value = ''; } + +function lock() { + session('lock').catch(() => {}); + epoch++; key = false; clearPassphrase(); status(''); controls(); +} + +async function run(work, preservePassphrase = false) { + if (busy) return; + busy = true; controls(); + const current = epoch; + const check = () => { if (epoch !== current) throw new Error('Locked.'); }; + try { await work(check); } + catch (error) { + if (epoch === current) { + key = false; session('lock').catch(() => {}); + if (error instanceof VaultUnavailableError) { + owner = null; items = []; + await chrome.storage.local.remove(CONNECTION).catch(() => {}); + status('Sign in to Persimmons, then link your vault.'); + } else { + status(preservePassphrase && owner ? 'Unlock failed. Check your passphrase.' : 'Nothing to save here yet.'); + } + } + } finally { + const retry = preservePassphrase && !key && owner ? $('human-passphrase').value : ''; + clearPassphrase(); $('human-passphrase').value = retry; busy = false; controls(); + } +} + +async function activeTarget() { + const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); + const url = new URL(tab.url); + if (!isHttpsOrigin(url.origin) || url.origin === 'https://persimmons.app') throw new Error(); + return {id: tab.id, origin: url.origin}; +} + +async function refresh(check) { + const data = await vaultRequest('GET', owner); check(); + items = data.items.map(safeMetadata); + const inventoryMode = currentOrigin === 'https://persimmons.app'; + const visibleItems = inventoryMode ? items : items.filter(item => item.origin === target?.origin); + $('human-items').replaceChildren(new Option(inventoryMode ? 'Choose a saved Persim' : 'Choose this site\'s Persim', '')); + for (const item of visibleItems) { + let label = item.username ? item.username + ' — ' + item.origin : item.origin; + let account = null; + if (!item.username && key) { + try { + const full = await vaultRequest('GET', owner, item.id); check(); + account = await session('decrypt', {item: full.item}); check(); + if (account?.username) { + label = account.username + ' — ' + item.origin; + await vaultRequest('PUT', owner, item.id, {...full.item, username: account.username}); check(); + item.username = account.username; + } + } finally { + if (account) { account.username = ''; account.password = ''; account = null; } + } + } + $('human-items').add(new Option(label, item.id)); + } +} + +async function connect(check, expectedOwner) { + await chrome.storage.local.setAccessLevel({accessLevel: 'TRUSTED_CONTEXTS'}); + const data = await vaultRequest('GET', expectedOwner); check(); + owner = data.owner; + const storageId = `human-vault:https://persimmons.app:${owner}`; + const initialized = !!(await chrome.storage.local.get(storageId))[storageId]; check(); + $('human-unlock').textContent = initialized ? 'Unlock vault' : 'Create vault'; + $('human-passphrase').placeholder = initialized ? 'Vault passphrase' : 'Choose at least 12 characters'; + try { target = await activeTarget(); } catch { target = null; } + await refresh(check); + await chrome.storage.local.set({[CONNECTION]: {owner}}); check(); + key = !!(await session('status')); check(); + if (key) await refresh(check); + checkUpdates(); + status(''); +} + +$('human-connect').onclick = () => { + if (busy) return; + const grant = chrome.permissions.request({origins: ['https://persimmons.app/*']}); + lock(); owner = null; items = []; target = null; $('human-items').replaceChildren(); + run(async check => { if (!await grant) throw new Error(); await connect(check); }); +}; + +$('human-unlock-panel').onsubmit = event => { + event.preventDefault(); + return run(async check => { + let passphrase = $('human-passphrase').value; + try { + key = !!(await session('unlock', {passphrase})); check(); + await refresh(check); + $('human-unlock').textContent = 'Unlock vault'; + $('human-passphrase').placeholder = 'Vault passphrase'; + status(''); + } finally { passphrase = ''; } + }, true); +}; + +$('human-capture').onclick = () => run(async check => { + let account = null; + try { + const current = await activeTarget(); check(); + const result = await chrome.scripting.executeScript({ + target: {tabId: current.id, allFrames: true}, world: 'ISOLATED', + func: captureAccount, args: [current.origin] + }); + check(); + account = result.map(entry => entry.result).find(value => value?.username && value?.password) || null; + if (!account) { status('No filled login found. Let your browser autofill the login form, then try again.'); return; } + const item = await session('encrypt', {origin: current.origin, account}); check(); + account.username = ''; account.password = ''; + await vaultRequest('POST', owner, undefined, item); check(); + target = current; await refresh(check); $('human-items').value = item.id; + status('Saved to your vault.'); + } finally { if (account) { account.username = ''; account.password = ''; } } +}); + +$('human-fill').onclick = () => run(async check => { + let account = null; + try { + const id = $('human-items').value; + const current = await activeTarget(); check(); + if (!key || !target || current.id !== target.id || current.origin !== target.origin) throw new Error(); + const {item} = await vaultRequest('GET', owner, id); check(); + const metadata = safeMetadata(item); + if (metadata.id !== id || metadata.origin !== current.origin) throw new Error(); + account = await session('decrypt', {item}); check(); + const result = await chrome.scripting.executeScript({ + target: {tabId: current.id, frameIds: [0]}, world: 'ISOLATED', + func: fillAccount, args: [current.origin, account] + }); + check(); if (!result[0]?.result) throw new Error(); + status(''); + } finally { if (account) { account.username = ''; account.password = ''; } } +}); + +$('human-delete').onclick = () => run(async check => { + if (!key || !await session('status')) throw new Error(); check(); + await vaultRequest('DELETE', owner, $('human-items').value); check(); + await refresh(check); status(''); +}); + +$('human-lock').onclick = lock; +$('human-items').onchange = controls; +$('human-signin').onclick = () => chrome.tabs.create({url: 'https://persimmons.app/'}); +$('updates').onclick = () => chrome.tabs.create({url: 'https://persimmons.app/'}); +window.addEventListener('pagehide', () => { epoch++; key = false; clearPassphrase(); port.disconnect(); }); + +port.onDisconnect.addListener(() => { + key = false; epoch++; clearPassphrase(); controls(); + for (const task of pending.values()) { clearTimeout(task.timer); task.reject(new Error()); } + pending.clear(); +}); + +controls(); + +run(async check => { + try { + const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); check(); + const url = new URL(tab.url); + currentOrigin = url.protocol === 'https:' ? url.origin : null; + $('origin').textContent = currentOrigin || 'Open an HTTPS website'; + } catch { $('origin').textContent = 'Open an HTTPS website'; } + const policy = (await chrome.storage.local.get(AUTO_LOCK))[AUTO_LOCK]; + $('auto-lock').value = policies.includes(policy) ? policy : 'close'; + const hint = (await chrome.storage.local.get(CONNECTION))[CONNECTION]; check(); + const permitted = await chrome.permissions.contains({origins: ['https://persimmons.app/*']}); check(); + if (hint?.owner) { + if (!permitted) { status('Link vault to continue.'); return; } + await connect(check, hint.owner); return; + } + if (permitted) await connect(check); +}); + +$('auto-lock').onchange = () => run(async check => { + const policy = $('auto-lock').value; + if (!policies.includes(policy)) throw new Error(); + await session('lock'); key = false; check(); + await chrome.storage.local.set({[AUTO_LOCK]: policy}); check(); + status(''); +}); + +function checkUpdates() { + fetch('https://persimmons.app/assets/version.json', {cache: 'no-store'}) + .then(response => response.ok ? response.json() : null) + .then(latest => { + $('updates').dataset.available = + updateState(chrome.runtime.getManifest().version, latest?.version) === 'Update available' ? 'yes' : ''; + controls(); + }).catch(() => {}); +} + +setInterval(async () => { + if (!key || busy) return; + const current = epoch; + try { + const active = await session('status'); + if (epoch !== current || busy) return; + if (!active) { epoch++; key = false; clearPassphrase(); controls(); } + } catch (error) { + if (epoch !== current) return; + if (error instanceof VaultUnavailableError) { + epoch++; key = false; owner = null; items = []; target = null; clearPassphrase(); + await chrome.storage.local.remove(CONNECTION).catch(() => {}); + status('Sign in to Persimmons, then link your vault.'); + controls(); + } else { + lock(); + } + } +}, 10000);