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 --- build.js | 10 ++++++ src/app.js | 32 ++++++++++++++++++ src/ark/manager.js | 18 ++++++++++ src/features/ark.js | 30 ++++++++++++++++- src/i18n.js | 5 +++ src/sw-nwc.js | 15 ++++++++- tools/ln-receive-twin-test.js | 14 ++++++++ tools/safe-boot-test.js | 77 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tools/safe-boot-test.js diff --git a/build.js b/build.js index eb81f80..838e891 100644 --- a/build.js +++ b/build.js @@ -159,6 +159,15 @@ const SW_REGISTER = `navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` + `addEventListener('load',function(){navigator.serviceWorker.register('/sw.js',{updateViaCache:'none'}).catch(function(){})})}`; +// Crash-loop guard. iOS kills a page that dies twice in a row while opening +// ("A problem repeatedly occurred") and the wallet is locked out with nothing +// to go on. Count page starts here, before the app runs; the app clears the +// count once it has lived a while or is left cleanly (hidden / pagehide) and +// notes the boot phase it reaches. Three starts with no clear = the app +// opens in safe mode: no automatic connections, a notice naming the last +// phase, and a way back to a normal boot. +export const BOOT_GUARD = ``; + // Lazy bundles load as classic scripts into the page's global scope, where // the main bundle's minified top-level names already live — two Bun outputs // can mint the same identifier and die with "already been declared". An IIFE @@ -434,6 +443,7 @@ ${siteTags(staging)}${pwa ? PWA_HEAD : ''}`; +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/' + ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +try { + const page = await browser.newPage(), errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(() => { + if (!localStorage.getItem('sb-seeded')) { + localStorage.setItem('sb-seeded', '1'); + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); + // two starts already, neither cleared: the last one died merging the worker's state + localStorage.setItem('coinos-boot', '2'); + localStorage.setItem('coinos-boot-phase', 'ark-merge'); + } + }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.sbApp?.ready); + const guard = await page.evaluate(() => ({ safe: window.__safeBoot, phase: window.__bootPhase, count: localStorage.getItem('coinos-boot') })); + check('the third start in a row opens in safe mode, knowing the phase the last one reached', guard.safe === 3 && guard.phase === 'ark-merge' && guard.count === '3', JSON.stringify(guard)); + await page.evaluate(async (mn) => { + await sbApp.enterWallet(mn, '', { fresh: true, generated: true }); + sbApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step'); + sbApp.ui.tab = 'wallet'; sbApp.render(); + }, generateMnemonic(wordlist)); + await new Promise((r) => setTimeout(r, 1500)); + const notice = await page.evaluate(() => { const n = [...document.querySelectorAll('.notice')].find((x) => /safe mode/i.test(x.textContent)); return n ? n.textContent : ''; }); + check('the wallet home says so and names the phase', /safe mode/i.test(notice) && /ark-merge/.test(notice) && /2 times/.test(notice), notice.slice(0, 120)); + const arkState = await page.evaluate(() => ({ ark: !!sbArk.ark, connecting: sbArk.connecting, auto: sbArk.lastAutoInit })); + check('nothing dialled the Ark server on its own', !arkState.ark && !arkState.connecting && !arkState.auto, JSON.stringify(arkState)); + // a clean leave clears the count + await page.evaluate(() => dispatchEvent(new Event('pagehide'))); + check('leaving the page cleanly clears the count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null); + // "Open normally": the count is cleared and the page reloads into a plain boot + await page.evaluate(() => localStorage.setItem('coinos-boot', '3')); + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded' }), + page.evaluate(() => { const b = [...document.querySelectorAll('.notice button')].find((x) => /open normally/i.test(x.textContent)); b.click(); }), + ]); + await page.waitForFunction(() => window.sbApp?.ready); + const after = await page.evaluate(() => ({ safe: window.__safeBoot, count: localStorage.getItem('coinos-boot'), notice: [...document.querySelectorAll('.notice')].some((x) => /safe mode/i.test(x.textContent)) })); + check('"Open normally" reloads into a plain boot (one start counted, no notice)', after.safe === undefined && after.count === '1' && !after.notice, JSON.stringify(after)); + await new Promise((r) => setTimeout(r, 8600)); + check('a boot that lives eight seconds clears its count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null); + check('no page errors', errors.length === 0, errors.join(' | ').slice(0, 200)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ safe mode after a crash loop' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3