aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js77
1 files changed, 67 insertions, 10 deletions
diff --git a/src/app.js b/src/app.js
index eb51e2f..6d6933f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -585,6 +585,15 @@ let _publicChatMode = false;
// welcome screen is the one screen that must never be guessed at. The boot
// shell holds the frame for those few ms instead; see the dispatch below.
let _bootDeciding = true;
+// Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page
+// starts): the app notes the phase it reaches, and clears the count once it
+// has lived eight seconds or leaves cleanly. A page iOS kills while opening
+// does neither, so the third start in a row opens in safe mode.
+function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }
+function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} }
+const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot);
+document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') bootSettled(); });
+addEventListener('pagehide', bootSettled);
// Belt and braces: a boot path that returns early (or throws) must not leave
// the welcome screen held back forever. Nothing legitimate is still deciding
// four seconds in — the store wait alone caps at 800ms.
@@ -1200,31 +1209,44 @@ let unit = (() => {
// been fetched — a third position showing blanks is worse than two that work.
function toggleUnit() {
const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc'];
+ const was = unit;
unit = ring[(ring.indexOf(unit) + 1) % ring.length];
try {
localStorage.setItem(UNIT_KEY, unit);
} catch {}
+ // An amount already typed into the send form is the same money in the new
+ // unit, not the same digits: 1000 sats does not become 1000 BTC. Whatever
+ // doesn't parse is left as typed.
+ for (const r of (ui.send && ui.send.recipients) || []) {
+ if (!r.amount || !String(r.amount).trim()) continue;
+ const sat = parseField(r.amount, was);
+ if (sat) r.amount = amountField(sat);
+ }
render();
}
// A sats figure as the amount field types it in the current unit — what a
// Max button or a fixed-amount invoice fills in. Fiat is floored to the
// cent so a filled-in maximum never parses back to more than was offered.
-// The figure it filled is remembered with its exact sats: a fiat maximum
+// The figures it filled are remembered with their exact sats: a fiat maximum
// rounded to the cent would otherwise parse back a few sats short and leave
-// them behind. Typing anything else parses as typed.
-let lastFill = null;
+// them behind. Typing anything else parses as typed. Several are kept so a
+// form with more than one amount round-trips each of them.
+const fills = new Map();
function amountField(sat) {
let s;
const r = unit === 'fiat' && rateNow();
if (unit === 'sats') s = String(sat);
else if (r) s = (Math.floor((sat / 1e8) * r * 100) / 100).toFixed(2);
else s = (sat / 1e8).toFixed(8);
- lastFill = { s, unit, sat };
+ fills.delete(unit + '|' + s);
+ fills.set(unit + '|' + s, sat);
+ while (fills.size > 16) fills.delete(fills.keys().next().value);
return s;
}
function parseField(v, u = unit) {
- if (lastFill && lastFill.unit === u && String(v).trim() === lastFill.s) return lastFill.sat;
+ const k = u + '|' + String(v).trim();
+ if (fills.has(k)) return fills.get(k);
return parseAmount(v, u, rateNow());
}
@@ -4188,16 +4210,18 @@ function onboardScreen() {
}
if (o.step === 'spend') {
return page([
- title(t('onbSpendTitle')),
- h('p', { class: 'muted', style: 'margin:0' }, t('onbSpendBody')),
+ title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')),
featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))),
// Optional means optional: one tap to the wallet, no questions — on
- // Savings, the account that exists without any setup.
+ // Savings, the account that exists without any setup (or, after a
+ // gift, the account the gift arrived in).
h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ const home = o.home || 'savings';
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
- ui.account = 'savings';
- try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
+ ui.account = home;
+ try { localStorage.setItem(ACCOUNT_KEY, home); } catch {}
ui.tab = 'receive';
render();
} }, t('onbNotNow')),
@@ -4417,6 +4441,15 @@ function claimMigratedName() {
attempt();
}
+// After a gift is claimed into a fresh wallet (and its seed written down):
+// the wizard's name → look → done steps, the rest of a first run. `home` is
+// the account a skip lands on — the one the gift arrived in.
+function offerProfileSetup({ home = 'savings' } = {}) {
+ if (!canOfferSpending()) return false;
+ ui.onb = { step: 'spend', enterAddr: undefined, fromGift: true, home };
+ return true;
+}
+
function leaveWizard() {
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
@@ -4478,6 +4511,7 @@ function walletScreen() {
brandHeader(true),
h('div', { class: 'mt16' }, balanceCard()),
ui.offlineFallback && wallet.offline ? offlineBanner() : null,
+ safeBootNotice(),
backupNotice(),
...featureAll('walletNotices'),
tabsBar(),
@@ -4536,6 +4570,24 @@ function backupNotice() {
} }, t('backupReminderGo')));
}
+// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a
+// row, so this boot made no automatic connections. Say so, name the phase
+// the last boot reached, and offer the way back.
+function safeBootNotice() {
+ if (!safeBoot()) return null;
+ const phase = (typeof window !== 'undefined' && window.__bootPhase) || '?';
+ const report = () => ['Coinos safe mode', 'build ' + (typeof window._v === 'string' ? window._v : '?'),
+ 'starts without a clean boot: ' + window.__safeBoot, 'last phase: ' + phase, navigator.userAgent].join('\n');
+ return h('div', { class: 'notice info col', style: 'margin:12px 0 0;gap:8px' },
+ h('b', {}, t('safeBootTitle')),
+ h('span', { class: 'small' }, t('safeBootBody', { n: window.__safeBoot - 1, phase })),
+ h('div', { class: 'row', style: 'gap:8px' },
+ h('button', { class: 'btn-sm', onClick: () => { bootSettled(); location.reload(); } }, t('safeBootOpen')),
+ h('button', { class: 'btn-sm', onClick: (e) => {
+ navigator.clipboard?.writeText(report()).then(() => { e.target.textContent = t('safeBootCopied'); }).catch(() => {});
+ } }, t('safeBootCopy'))));
+}
+
function offlineBanner() {
return h(
'div',
@@ -6439,6 +6491,7 @@ const ctx = {
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk || null,
getAccount: () => accountSel(),
setAccount: (a, dir) => setAccountSel(a, dir),
+ offerProfileSetup, pickConfirm,
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
// Profile pictures retain the metadata-scrubbing image uploader. Public
@@ -6824,6 +6877,7 @@ window.addEventListener('load', () => {
}, 1000);
});
loadLocale(getLang()).finally(async () => {
+ bootPhase('boot');
applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
// Public read-only community chat: no wallet, no login — the join material
// ships in the bundle, so the page decrypts client-side (src/public-chat.js).
@@ -6849,6 +6903,7 @@ loadLocale(getLang()).finally(async () => {
// and a wallet opening in fiat should open with a number in it.
refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {});
setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000);
+ bootPhase('store');
const storeReady = featureHook('arkStoreReady');
// the IndexedDB caches (profiles, reports, …) open alongside: a wallet's
// caches are read as soon as it opens
@@ -6859,6 +6914,8 @@ loadLocale(getLang()).finally(async () => {
_bootDeciding = false; // from here on the screen is a decision, not a guess
const opened = restoreAccountsState();
if (!opened) render();
+ bootPhase('ui');
+ setTimeout(bootSettled, 8000);
if (WAKE_PAYLOAD != null) {
// Nothing opened — a locked vault, or no wallet on this device. There is
// nothing to answer with, so say so now.