From b3cee21d7f6614c4ec0cf462ba4cfe3a3d4e308c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 23 Oct 2026 07:39:05 -0700 Subject: iPhone: a push shows its line first and the outcome replaces it; a crash loop on open lands in safe mode; a closed-app payment is one history row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PullCap's night of 2026-10-22 on an iPhone: four hourly payments made while closed, no notification for the fourth, then no more wake-ups until the app was opened — WebKit's silent-push quota. APNs accepted every push (no 410); the device had stopped listening. The stand-in line went up at the END of the push handler, after a payment that can take most of the budget, and a worker stopped before its last line showed nothing. On an Apple endpoint the line now goes up first and the outcome ("Payment sent") replaces it. The same phone then crashed on every open ("A problem repeatedly occurred"). An inline guard counts page starts before the app runs; the app clears the count after eight seconds alive or a clean leave, and notes the boot phase it reaches. A third start in a row opens in safe mode: no automatic Ark connection, a notice naming the last phase, "Open normally" and "Copy report". Every payment the worker made showed twice once the app opened: the worker's own ln-send row (merged from the mirror) and the "while closed" note the app adds from its spend log. The note now stamps the existing row, and lnFoldedIds hides a twin that still forms. The balance was never counted twice — it comes from the coins. Co-Authored-By: Claude Fable 5.1 --- src/app.js | 32 ++++++++++++++++++++++++++++++++ src/ark/manager.js | 18 ++++++++++++++++++ src/features/ark.js | 30 +++++++++++++++++++++++++++++- src/i18n.js | 5 +++++ src/sw-nwc.js | 15 ++++++++++++++- 5 files changed, 98 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index eb51e2f..ffe9ba8 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. +export function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} } +function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} } +export 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. @@ -4478,6 +4487,7 @@ function walletScreen() { brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, + safeBootNotice(), backupNotice(), ...featureAll('walletNotices'), tabsBar(), @@ -4536,6 +4546,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', @@ -6824,6 +6852,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 +6878,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 +6889,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. diff --git a/src/ark/manager.js b/src/ark/manager.js index 33bac55..0723fc1 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -119,6 +119,24 @@ export function lnFoldedIds(movements) { } if (best) out.add(best.id); } + // A Lightning payment the closed app's worker made is told twice once the + // app opens: the worker's own ln-send row (merged in from the mirror) and + // the "while closed" note the app adds from the worker's spend log. Per + // preimage (or payment hash), keep the row that names its inputs — the + // manager's — else the earliest. + const sends = (movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete' && (m.preimage || m.paymentHash)); + const keyOf = new Map(); // payment hash → the preimage key, where a row knows both + for (const s of sends) if (s.preimage && s.paymentHash) keyOf.set(s.paymentHash, 'p:' + s.preimage); + const byPay = new Map(); + for (const s of sends) { + const k = s.preimage ? 'p:' + s.preimage : (keyOf.get(s.paymentHash) || 'h:' + s.paymentHash); + (byPay.get(k) || byPay.set(k, []).get(k)).push(s); + } + for (const rows of byPay.values()) { + if (rows.length < 2) continue; + const keep = rows.find((r) => r.inputIds && r.inputIds.length) || rows.reduce((a, b) => ((a.ts || 0) <= (b.ts || 0) ? a : b)); + for (const r of rows) if (r !== keep) out.add(r.id); + } return out; } diff --git a/src/features/ark.js b/src/features/ark.js index 94dfc09..5a8dc61 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -24,6 +24,11 @@ function freeCoins(st) { function exitableCoins(st, tip) { return freeCoins(st).filter((v) => !(tip && v.expiryHeight && v.expiryHeight <= tip)); } +// The crash-loop guard's phase notes (build.js BOOT_GUARD, app.js bootPhase): +// a boot iOS killed shows the last one it reached, and three in a row open +// the app in safe mode, where nothing connects on its own. +const bootPhase = (p) => { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }; +const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot); import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee, lnShortfall } from '../ark/lightning.js'; @@ -547,9 +552,11 @@ export function arkFeature(ctx) { // holds is on screen the moment it can be. const store = wallet._arkStore; if (store && !store.ready) { + bootPhase('ark-store'); store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {}); return; } + if (safeBoot()) return; // safe mode: the coins show from the store; nothing dials out unasked if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().then(() => rescueOtherServers()).catch(() => {}); } } @@ -596,7 +603,7 @@ export function arkFeature(ctx) { } } function maybeInitArk() { - if (ark || arkConnectPromise) return; + if (ark || arkConnectPromise || safeBoot()) return; if (Date.now() - lastAutoInit < 20000) return; // A fresh snapshot may be held under its server pubkey, so arkWanted() // cannot see it until the handshake identifies the server and adopts it. @@ -681,8 +688,10 @@ export function arkFeature(ctx) { // Open the server connection while Nostr restores, but do not replay the // mailbox before the snapshot's history and cursor have arrived. const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false); + bootPhase('ark-init'); const inited = mgr.init().then(() => { if (gen !== arkInitGen) return; + bootPhase('ark-inited'); arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); render(); @@ -699,6 +708,7 @@ export function arkFeature(ctx) { arkConnectPromise = Promise.all([inited, restored]).then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; + bootPhase('ark-adopt'); // Sync may have arrived while init awaited the server handshake. adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark)); // Boards that died before funding (a failed tx build, a closed tab) @@ -739,12 +749,14 @@ export function arkFeature(ctx) { // sync() already reconciles on its first pass. Start the live stream // afterwards, at the caught-up cursor: running both together replayed // the backlog one message/render at a time alongside the batch read. + bootPhase('ark-sync'); tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); }); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); + bootPhase('ark-ready'); render(); return mgr; }).catch((e) => { @@ -1018,6 +1030,7 @@ export function arkFeature(ctx) { return; } if (!rec.mgr) return; + bootPhase('ark-merge'); const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -1027,8 +1040,11 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); + bootPhase('ark-reconciled'); await mgr.resumePending().catch(() => {}); + bootPhase('ark-resumed'); } // A leftover chain-6 pouch from before whole-balance background spending: @@ -4028,9 +4044,21 @@ export function arkFeature(ctx) { async arkBgNoteSpends(spends) { const mgr = await connectArk(); for (const sp of spends) { + // The worker's own manager wrote this payment's row into the mirror, + // and the merge on connect brings it over: a second row here showed + // every closed-app payment twice (PullCap, 2026-10-23). Stamp the + // row that exists instead; lnFoldedIds hides a twin that still + // forms when the merge lands after this note. + const twin = (mgr.state.movements || []).find((m) => m.type === 'ln-send' + && ((sp.preimage && m.preimage === sp.preimage) || (sp.paymentHash && m.paymentHash === sp.paymentHash))); + if (twin) { + if (!/while closed/.test(twin.detail || '')) twin.detail = `while closed · fee ${sp.feeSat || 0} sat`; + continue; + } mgr._movement({ type: 'ln-send', amountSat: sp.amountSat, status: 'complete', detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '', + paymentHash: sp.paymentHash || undefined, }); } mgr._save(); diff --git a/src/i18n.js b/src/i18n.js index edf9102..48faa49 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -783,6 +783,11 @@ const STR = { arkCoopSelectedAmt: '{n} selected coins · {amount}', arkCoopSelectedAmtOne: '1 selected coin · {amount}', arkCustodyTitle: 'Custody strategy', + safeBootTitle: 'Coinos opened in safe mode', + safeBootBody: 'The app was closed {n} times in a row before it finished opening, so automatic connections are paused this time. Your coins and keys are untouched. Last step reached: {phase}.', + safeBootOpen: 'Open normally', + safeBootCopy: 'Copy report', + safeBootCopied: 'Copied', arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos; after that, Coinos keeps honoring it. Renewing before expiry keeps your own exit, for a fee, so it is a choice:', arkCustodyCustodial: 'Allow coins to expire', arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation.', diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 0b65268..dbd2d98 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -155,14 +155,25 @@ self.addEventListener('push', (e) => { if (data.type !== 'nwc') { settle(async () => {}); return; } const startedAt = Date.now(); settle(async () => { + // WebKit's silent-push quota counts a push whose notification comes + // late: a payment can take most of the push budget, and a worker stopped + // before its last line never shows one (PullCap's night of 2026-10-22: + // four payments, no line for the fourth, then no more wakes). On an + // Apple endpoint the stand-in goes up FIRST; the outcome replaces it. + const apple = await applePush(); + const dropStandIn = async () => { + const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); + for (const n of open) n.close(); + }; + if (apple) { showed = true; await standIn(false).catch(() => {}); } // An open window handles requests itself with full wallet state — nudge // it and stay out of the way. On iOS only one on screen counts: a // backgrounded app is suspended, so a nudge to it went unanswered. const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const apple = await applePush(); const clients = all.filter((c) => !apple || c.visibilityState === 'visible'); if (clients.length) { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); + if (apple) await dropStandIn(); // the window on screen shows it live return; } // A wake is the only moment a closed wallet gets to look after its coins: @@ -189,6 +200,7 @@ self.addEventListener('push', (e) => { tag: handled.notify.tag || 'nwc-incoming', renotify: true, data: { url: './' }, }); + if (apple) await dropStandIn(); await tend(); return; } @@ -204,6 +216,7 @@ self.addEventListener('push', (e) => { tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, }); + if (apple) await dropStandIn(); }); }); -- cgit v1.2.3