1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
|
// 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, readdir } from 'node:fs/promises';
const FAVICON =
'data:image/svg+xml,' +
encodeURIComponent(
`<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>`
);
// 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: 'Hal',
short_name: 'Hal',
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}}';
// jsqr.js is precached so the lazy QR decoder is available offline too (for
// installed PWAs on browsers without a native BarcodeDetector).
const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js'];
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;
}
// Cache-first, but cache same-origin assets (e.g. the chosen locale, icons)
// on first fetch so they're available offline without precaching them all.
e.respondWith(
caches.match(req).then(
(hit) =>
hit ||
fetch(req).then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
)
);
});
`;
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="Hal">
<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>`;
// Bundle the standalone jsQR decoder (sets window.jsQR) for lazy loading.
export async function buildJsQr({ minify = true } = {}) {
const result = await Bun.build({
entrypoints: ['./src/jsqr-global.js'],
target: 'browser',
minify,
});
if (!result.success) {
for (const log of result.logs) console.error(log);
throw new Error('jsqr bundle failed');
}
return result.outputs[0].text();
}
// The silent-payment scanning worker, bundled separately (its own global scope)
// and embedded as base64 so the single-file app can spawn it from a Blob URL.
export async function buildSpWorker({ minify = true } = {}) {
const result = await Bun.build({
entrypoints: ['./src/sp-worker.js'],
target: 'browser',
minify,
});
if (!result.success) {
for (const log of result.logs) console.error(log);
throw new Error('sp-worker bundle failed');
}
return result.outputs[0].text();
}
export async function buildHtml({ minify = true, pwa = minify } = {}) {
const result = await Bun.build({
entrypoints: ['./src/app.js'],
target: 'browser',
minify,
});
if (!result.success) {
for (const log of result.logs) console.error(log);
throw new Error('bundle failed');
}
let js = await result.outputs[0].text();
// Guard against a literal </script> inside the bundle closing our tag early.
js = js.replaceAll('</script', '<\\/script');
const workerB64 = Buffer.from(await buildSpWorker({ minify })).toString('base64');
const css = await Bun.file('./src/style.css').text();
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="color-scheme" content="light dark">
<title>Hal</title>
<link rel="icon" href="${FAVICON}">
<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;}catch(e){}</script>
${pwa ? PWA_HEAD : ''}<style>${css}</style>
</head>
<body>
<div id="app"></div>
<script>globalThis.__SP_WORKER__=${JSON.stringify(workerB64)}</script>
<script>${js}</script>
${pwa ? SW_REGISTER + '\n' : ''}</body>
</html>`;
}
if (import.meta.main) {
await mkdir('dist', { recursive: true });
const html = await buildHtml({ minify: true });
await Bun.write('dist/index.html', html);
// Lazy-loaded QR decoder — kept out of index.html, fetched only when a
// browser without BarcodeDetector opens the scanner.
await Bun.write('dist/jsqr.js', await buildJsQr());
// Per-language strings — fetched on demand so visitors only download theirs.
await mkdir('dist/locales', { recursive: true });
const locales = await readdir('src/locales');
for (const f of locales) await Bun.write('dist/locales/' + f, Bun.file('src/locales/' + f));
// 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, jsqr.js, ${locales.length} locales`);
}
|