From 724a3a57ce1dd881cb3b9da0d009e91cf0390ee9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 15 Aug 2026 14:59:32 -0700 Subject: The password prompt asks how long: unlock for 5 minutes, 30, a day, or forever — the deadline survives refreshes and relocks on time; the skip button says what it does; the header padlock hangs open in green while you're in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv --- src/app.js | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- src/i18n.js | 6 ++++++ 2 files changed, 63 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 25cf508..fd9fb14 100644 --- a/src/app.js +++ b/src/app.js @@ -1217,6 +1217,14 @@ function switchAccount(id) { function restoreAccountsState() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} + // the "Unlock for" deadline outlives refreshes: past it, the password is + // required again no matter what the session cache still holds + if (unlockDeadlinePassed()) { + try { sessionStorage.removeItem(ACCOUNTS_KEY); localStorage.removeItem(UNLOCK_UNTIL_KEY); } catch {} + sess = null; + } else { + armUnlockDeadline(); + } if (sess && Array.isArray(sess.accounts) && sess.accounts.length) { accounts = sess.accounts; const active = accounts.find((a) => a.id === sess.activeId) || accounts[0]; @@ -1448,11 +1456,46 @@ function attemptVaultUnlock(password) { return true; } +// "Unlock for": a session deadline chosen at the password prompt. Past it the +// app relocks (back to this prompt) — wallets stay saved, nothing is wiped. +const UNLOCK_FOR_KEY = 'btc-wallet-unlock-for'; +const UNLOCK_UNTIL_KEY = 'btc-wallet-unlock-until'; +const UNLOCK_FOR_OPTIONS = [ + { ms: 300_000, label: 'unlockFor5m' }, + { ms: 1_800_000, label: 'unlockFor30m' }, + { ms: 86_400_000, label: 'unlockFor1d' }, + { ms: 0, label: 'unlockForever' }, +]; +let _unlockUntilTimer = null; +function armUnlockDeadline() { + clearTimeout(_unlockUntilTimer); _unlockUntilTimer = null; + let until = 0; + try { until = Number(localStorage.getItem(UNLOCK_UNTIL_KEY)) || 0; } catch {} + if (!until) return; + const left = until - Date.now(); + if (left <= 0) { + try { localStorage.removeItem(UNLOCK_UNTIL_KEY); } catch {} + if (activeAccount()) lock({ offerPassword: true }); + return; + } + _unlockUntilTimer = setTimeout(armUnlockDeadline, Math.min(left, 60_000)); +} +function unlockDeadlinePassed() { + try { const u = Number(localStorage.getItem(UNLOCK_UNTIL_KEY)) || 0; return u > 0 && Date.now() >= u; } catch { return false; } +} + // On-open vault unlock (password prompt). function unlockVault() { if (!attemptVaultUnlock(ui.vaultPw)) { ui.vaultError = t('pwWrong'); render(); return; } ui.vaultPw = ''; ui.vaultError = ''; + const ms = ui.unlockFor != null ? ui.unlockFor : Number(localStorage.getItem(UNLOCK_FOR_KEY)) || 0; + try { + localStorage.setItem(UNLOCK_FOR_KEY, String(ms)); + if (ms > 0) localStorage.setItem(UNLOCK_UNTIL_KEY, String(Date.now() + ms)); + else localStorage.removeItem(UNLOCK_UNTIL_KEY); + } catch {} + armUnlockDeadline(); if (accounts.length) activateAccount(accounts[0], { fresh: true }); else lock(); // everything got signed out by the timer } @@ -1567,6 +1610,7 @@ function onAppVisible() { clearTimeout(_awayTimer); _awayTimer = null; const awayAt = _awayAt(); _clearAwayAt(); if (awayAt) evaluateOverdue(Date.now() - awayAt); + armUnlockDeadline(); // background tabs throttle timers — recheck on return } // At boot the in-memory timer is gone (reload / discarded tab). Drop overdue @@ -1681,12 +1725,14 @@ function settingsBtn() { } function lockBtn() { + // While a wallet is open this shows an OPEN padlock in green — the state is + // "unlocked", the tap locks it. (Locked state has no header at all.) return h('button', { class: 'header-msgs', title: t('lockWallet'), 'aria-label': t('lockWallet'), onClick: () => lock({ offerPassword: true }), }, h('span', { - class: 'hm-ico', - html: '', + class: 'hm-ico', style: 'color:#1d9e63', + html: '', })); } @@ -2346,9 +2392,17 @@ function vaultScreen() { h('p', { class: 'small muted', style: 'margin:0' }, t('unlockSavedDesc')), h('input', { type: 'password', placeholder: t('password'), value: ui.vaultPw, onInput: (e) => (ui.vaultPw = e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') unlockVault(); } }), + h('div', { class: 'row between', style: 'align-items:center;gap:10px' }, + h('span', { class: 'small muted' }, t('unlockForLabel')), + h('select', { onChange: (e) => { ui.unlockFor = Number(e.target.value) || 0; } }, + UNLOCK_FOR_OPTIONS.map((o) => h('option', { + value: String(o.ms), + selected: (ui.unlockFor != null ? ui.unlockFor : Number(localStorage.getItem(UNLOCK_FOR_KEY)) || 0) === o.ms, + }, t(o.label))))), ui.vaultError && h('div', { class: 'notice err' }, ui.vaultError), h('button', { class: 'btn-primary btn-block', onClick: unlockVault }, t('unlock')), - h('button', { class: 'btn-ghost btn-block', onClick: skipVault }, t('useAnotherWallet')) + h('button', { class: 'btn-ghost btn-block', onClick: skipVault }, + loadWatchAccounts().length ? t('viewWithoutPassword') : t('useAnotherWallet')) ) ); } diff --git a/src/i18n.js b/src/i18n.js index f9e8d6d..d2fd6e5 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -671,6 +671,12 @@ const STR = { searchUsers: 'Search', spendingName: 'Spending', manageAccounts: 'Manage accounts', + unlockForLabel: 'Unlock for', + unlockFor5m: '5 minutes', + unlockFor30m: '30 minutes', + unlockFor1d: '1 day', + unlockForever: 'Forever', + viewWithoutPassword: 'View without password', arkExitSendTitle: 'Send on-chain', arkExitSendNote: 'Leaves your Spending balance and arrives on-chain — network fees come off what arrives.', savingsName: 'Savings', -- cgit v1.2.3