aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-22 21:47:09 -0700
committerAdam Soltys <[email protected]>2026-06-22 21:47:09 -0700
commit7c542410dc78711bcf017dd3d23d560ed6349669 (patch)
tree79e3f911264d76851064b4da2c81e5c8f9a827db
parent44231703087408884ad3ed31c313fc306d4e90c8 (diff)
downloadcoinosv3-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.js95
-rw-r--r--src/app.js13
-rw-r--r--static/icon-192.pngbin0 -> 4109 bytes
-rw-r--r--static/icon-512.pngbin0 -> 11584 bytes
-rw-r--r--static/icon-maskable-512.pngbin0 -> 7086 bytes
-rw-r--r--static/icon-maskable.svg4
-rw-r--r--static/icon.svg4
7 files changed, 108 insertions, 8 deletions
diff --git a/build.js b/build.js
index 486908e..f11a9f6 100644
--- a/build.js
+++ b/build.js
@@ -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`);
}
diff --git a/src/app.js b/src/app.js
index bccb86d..8343290 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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
new file mode 100644
index 0000000..ce7992d
--- /dev/null
+++ b/static/icon-192.png
Binary files differ
diff --git a/static/icon-512.png b/static/icon-512.png
new file mode 100644
index 0000000..c0d953a
--- /dev/null
+++ b/static/icon-512.png
Binary files differ
diff --git a/static/icon-maskable-512.png b/static/icon-maskable-512.png
new file mode 100644
index 0000000..65abe21
--- /dev/null
+++ b/static/icon-maskable-512.png
Binary files differ
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">&#8383;</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">&#8383;</text>
+</svg>