2026-10-04 08:05:18 +00:00
import { updateState } from './version.mjs' ;
2026-10-04 02:44:55 +00:00
2026-10-04 08:05:18 +00:00
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 ();
2026-10-04 02:44:55 +00:00
2026-10-04 08:05:18 +00:00
$ ( '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>` ;
2026-10-04 02:44:55 +00:00
2026-10-04 08:05:18 +00:00
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 );
2026-10-04 02:44:55 +00:00
}
});
}
2026-10-04 08:05:18 +00:00
const extensionStatus = owner => extensionMessage ( 'persimmons.connection.v1' , owner );
const extensionInventory = owner => extensionMessage ( 'persimmons.inventory.v1' , owner );
2026-10-04 02:44:55 +00:00
2026-10-04 08:05:18 +00:00
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 ; }
2026-10-04 02:44:55 +00:00
}
2026-10-12 14:28:00 -07:00
// One refresh at a time. Signed in, a pass (session, then the vault index and the vault list, then the extension)
// takes longer than the 1.5 s poll, and each new pass used to make the one in flight bail out before rendering, so
// the vault stayed empty forever. Now a refresh asked for during another runs once more after it (actions, focus),
// and the poll simply skips while one is running.
let refreshing = null , refreshAgain = false ;
function refresh () {
if ( refreshing ) { refreshAgain = true ; return refreshing ; }
refreshing = ( async () => {
try { do { refreshAgain = false ; await refreshOnce (); } while ( refreshAgain ); }
finally { refreshing = null ; }
})();
return refreshing ;
}
async function refreshOnce () {
2026-10-04 08:05:18 +00:00
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 ();
2026-10-04 02:44:55 +00:00
2026-10-04 08:05:18 +00:00
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 ));
2026-10-12 14:28:00 -07:00
setInterval (() => { if ( ! refreshing && ! document . hidden && ! $ ( 'siteModal' ) ? . open ) refresh (). catch (() => {}); }, 1500 );
2026-10-04 08:05:18 +00:00
refresh (). catch ( failed );