diff options
| author | Adam Soltys <[email protected]> | 2026-06-22 21:47:09 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-22 21:47:09 -0700 |
| commit | 7c542410dc78711bcf017dd3d23d560ed6349669 (patch) | |
| tree | 79e3f911264d76851064b4da2c81e5c8f9a827db | |
| parent | 44231703087408884ad3ed31c313fc306d4e90c8 (diff) | |
| download | coinosv3-7c542410dc78711bcf017dd3d23d560ed6349669.tar.gz coinosv3-7c542410dc78711bcf017dd3d23d560ed6349669.zip | |
Fix false "payment received" on import; PWA; send-page font
- Don't celebrate historical payments on import. receiveSeenIndex was
baselined to 0 before the scan ran, so when the scan advanced the
index every past payment looked new and flashed "payment received".
Leave it null until the post-scan ack logic baselines it.
- PWA: add a web manifest, ₿ icons (192/512 + maskable), theme/apple
meta tags, and a service worker that precaches the shell so the
installed app works offline. index.html stays self-contained; the
sidecar files just 404 harmlessly from file://. build.js emits them.
- Send page: drop btn-sm from the amount unit toggle so "sats" matches
the "Max" button size (both 16px).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rw-r--r-- | build.js | 95 | ||||
| -rw-r--r-- | src/app.js | 13 | ||||
| -rw-r--r-- | static/icon-192.png | bin | 0 -> 4109 bytes | |||
| -rw-r--r-- | static/icon-512.png | bin | 0 -> 11584 bytes | |||
| -rw-r--r-- | static/icon-maskable-512.png | bin | 0 -> 7086 bytes | |||
| -rw-r--r-- | static/icon-maskable.svg | 4 | ||||
| -rw-r--r-- | static/icon.svg | 4 |
7 files changed, 108 insertions, 8 deletions
@@ -1,8 +1,13 @@ -// Build a single, self-contained dist/index.html. +// Build a single, self-contained dist/index.html (plus PWA sidecar files). // // Everything — app code, the @scure/@noble crypto libs, the QR generator, and // all CSS — is bundled and inlined so the result is one file you can save and // open offline straight from the filesystem (file://). No server, no network. +// +// For the hosted site we also emit a web manifest, icons, and a small service +// worker so the page can be installed as a PWA and still work offline once +// installed. index.html stays self-contained; these are optional extras that +// simply 404 (harmlessly) when the file is opened on its own from file://. import { mkdir } from 'node:fs/promises'; @@ -12,7 +17,81 @@ const FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#f7931a"/><text x="16" y="23" font-size="20" font-family="Arial" font-weight="bold" text-anchor="middle" fill="#fff">₿</text></svg>` ); -export async function buildHtml({ minify = true } = {}) { +// Static assets copied verbatim into dist/ (icons referenced by the manifest). +const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg']; + +const MANIFEST = { + id: '/', + name: 'Bitcoin Wallet', + short_name: 'Bitcoin', + description: 'Self-custody Bitcoin wallet that runs entirely in your browser.', + start_url: './', + scope: './', + display: 'standalone', + background_color: '#eef0f3', + theme_color: '#f7931a', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +}; + +// Service worker: precache the app shell, network-first for the page (so a new +// deploy shows up), cache-first for the icons/manifest, and never touch the +// cross-origin explorer/Nostr requests. CACHE carries a build token so each +// deploy supersedes the previous cache. {{VERSION}} is filled in at build time. +const SW = `const CACHE = 'cold-{{VERSION}}'; +const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png']; + +self.addEventListener('install', (e) => { + self.skipWaiting(); + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL))); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + const req = e.request; + if (req.method !== 'GET') return; + if (new URL(req.url).origin !== self.location.origin) return; // explorer/ws → network + + if (req.mode === 'navigate') { + e.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put('./', copy)); + return res; + }) + .catch(() => caches.match('./')) + ); + return; + } + e.respondWith(caches.match(req).then((hit) => hit || fetch(req))); +}); +`; + +const PWA_HEAD = `<link rel="manifest" href="manifest.webmanifest"> +<meta name="theme-color" content="#f7931a"> +<meta name="mobile-web-app-capable" content="yes"> +<meta name="apple-mobile-web-app-capable" content="yes"> +<meta name="apple-mobile-web-app-status-bar-style" content="default"> +<meta name="apple-mobile-web-app-title" content="Bitcoin Wallet"> +<link rel="apple-touch-icon" href="icon-192.png"> +`; + +const SW_REGISTER = + `<script>if('serviceWorker'in navigator)addEventListener('load',function(){navigator.serviceWorker.register('sw.js').catch(function(){})});</script>`; + +export async function buildHtml({ minify = true, pwa = minify } = {}) { const result = await Bun.build({ entrypoints: ['./src/app.js'], target: 'browser', @@ -35,12 +114,12 @@ export async function buildHtml({ minify = true } = {}) { <meta name="color-scheme" content="light"> <title>Bitcoin Wallet</title> <link rel="icon" href="${FAVICON}"> -<style>${css}</style> +${pwa ? PWA_HEAD : ''}<style>${css}</style> </head> <body> <div id="app"></div> <script>${js}</script> -</body> +${pwa ? SW_REGISTER + '\n' : ''}</body> </html>`; } @@ -48,6 +127,14 @@ if (import.meta.main) { await mkdir('dist', { recursive: true }); const html = await buildHtml({ minify: true }); await Bun.write('dist/index.html', html); + + // PWA sidecars. + await Bun.write('dist/manifest.webmanifest', JSON.stringify(MANIFEST, null, 2)); + const version = Bun.hash(html).toString(36); + await Bun.write('dist/sw.js', SW.replace('{{VERSION}}', version)); + for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f)); + const kb = (Buffer.byteLength(html) / 1024).toFixed(0); console.log(`✓ dist/index.html written (${kb} KB) — open it offline, no server needed`); + console.log(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons`); } @@ -466,6 +466,10 @@ async function enterWallet(mnemonic, passphrase) { const hadCache = wallet.restoreCache(); // show last-known balance/history instantly ui.screen = 'wallet'; ui.tab = 'receive'; + // Not baselined yet — stays null until the scan + ack logic below sets it, + // so the celebration never fires for payments that were already there at + // import (the index only looks "advanced" because the scan hadn't run yet). + ui.receiveSeenIndex = null; ui.send = blankSend(); ui.draft = null; ui.sendResult = null; @@ -760,11 +764,12 @@ function tabContent() { // ---------------------------------------------------------------- Receive function receiveTab() { - if (ui.receiveSeenIndex == null) ui.receiveSeenIndex = wallet.nextReceiveIndex; - // A payment landed on the shown address (the fresh index advanced past what // the user last saw) — celebrate, and wait for a tap before showing the next. - if (wallet.nextReceiveIndex > ui.receiveSeenIndex) { + // Until receiveSeenIndex has been baselined (post-scan, in enterWallet) it + // stays null and we never celebrate, so importing a wallet with existing + // history doesn't flash "payment received" for old payments. + if (ui.receiveSeenIndex != null && wallet.nextReceiveIndex > ui.receiveSeenIndex) { let amt = 0; for (let i = ui.receiveSeenIndex; i < wallet.nextReceiveIndex; i++) { const e = wallet._addrInfo(0, i); @@ -837,7 +842,7 @@ function recipientRow(s, r, i) { r.amount = v; }, }), - h('button', { type: 'button', class: 'btn-sm', title: t('switchUnit'), onClick: toggleUnit }, unitLabel()), + h('button', { type: 'button', title: t('switchUnit'), onClick: toggleUnit }, unitLabel()), single && h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, t('max')) ) ); diff --git a/static/icon-192.png b/static/icon-192.png Binary files differnew file mode 100644 index 0000000..ce7992d --- /dev/null +++ b/static/icon-192.png diff --git a/static/icon-512.png b/static/icon-512.png Binary files differnew file mode 100644 index 0000000..c0d953a --- /dev/null +++ b/static/icon-512.png diff --git a/static/icon-maskable-512.png b/static/icon-maskable-512.png Binary files differnew file mode 100644 index 0000000..65abe21 --- /dev/null +++ b/static/icon-maskable-512.png diff --git a/static/icon-maskable.svg b/static/icon-maskable.svg new file mode 100644 index 0000000..7075afd --- /dev/null +++ b/static/icon-maskable.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> + <rect width="512" height="512" fill="#f7931a"/> + <text x="256" y="356" font-size="300" font-family="Arial, Helvetica, sans-serif" font-weight="bold" text-anchor="middle" fill="#ffffff">₿</text> +</svg> diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..5ef4cf0 --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> + <rect width="512" height="512" rx="96" fill="#f7931a"/> + <text x="256" y="368" font-size="340" font-family="Arial, Helvetica, sans-serif" font-weight="bold" text-anchor="middle" fill="#ffffff">₿</text> +</svg> |
