aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js12
-rw-r--r--src/app.js5
-rw-r--r--src/asset-url.js6
-rw-r--r--src/i18n.js3
-rw-r--r--src/nostr-login.js3
-rw-r--r--src/recipient-search.js4
-rw-r--r--src/scan.js3
-rw-r--r--tools/standalone-file-test.js42
8 files changed, 68 insertions, 10 deletions
diff --git a/build.js b/build.js
index 848ce92..1fdd889 100644
--- a/build.js
+++ b/build.js
@@ -397,12 +397,18 @@ ${pwa && !NO_SW ? SW_REGISTER + '\n' : ''}</body>
}
// Self-contained single file (dev server, test tools, save-and-open-offline).
-export async function buildHtml({ minify = true, pwa = minify, features = process.env.HAL_FEATURES, staging = isStaging() } = {}) {
+export async function buildHtml({ minify = true, pwa = minify, features = process.env.HAL_FEATURES, staging = isStaging(), inlineFonts = false } = {}) {
const outputs = await bundleApp({ minify, features, staging });
let js = await outputs[0].text();
// Guard against a literal </script> inside the bundle closing our tag early.
js = js.replaceAll('</script', '<\\/script');
- const css = await Bun.file('./src/style.css').text();
+ let css = await Bun.file('./src/style.css').text();
+ // the standalone file has no fonts beside it on a disk, and the site's
+ // copies are same-origin only — so it carries its own
+ if (inlineFonts) for (const f of ['PublicSans.woff2', 'PublicSans-Italic.woff2']) {
+ const b64 = Buffer.from(await Bun.file('static/' + f).arrayBuffer()).toString('base64');
+ css = css.replaceAll(`url("/${f}")`, `url(data:font/woff2;base64,${b64})`);
+ }
const html = await pageHtml({ css, staging, pwa, scriptHtml: `<script>${js}</script>` });
return html.replaceAll('{{COMMIT}}', gitCommit());
}
@@ -453,7 +459,7 @@ if (import.meta.main) {
// included), no PWA plumbing — meant to be downloaded from /standalone.html
// and opened straight from a disk. Network features still reach out when
// online; the wallet itself needs no server.
- await Bun.write('dist/standalone.html', await buildHtml({ minify: true, pwa: false }));
+ await Bun.write('dist/standalone.html', await buildHtml({ minify: true, pwa: false, inlineFonts: true }));
// Lazy-loaded QR decoder — kept out of index.html, fetched only when a
// browser without BarcodeDetector opens the scanner.
diff --git a/src/app.js b/src/app.js
index dc17a37..54404d4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -8,6 +8,7 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo
import { qrSvg } from './qr.js';
import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
+import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf } from './nostr.js';
import { nip98Header } from './nip98.js';
import { uploadPublicMedia, MediaUploadError } from './media-upload.js';
@@ -3467,7 +3468,7 @@ const PUNK_PICKS = [7, 14, 21, 3, 33, 40, 47, 36, 61, 26, 12, 50];
// becomes this person's picture on every nostr client. The tiles in the
// picker are 56px, so they load the small copies.
const punkImg = (n) => `punks/${n}.webp`;
-const punkTile = (n) => `punks-sm/${n}.webp`;
+const punkTile = (n) => assetUrl(`punks-sm/${n}.webp`);
async function onbUpload(file) {
const fd = new FormData();
@@ -3752,7 +3753,7 @@ function onboardScreen() {
title(t('onbAvatarTitle')),
h('p', { class: 'muted', style: 'margin:0' }, t('onbAvatarBody')),
preview ? h('img', {
- class: 'onb-avatar', src: preview, alt: '',
+ class: 'onb-avatar', src: /^(https?|data|blob):/i.test(preview) ? preview : assetUrl(preview), alt: '',
style: ui.onbBusy ? 'opacity:.5' : '',
onError: (e) => { e.target.style.visibility = 'hidden'; },
}) : null,
diff --git a/src/asset-url.js b/src/asset-url.js
new file mode 100644
index 0000000..bd80240
--- /dev/null
+++ b/src/asset-url.js
@@ -0,0 +1,6 @@
+// The standalone wallet (standalone.html opened from a disk) has no files
+// beside it: punks, the lazy modules and the language packs come from the
+// site instead. Served from anywhere else, paths stay relative.
+export const SITE = 'https://v3.coinos.io/';
+const fromDisk = () => typeof location !== 'undefined' && location.protocol === 'file:';
+export const assetUrl = (path) => fromDisk() ? SITE + path.replace(/^\//, '') : path;
diff --git a/src/i18n.js b/src/i18n.js
index 951bc8d..8d67b3a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1,3 +1,4 @@
+import { assetUrl } from './asset-url.js';
// Lightweight i18n. t('key', {vars}) interpolates {var} and falls back to the
// English string (then the key). The chosen language persists in localStorage;
// otherwise it's guessed from the browser. Arabic/Urdu render right-to-left.
@@ -1307,7 +1308,7 @@ const _inflight = new Map();
export function loadLocale(code) {
if (code === 'en' || STR[code] || !SUPPORTED.has(code)) return Promise.resolve();
if (_inflight.has(code)) return _inflight.get(code);
- const p = fetch('locales/' + code + '.json')
+ const p = fetch(assetUrl('locales/' + code + '.json'))
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d) STR[code] = d; })
.catch(() => {})
diff --git a/src/nostr-login.js b/src/nostr-login.js
index d6cb978..fe60261 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -40,6 +40,7 @@ import { getPublicKey, finalizeEvent, nip44 } from './nostr.js';
import { SimplePool } from 'nostr-tools/pool';
import { generateSecretKey } from 'nostr-tools/pure';
import { decode as nip19decode } from 'nostr-tools/nip19';
+import { assetUrl } from './asset-url.js';
// NIP-46 loads lazily from dist/nip46.js (same pattern as the QR decoder):
// bunker logins are rare, and the module is heavy. Tests running outside a
@@ -50,7 +51,7 @@ function loadNip46() {
if (typeof globalThis !== 'undefined' && globalThis.__nip46) return (_nip46 = Promise.resolve(globalThis.__nip46));
_nip46 = new Promise((resolve, reject) => {
const s = document.createElement('script');
- s.src = 'nip46.js';
+ s.src = assetUrl('nip46.js');
s.onload = () => (window.__nip46 ? resolve(window.__nip46) : reject(new Error('signer module unavailable')));
s.onerror = () => { _nip46 = null; reject(new Error('could not load the remote-signer module')); };
document.head.appendChild(s);
diff --git a/src/recipient-search.js b/src/recipient-search.js
index e0339ca..20083ee 100644
--- a/src/recipient-search.js
+++ b/src/recipient-search.js
@@ -9,6 +9,7 @@
// prefixes already seen.
import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js';
+import { assetUrl } from './asset-url.js';
// coinos punks: the deterministic default avatar — same formula the coinos
// app uses (last byte of the pubkey picks one of 64). Initials show through
@@ -16,8 +17,7 @@ import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js';
// Self-hosted (dist/punks) — coinos.io's own copies 502 for a third of the
// set. Same deterministic formula the coinos app uses.
const punkN = (pk) => Math.floor((parseInt(pk.slice(-2), 16) / 256) * 64) + 1;
-export const punkImageUrl = (n, small = false) =>
- `${typeof location !== 'undefined' && location.protocol === 'file:' ? '' : '/'}punks${small ? '-sm' : ''}/${n}.webp`;
+export const punkImageUrl = (n, small = false) => assetUrl(`/punks${small ? '-sm' : ''}/${n}.webp`);
export const punkUrl = (pk) => punkImageUrl(punkN(pk));
// The art is 240px, which is what a punk published as someone's nostr
// picture should be — and eight times what a 30px circle needs. We ship a
diff --git a/src/scan.js b/src/scan.js
index a0d520c..083d5bb 100644
--- a/src/scan.js
+++ b/src/scan.js
@@ -1,3 +1,4 @@
+import { assetUrl } from './asset-url.js';
// Camera QR scanner. Opens a fullscreen overlay, streams the back camera, and
// resolves with the decoded string (or null if the user cancels).
//
@@ -16,7 +17,7 @@ function loadJsQR() {
if (!jsqrLoader) {
jsqrLoader = new Promise((resolve, reject) => {
const s = document.createElement('script');
- s.src = 'jsqr.js';
+ s.src = assetUrl('jsqr.js');
s.onload = () => (window.jsQR ? resolve(window.jsQR) : reject(new Error('decoder unavailable')));
s.onerror = () => reject(new Error('decoder unavailable'));
document.head.appendChild(s);
diff --git a/tools/standalone-file-test.js b/tools/standalone-file-test.js
new file mode 100644
index 0000000..cb30565
--- /dev/null
+++ b/tools/standalone-file-test.js
@@ -0,0 +1,42 @@
+// dist/standalone.html opened straight from a disk: its own font, the
+// wizard's punks and a language pack all arrive with no files beside it.
+// Run: bun run build && bun tools/standalone-file-test.js
+import assert from 'node:assert/strict';
+import { resolve } from 'node:path';
+import puppeteer from 'puppeteer-core';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [], failed = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ page.on('requestfailed', (r) => { if (r.url().startsWith('file:')) failed.push(r.url()); });
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('file://' + resolve('dist/standalone.html'));
+ await sleep(1500);
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); e?.click(); return !!e; }, x);
+ assert(await page.evaluate(async () => { await document.fonts.ready; return document.fonts.check('16px "Public Sans"') && [...document.fonts].some((f) => f.status === 'loaded'); }), 'the embedded font loads');
+ // a language pack from the site, read cross-origin
+ await page.evaluate(() => { const s = [...document.querySelectorAll('select')].find((x) => [...x.options].some((o) => o.value === 'de')); s.value = 'de'; s.dispatchEvent(new Event('change', { bubbles: true })); });
+ await page.waitForFunction(() => document.documentElement.lang === 'de' || /Wallet erstellen|Neue Wallet/i.test(document.body.innerText), { timeout: 8000 })
+ .catch(() => {});
+ const de = await page.evaluate(() => document.body.innerText.slice(0, 120));
+ assert(!/Create a new wallet/.test(de), 'German strings arrived: ' + de);
+ await page.evaluate(() => { const s = [...document.querySelectorAll('select')].find((x) => [...x.options].some((o) => o.value === 'en')); s.value = 'en'; s.dispatchEvent(new Event('change', { bubbles: true })); });
+ await sleep(500);
+ await click('create a new wallet'); await sleep(1500);
+ await click('skip verification'); await sleep(1500);
+ // the avatar step comes after a name claim; resume the wizard there
+ await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar'));
+ await page.reload(); await sleep(2500);
+ await page.waitForSelector('.onb-punk', { timeout: 8000 }).catch(async (e) => { console.log(await page.evaluate(() => document.body.innerText.slice(0, 300))); throw e; });
+ await page.waitForFunction(() => [...document.querySelectorAll('img.onb-punk')].every((i) => i.complete), { timeout: 10000 });
+ const punks = await page.evaluate(() => [...document.querySelectorAll('img.onb-punk')].map((i) => ({ w: i.naturalWidth, src: i.src.slice(0, 40) })));
+ const big = await page.evaluate(async () => { const i = document.querySelector('img.onb-punk'); i.click(); await new Promise((r) => setTimeout(r, 1500));
+ const a = document.querySelector('img.onb-avatar'); return { ok: a.complete && a.naturalWidth > 0, src: a.src.slice(0, 40) }; });
+ assert(big.ok, 'the picked punk shows big: ' + JSON.stringify(big));
+ assert(punks.length >= 10 && punks.every((p) => p.w > 0), 'every punk tile painted: ' + JSON.stringify(punks.slice(0, 3)));
+ assert.deepEqual(failed, [], 'nothing looked for beside the file');
+ assert.deepEqual(errors, []);
+ console.log(`✓ standalone from disk: embedded font, German pack, ${punks.length} punks from ${punks[0].src}`);
+} finally { await browser.close(); }