Release cookie-free Persimmons vault link 0.6.0

This commit is contained in:
matcha committed 2026-10-04 08:05:18 +00:00
1 parent 53c4bb50f4
commit 1aa3cffaf2
32 files changed
+1535 -624

No files matched your search

+554
View File
@@ -0,0 +1,554 @@
import {updateState} from './version.mjs';
const $ = id => document.getElementById(id);
const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg';
let generation = 0, currentOwner, currentState, dragging = false, juiceBusy = false;
const masterItems = new Map();
const openVaults = new Set();
$('signOutButton').hidden = true;
$('signOutButton').textContent = 'Sign out';
$('userList').innerHTML = `
<div id="vaultSummary" class="vaultSummary" hidden>
<span id="extensionState" class="lockIcon" role="img"></span>
<div><strong id="vaultCount"></strong><div id="extensionHelp" class="hint"></div></div>
</div>
<div id="onboarding" class="empty">
<p id="onboardingHelp"></p>
<button id="createAccount" class="primary" hidden>Create account</button>
<button id="getExtension" class="primary" hidden>Get extension</button>
<button id="updateExtension" hidden>Update available</button>
</div>
<div id="vaultItems"></div>`;
function extensionMessage(type, owner, extra = {}) {
return new Promise(resolve => {
const timer = setTimeout(() => resolve(null), type === 'persimmons.juice.v1' ? 180000 : 2500);
try {
chrome.runtime.sendMessage(EXTENSION_ID, {type, owner, ...extra}, result => {
const failed = chrome.runtime.lastError;
clearTimeout(timer);
resolve(failed ? null : result);
});
} catch {
clearTimeout(timer);
resolve(null);
}
});
}
const extensionStatus = owner => extensionMessage('persimmons.connection.v1', owner);
const extensionInventory = owner => extensionMessage('persimmons.inventory.v1', owner);
async function latestVersion() {
try {
const response = await fetch('/assets/version.json', {cache: 'no-store'});
return response.ok ? (await response.json()).version : null;
} catch { return null; }
}
async function refresh() {
if (dragging || juiceBusy) return;
const current = ++generation;
const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'});
if (current !== generation) return;
if (!response.ok && response.status !== 401) throw new Error();
const signedIn = response.ok;
const identity = signedIn ? await response.json() : null;
const owner = identity?.owner || identity?.login?.toLowerCase() || identity?.user?.login?.toLowerCase() || identity?.name?.toLowerCase();
const [state, latest, vault, named] = await Promise.all([
extensionStatus(owner),
latestVersion(),
signedIn
? fetch('/api/vault-index', {credentials: 'same-origin', cache: 'no-store'})
.then(async response => response.ok ? response.json() : null)
.catch(() => null)
: Promise.resolve(null),
signedIn ? fetch('/api/vaults', {credentials: 'same-origin', cache: 'no-store'})
.then(async response => response.ok ? response.json() : null).catch(() => null) : null
]);
if (current !== generation) return;
const decrypted = state?.unlocked ? await extensionInventory(owner) : null;
if (current !== generation || dragging || juiceBusy) return;
$('signInLink').hidden = signedIn;
$('signOutButton').hidden = !signedIn;
$('addCredential').hidden = !signedIn || !state?.connected || !state?.initialized;
$('userState').hidden = true;
$('userState').textContent = '';
const displayName = identity?.name || identity?.login || identity?.user?.login || owner || '';
$('userIdentity').textContent = signedIn ? 'Signed in as ' + displayName : '';
currentOwner = owner; currentState = state;
if (!await renderVaults(signedIn, named, current)) return;
if (current !== generation || dragging || juiceBusy) return;
const stale = !!state && updateState(state.version, latest) === 'Update available';
$('updateExtension').hidden = !signedIn || !stale;
$('createAccount').hidden = signedIn;
$('getExtension').hidden = !signedIn || !!state;
if (!signedIn) {
$('vaultSummary').hidden = true;
$('onboarding').hidden = false;
$('onboardingHelp').textContent = 'Sign in or create an account to use your encrypted vault.';
$('vaultItems').replaceChildren();
return;
}
const linked = state?.connected === true;
const ready = linked && state.initialized;
$('vaultSummary').hidden = !ready;
$('onboarding').hidden = ready;
$('extensionState').textContent = state?.unlocked ? '🔓' : '🔒';
$('extensionState').ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
$('extensionState').title = $('extensionState').ariaLabel;
$('extensionHelp').textContent = state?.unlocked ? 'Drag a credential to a named vault to create Juice.' : 'Open 🍊 to unlock. Choose a timed auto-lock in Settings for Juice.';
$('onboardingHelp').textContent =
!state ? 'Install Persimmons in this browser.' :
!linked ? 'Open 🍊 and choose Link vault.' :
!state.initialized ? 'Open 🍊 and choose a vault passphrase.' :
'';
const inventory = $('vaultItems');
inventory.replaceChildren();
const serverItems = Array.isArray(vault?.items) ? vault.items : null;
const extensionItems = Array.isArray(decrypted?.items) ? decrypted.items : null;
const sourceItems = serverItems || extensionItems;
const vaultAvailable = Array.isArray(sourceItems);
const items = vaultAvailable ? sourceItems.filter(item => {
try { return /^[0-9a-f]{32}$/.test(item.id) && new URL(item.origin).protocol === 'https:'; }
catch { return false; }
}) : [];
$('vaultCount').textContent = !vaultAvailable ? 'Credential list unavailable' :
items.length === 1 ? '1 saved credential' : items.length + ' saved credentials';
masterItems.clear();
for (const item of items) masterItems.set(item.id, item);
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = vaultAvailable ?
'No saved credentials yet. Add one from the site where you use it.' :
'Credentials could not be loaded. The page will retry automatically.';
inventory.append(empty);
} else {
const usernames = new Map((decrypted?.items || []).map(item => [item.id, item.username]));
for (const item of items) {
const url = new URL(item.origin);
const username = item.username || usernames.get(item.id);
const card = document.createElement('article');
card.className = 'connection';
card.dataset.sourceId = item.id;
card.draggable = !!(state?.connected && state?.initialized && state?.unlocked);
card.ondragstart = event => {
dragging = true;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-persimmons-source', item.id);
};
card.ondragend = () => { dragging = false; };
const top = document.createElement('div');
top.className = 'cardtop';
const description = document.createElement('div');
const name = document.createElement('div');
name.className = 'title';
name.textContent = '🔑 ' + (username || url.hostname);
const label = document.createElement('div');
label.className = 'account';
label.textContent = username ? 'Website credential' : 'Credential · ID ' + item.id.slice(-6).toUpperCase();
description.append(name, label);
const status = document.createElement('span');
status.className = 'badge';
status.textContent = state?.unlocked ? '🔓' : '🔒';
status.ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
status.title = status.ariaLabel;
top.append(description, status);
const source = document.createElement('div');
source.className = 'credentialSource';
const sourceLabel = document.createElement('span');
sourceLabel.textContent = 'Source';
const sourceLink = document.createElement('a');
sourceLink.href = item.origin;
sourceLink.target = '_blank';
sourceLink.rel = 'noreferrer';
sourceLink.textContent = item.origin;
source.append(sourceLabel, sourceLink);
const mask = document.createElement('div');
mask.className = 'mask';
mask.textContent = '••••••••••••••••';
const capability = document.createElement('span');
capability.className = 'scope';
capability.textContent = 'Fill only on ' + url.hostname;
const metadata = document.createElement('div');
metadata.className = 'meta credentialId';
metadata.textContent = 'ID ' + item.id.slice(-6);
const actions = document.createElement('div');
actions.className = 'actions';
const open = document.createElement('a');
open.className = 'buttonLink';
open.href = item.origin;
open.target = '_blank';
open.rel = 'noreferrer';
open.textContent = 'Open site';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'danger';
remove.textContent = 'Delete';
remove.onclick = async () => {
if (!confirm('Delete this saved credential? This cannot be undone.')) return;
remove.disabled = true;
try {
const deleted = await fetch('/api/vault-index/' + item.id, {
method: 'DELETE', credentials: 'same-origin', headers: {'X-Requested-With': 'Persimmons'}
});
if (!deleted.ok) throw new Error();
await refresh();
} catch {
remove.disabled = false;
$('userState').hidden = false;
$('userState').textContent = 'Delete unavailable. Retry shortly.';
}
};
actions.append(open, remove);
card.append(top, source, mask, capability, metadata, actions);
inventory.append(card);
}
}
}
async function renderVaults(signedIn, result, current) {
const contents = new Map();
if (signedIn && Array.isArray(result?.vaults)) await Promise.all(result.vaults.map(async vault => {
try { contents.set(vault.id, await pageJuice('list', vault)); } catch {}
}));
// Resolve all network reads before touching cards. Never replace a drag target mid-gesture.
if (current !== generation || dragging || juiceBusy) return false;
$('namedVaults').hidden = !signedIn;
const list = $('namedVaultList');
list.replaceChildren();
if (!signedIn) return true;
if (!Array.isArray(result?.vaults) || !result.vaults.length) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = Array.isArray(result?.vaults) ? 'No named vaults yet. Create a private vault when you need one.' : 'Vaults could not be loaded. Retrying shortly.';
list.append(empty);
return true;
}
for (const vault of result.vaults) {
const card = document.createElement('article');
card.className = 'connection namedVaultCard';
card.dataset.vaultId = vault.id;
card.ondragover = event => {
if (!currentState?.connected || !currentState?.initialized || !currentState?.unlocked ||
!event.dataTransfer.types.includes('application/x-persimmons-source')) return;
event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; card.classList.add('juiceHover');
};
card.ondragleave = event => { if (!card.contains(event.relatedTarget)) card.classList.remove('juiceHover'); };
card.ondrop = event => {
event.preventDefault(); dragging = false; card.classList.remove('juiceHover');
const item = masterItems.get(event.dataTransfer.getData('application/x-persimmons-source'));
if (item && currentState?.connected && currentState?.initialized && currentState?.unlocked) openJuice(item, vault);
};
const top = document.createElement('div');
top.className = 'cardtop';
const description = document.createElement('div');
const name = document.createElement('div');
name.className = 'title';
name.textContent = '📦 ' + vault.name;
const owner = document.createElement('div');
owner.className = 'account';
owner.textContent = vault.owner + '/' + vault.name;
description.append(name, owner);
const badge = document.createElement('span');
badge.className = 'badge active';
badge.textContent = 'private ✓';
top.append(description, badge);
const meta = document.createElement('div');
meta.className = 'namedVaultMeta';
const state = document.createElement('span');
state.className = 'namedVaultBadge';
const juiceCount = contents.get(vault.id)?.items.length;
state.textContent = juiceCount ? juiceCount + ' Juice' : vault.empty ? 'Empty vault' : 'Vault';
meta.append(state);
const actions = document.createElement('div');
actions.className = 'namedVaultActions';
const open = document.createElement('button');
open.type = 'button';
open.textContent = 'Open vault';
const body = document.createElement('div');
body.className = 'namedVaultBody';
body.hidden = !openVaults.has(vault.owner + '/' + vault.name);
const bodyTitle = document.createElement('strong');
bodyTitle.textContent = vault.name;
const bodyText = document.createElement('span');
bodyText.textContent = 'Drop an unlocked Master credential here to create Juice.';
body.append(bodyTitle, bodyText);
try {
const result = contents.get(vault.id);
for (const item of result.items) body.append(juiceCard(item, vault));
if (result.items.length) bodyText.remove();
} catch { bodyText.textContent = 'Juice metadata unavailable. Retry shortly.'; }
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
open.onclick = () => {
const key = vault.owner + '/' + vault.name;
if (openVaults.has(key)) openVaults.delete(key);
else openVaults.add(key);
body.hidden = !openVaults.has(key);
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
};
actions.append(open);
card.append(top, meta, actions, body);
list.append(card);
}
return true;
}
async function pageJuice(operation, vault, extra = {}) {
const response = await fetch('/api/juice/' + operation, {method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: {'Content-Type': 'application/json', 'X-Vault-Owner': currentOwner, 'X-Requested-With': 'Persimmons'},
body: JSON.stringify({target: {id: vault.id, name: vault.name}, ...extra})});
if (!response.ok) throw new Error();
return response.json();
}
function juiceCard(item, vault) {
const card = document.createElement('article');
card.className = 'juiceCard'; card.dataset.juiceId = item.id;
const title = document.createElement('strong');
title.textContent = '💧 Gitea · ' + item.source_username + '@' + new URL(item.source_origin).hostname;
const details = document.createElement('p');
details.textContent = item.scopes.join(', ') + ' · ' + item.status + ' · ' + item.created_at;
const boundary = document.createElement('p');
boundary.textContent = 'Provider API-class scopes; not restricted to this vault or a single repository.';
const status = document.createElement('p');
status.textContent = 'Target secret: ' + item.target_status + ' · Provider token: ' + item.provider_status;
if (item.status === 'partial-failure' || item.status === 'pending') status.textContent += ' · ANDON: cleanup may be required. Retry Revoke.';
const actions = document.createElement('div'); actions.className = 'actions';
for (const action of ['rotate', 'revoke']) {
const button = document.createElement('button'); button.textContent = action === 'rotate' ? 'Rotate' : 'Revoke';
button.disabled = action === 'rotate' && (item.status !== 'active' || !currentState?.unlocked);
if (action === 'revoke' && item.status === 'revoked') button.disabled = true;
button.onclick = async () => {
juiceBusy = true; button.disabled = true;
try {
if (action === 'revoke') await pageJuice('remove', vault, {id: item.id});
const result = await extensionMessage('persimmons.juice.v1', currentOwner,
{action, target: {id: vault.id, name: vault.name}, id: item.id});
if (!result && action === 'revoke') {
status.textContent = 'ANDON: Target secret removed; provider revocation unconfirmed. Connect and unlock the source to retry.';
} else if (!result?.ok) {
status.textContent = (action === 'revoke' ? 'ANDON: Target secret removed; provider cleanup unconfirmed. ' : '') +
(result?.message || 'ANDON: Operation outcome unknown. Refresh and inspect Juice before retrying.');
} else {
openVaults.add(vault.owner + '/' + vault.name);
juiceBusy = false; await refresh();
}
} catch { status.textContent = 'ANDON: Operation outcome unknown. Inspect Juice before retrying.'; }
finally { juiceBusy = false; button.disabled = false; }
};
actions.append(button);
}
card.append(title, details, boundary, status, actions);
return card;
}
function openJuice(item, vault) {
const dialog = ensureDialog(), body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.style.cssText = 'font-size:20px;overflow-wrap:anywhere;line-height:1.3';
title.textContent = 'Juice ' + (item.username || 'credential') + '@' + new URL(item.origin).hostname + ' → ' + vault.name;
body.append(title);
if (item.origin !== 'https://teahub.xyz') {
const note = document.createElement('p'); note.textContent = 'Juice adapter not available yet.'; body.append(note);
} else {
const form = document.createElement('form');
form.innerHTML = `<label>Gitea PAT scopes<select name="scope"><option value="read:repository">Read repositories</option><option value="read:issue">Read issues</option><option value="write:issue">Read and write issues</option></select></label>
<p class="hint">Scopes apply across the source account’s accessible resources in that API category. They are not limited to this vault or one repository. No automatic expiry.</p>
<button type="submit" class="primary">Create Juice</button><p role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault(); juiceBusy = true; form.querySelector('button').disabled = true;
const result = await extensionMessage('persimmons.juice.v1', currentOwner, {action: 'create', source_id: item.id,
target: {id: vault.id, name: vault.name}, policy: {scopes: [form.elements.scope.value]}});
juiceBusy = false;
if (result?.ok) {
openVaults.add(vault.owner + '/' + vault.name); dialog.close(); await refresh();
} else {
form.querySelector('[role=status]').textContent = result?.message || 'ANDON: Outcome unknown. Close and inspect Juice before retrying.';
form.querySelector('button').disabled = false;
}
};
body.append(form);
}
dialog.showModal();
}
function openNewVault() {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = 'New vault';
const form = document.createElement('form');
form.innerHTML = `<label>Vault name<input id="vaultName" name="name" autocomplete="off" maxlength="100" required></label>
<p class="hint">Creates an empty private vault owned by you. Spaces become hyphens.</p>
<button type="submit" class="primary">Create vault</button><p id="vaultStatus" role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault();
form.querySelector('button').disabled = true;
try {
const response = await fetch('/api/vaults', {method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'Persimmons'},
body: JSON.stringify({name: form.querySelector('#vaultName').value})});
const result = await response.json();
if (!response.ok) {
form.querySelector('#vaultStatus').textContent = result.error || 'Vault creation unavailable. Retry shortly.';
form.querySelector('button').disabled = false;
return;
}
dialog.close();
await refresh();
} catch {
form.querySelector('#vaultStatus').textContent = 'Vault creation unavailable. Refresh the page before retrying.';
form.querySelector('button').disabled = false;
}
};
body.append(title, form);
dialog.showModal();
}
function ensureDialog() {
let dialog = $('siteModal');
if (dialog) return dialog;
dialog = document.createElement('dialog');
dialog.id = 'siteModal';
dialog.style.cssText = 'border:0;border-radius:16px;padding:0;width:min(460px,calc(100vw - 32px));box-shadow:0 24px 80px rgba(36,26,20,.24)';
dialog.innerHTML = '<div id="siteModalBody" style="padding:22px;background:#fffdfa"></div>';
document.body.append(dialog);
dialog.addEventListener('close', () => refresh().catch(failed));
return dialog;
}
function closeButton(dialog) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = '×';
button.setAttribute('aria-label', 'Close');
button.style.cssText = 'float:right;border:0;background:none;font-size:22px';
button.onclick = () => dialog.close();
return button;
}
function openAuth(mode = 'login') {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = mode === 'register' ? 'Create account' : 'Sign in';
const form = document.createElement('form');
form.autocomplete = 'on';
form.innerHTML = `
<label>Username<input id="modalName" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="64" required></label>
<label>Password<input id="modalPassword" name="password" type="password" autocomplete="current-password" minlength="12" maxlength="1024" required></label>
<button type="submit" class="primary">${mode === 'register' ? 'Create account' : 'Sign in'}</button>
<p id="modalStatus" role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault();
const password = form.querySelector('#modalPassword');
const payload = {name: form.querySelector('#modalName').value, password: password.value};
password.value = '';
form.querySelector('button').disabled = true;
try {
const response = await fetch('/api/auth/' + mode, {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
payload.password = '';
if (!response.ok) throw new Error();
dialog.close();
await refresh();
} catch {
payload.password = '';
form.querySelector('#modalStatus').textContent = 'Sign-in unavailable. Check details and retry.';
form.querySelector('button').disabled = false;
}
};
body.append(title, form);
dialog.showModal();
}
function openInstall(update = false) {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = update ? 'Update Persimmons' : 'Get Persimmons';
const text = document.createElement('p');
text.textContent = update ? 'Install the current build into your unpacked extension folder, then reload it in Chromium.' : 'Download the current build, unzip it, then use Chromium → Extensions → Developer mode → Load unpacked.';
const link = document.createElement('a');
link.href = '/assets/extension.zip';
link.className = 'buttonLink primary';
link.textContent = update ? 'Download current build' : 'Download extension';
body.append(title, text, link);
dialog.showModal();
}
function openAddCredential() {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = 'Add a credential';
const text = document.createElement('p');
text.textContent = 'Open the HTTPS site where you use the credential, enter it without submitting, then open 🍊 and choose Save for this site.';
const note = document.createElement('p');
note.className = 'hint';
note.textContent = 'The page updates automatically after the extension saves it.';
body.append(title, text, note);
dialog.showModal();
}
const failed = () => {
$('userState').hidden = false;
$('userState').textContent = 'Service unavailable. Retry shortly.';
$('extensionState').hidden = false;
$('extensionState').textContent = 'Connection could not be checked.';
};
$('signOutButton').onclick = async () => {
try {
const response = await fetch('/api/logout', {method: 'POST'});
if (!response.ok) throw new Error();
await refresh();
} catch {
$('userState').hidden = false;
$('userState').textContent = 'Sign-out unavailable. Retry shortly.';
}
};
$('signInLink').onclick = event => { event.preventDefault(); openAuth('login'); };
$('createAccount').onclick = () => openAuth('register');
$('getExtension').onclick = () => openInstall(false);
$('updateExtension').onclick = () => openInstall(true);
$('addCredential').onclick = openAddCredential;
$('newVault').onclick = openNewVault;
window.addEventListener('pointerdown', event => {
if (event.target.closest?.('.connection[draggable="true"]')) dragging = true;
}, true);
window.addEventListener('pointerup', () => { dragging = false; }, true);
window.addEventListener('pageshow', () => refresh().catch(failed));
window.addEventListener('focus', () => refresh().catch(failed));
setInterval(() => { if (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500);
refresh().catch(failed);
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Install · Persimmons</title><style>body{background:#fffdfa;color:#241a14;font:16px system-ui;max-width:36rem;margin:4rem auto;padding:1rem;line-height:1.6}a{color:#b8530d}li{margin:1rem 0}</style><h1>Install Persimmons</h1><p>This MVP installs as an unpacked Chromium extension.</p><ol><li><a href="/assets/extension.zip">Download the current extension</a> and extract the ZIP. Keep the folder.</li><li>Open <strong>chrome://extensions</strong>, turn on <strong>Developer mode</strong>, choose <strong>Load unpacked</strong>, and select the extracted <strong>persimmons-extension</strong> folder.</li><li>Keep Persimmons open, pin 🍊 from your browser’s extensions menu, then open it and choose <strong>Link vault</strong>. Choose a vault passphrase and press Enter.</li></ol><p>Keep this browser profile, extension folder, and passphrase. Vault key sync and recovery are not available.</p><a href="/">Return to your vault</a></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in · Persimmons</title>
<style>body{background:#fffdfa;color:#241a14;font:16px system-ui;margin:4rem auto;padding:1rem;max-width:28rem}input,button{box-sizing:border-box;width:100%;padding:.8rem;margin:.5rem 0;border:1px solid #eadfd6;border-radius:.6rem;font:inherit}button{background:#f47b20;color:white;cursor:pointer}a{color:#b8530d}</style>
<h1>🍊 Persimmons</h1>
<form id="signin" autocomplete="on"><label>Username<input id="name" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}" required></label><label>Password<input id="password" name="password" type="password" autocomplete="current-password" minlength="12" maxlength="1024" required></label><button name="action" value="login">Sign in</button><button name="action" value="register">Create account</button></form>
<p id="status" role="status"></p><a href="/">Back to Persimmons</a><script src="/assets/signin.js"></script></html>
+26
View File
@@ -0,0 +1,26 @@
document.getElementById('signin').onsubmit = async event => {
event.preventDefault();
const form = event.currentTarget;
const status = document.getElementById('status');
const action = event.submitter.value;
const password = document.getElementById('password');
const payload = {name: document.getElementById('name').value, password: password.value};
password.value = '';
for (const button of form.querySelectorAll('button')) button.disabled = true;
try {
const response = await fetch('/api/auth/' + action, {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
payload.password = '';
if (!response.ok) throw new Error();
location.assign('/');
} catch {
payload.password = '';
status.textContent = 'Sign-in unavailable. Check details and retry.';
for (const button of form.querySelectorAll('button')) button.disabled = false;
}
};
fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'})
.then(response => { if (response.ok) location.replace('/'); }).catch(() => {});
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Replace a loaded Persimmons folder from the dedicated HTTPS release.
Run: python3 update-extension.py /path/to/persimmons-extension
Close the popup first; afterward use chrome://extensions -> Reload.
No browser profile, local vault storage, credentials or native host is accessed.
"""
import argparse
import base64
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
import tempfile
import urllib.request
import zipfile
EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg'
URL = 'https://persimmons.app/assets/extension.zip'
LIMIT = 4 * 1024 * 1024
def identity(manifest):
digest = hashlib.sha256(base64.b64decode(manifest['key'], validate=True)).hexdigest()[:32]
return ''.join(chr(ord('a') + int(c, 16)) for c in digest)
def update(folder, raw):
folder = Path(folder).expanduser().absolute()
if folder.is_symlink() or any(p.is_symlink() for p in folder.parents):
raise ValueError('Use a real extension folder, not a symbolic link.')
installed = json.loads((folder / 'manifest.json').read_text())
if identity(installed) != EXTENSION_ID:
raise ValueError('The installed extension identity does not match.')
files = {}
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
entries = archive.infolist()
if len(entries) > 100 or sum(e.file_size for e in entries) > LIMIT:
raise ValueError('Release exceeds size limit.')
for entry in entries:
path = PurePosixPath(entry.filename)
if (not path.parts or path.parts[0] != 'persimmons-extension' or
'..' in path.parts or '\\' in entry.filename or entry.is_dir() or
(entry.external_attr >> 16) & 0o170000 == 0o120000):
raise ValueError('Unexpected release path.')
relative = Path(*path.parts[1:])
if relative.suffix not in {'.mjs', '.json', '.html', '.css', '.png'} or relative in files:
raise ValueError('Unexpected release file.')
destination = folder / relative
if destination.is_symlink() or any(p.is_symlink() for p in destination.parents):
raise ValueError('Refusing to replace symbolic links.')
files[relative] = archive.read(entry)
candidate = json.loads(files[Path('manifest.json')])
if identity(candidate) != EXTENSION_ID or candidate['key'] != installed['key']:
raise ValueError('Release identity does not match; no files replaced.')
version = lambda value: tuple(int(n) for n in value.split('.'))
if version(candidate['version']) < version(installed['version']):
raise ValueError('Refusing an older release.')
# Validate the entire archive before any write. Stage beside the destination
# for atomic per-file replacements; manifest goes last. Retry after interruption.
with tempfile.TemporaryDirectory(prefix='.persimmons-update-', dir=folder.parent) as staging:
stage = Path(staging)
for relative, data in files.items():
(stage / relative).parent.mkdir(parents=True, exist_ok=True)
(stage / relative).write_bytes(data)
for relative in sorted(files, key=lambda p: (p.name == 'manifest.json', str(p))):
(folder / relative).parent.mkdir(parents=True, exist_ok=True)
os.replace(stage / relative, folder / relative)
return candidate['version']
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
raise ValueError('Release URL redirected; no files replaced.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('folder', help='Existing folder shown under chrome://extensions -> Details -> Loaded from')
args = parser.parse_args()
try:
with urllib.request.build_opener(NoRedirect).open(URL, timeout=30) as response:
raw = response.read(LIMIT + 1)
if len(raw) > LIMIT:
raise ValueError('Release exceeds size limit.')
release = update(args.folder, raw)
print(f'Installed files for {release}. Now click Reload at chrome://extensions, or Reload installed files in the popup.')
except Exception:
parser.exit(1, 'Update failed safely. Check the folder, network and release; retry before reloading.\n')
if __name__ == '__main__':
main()
+10
View File
@@ -0,0 +1,10 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Update Persimmons</title>
<style>body{max-width:650px;margin:48px auto;padding:0 24px;font:16px/1.6 system-ui;color:#241a14;background:#fffdfa}a{color:#b8530d}h1{letter-spacing:-.04em}code{overflow-wrap:anywhere}li{margin:12px 0}</style>
<h1>Update your extension</h1>
<p>Keep the same folder and browser profile. Your local wrapped vault key stays in the browser.</p>
<ol><li>Close the vault popup. Find your existing folder under <code>chrome://extensions</code> → Persimmons → Details → Loaded from.</li>
<li>Save the <a href="/assets/update-extension.py" download>update helper</a> once. With Python 3 installed, run:<p><code>python3 update-extension.py "/path/to/persimmons-extension"</code></p>On Windows, use <code>py</code> in place of <code>python3</code>. Repeat this same command for future releases; no manual ZIP extraction or folder selection.</li>
<li>Click Reload on the Persimmons card at <code>chrome://extensions</code>. Version 0.3.4 also offers Update extension → Reload installed files in the popup.</li>
<li>Reopen 🍊. Connection is checked automatically; enter your passphrase to unlock.</li></ol>
<details><summary>Prefer to replace files yourself?</summary><p><a href="/assets/extension.zip">Download the latest ZIP</a> and copy its extension files over the same loaded folder, then Reload. Do not uninstall the extension or clear its storage.</p></details>
<details><summary>Why a helper?</summary><p>An unpacked extension cannot overwrite its installed source directory itself. Reload rereads the files already on disk; it does not download an update. This helper downloads only from persimmons.app over HTTPS, checks the pinned identity and replaces extension files. It never accesses your browser profile or vault. Close the popup during replacement; if interrupted, rerun before Reload.</p><p>Automatic Chrome Web Store updates are a future distribution step, pending approval and identity verification.</p></details></html>
+1
View File
@@ -0,0 +1 @@
{"version":"0.6.0"}
+10
View File
@@ -0,0 +1,10 @@
export function updateState(installed, latest) {
const parse = value => typeof value === 'string' && /^\d+(\.\d+){0,3}$/.test(value) ? value.split('.').map(Number) : null;
const a = parse(installed), b = parse(latest);
if (!a || !b) return '';
for (let i = 0; i < 4; i++) {
if ((a[i] || 0) < (b[i] || 0)) return 'Update available';
if ((a[i] || 0) > (b[i] || 0)) return '';
}
return '';
}