aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/LoadingSplash.svelte
blob: 41477a4ea50509bc2f2b5c2d4a95902652054f71 (plain) (blame)
1
2
3
4
5
6
7
<script>
  import Icon from "$comp/Icon.svelte";
</script>

<div class="flex justify-center items-center">
  <Icon icon="logo" style="mt-[calc(50vh-72px)]" />
</div>
-left: 5px; padding-right: 5px; } .highlight .hll { background-color: #ffffcc } .highlight .c { color: #888 } /* Comment */ .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ .highlight .k { color: #080; font-weight: bold } /* Keyword */ .highlight .ch { color: #888 } /* Comment.Hashbang */ .highlight .cm { color: #888 } /* Comment.Multiline */ .highlight .cp { color: #C00; font-weight: bold } /* Comment.Preproc */ .highlight .cpf { color: #888 } /* Comment.PreprocFile */ .highlight .c1 { color: #888 } /* Comment.Single */ .highlight .cs { color: #C00; font-weight: bold; background-color: #FFF0F0 } /* Comment.Special */ .highlight .gd { color: #000; background-color: #FDD } /* Generic.Deleted */ .highlight .ge { font-style: italic } /* Generic.Emph */ .highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ .highlight .gr { color: #A00 } /* Generic.Error */ .highlight .gh { color: #333 } /* Generic.Heading */ .highlight .gi { color: #000; background-color: #DFD } /* Generic.Inserted */ .highlight .go { color: #888 } /* Generic.Output */ .highlight .gp { color: #555 } /* Generic.Prompt */ .highlight .gs { font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #666 } /* Generic.Subheading */ .highlight .gt { color: #A00 } /* Generic.Traceback */ .highlight .kc { color: #080; font-weight: bold } /* Keyword.Constant */ .highlight .kd { color: #080; font-weight: bold } /* Keyword.Declaration */ .highlight .kn { color: #080; font-weight: bold } /* Keyword.Namespace */ .highlight .kp { color: #080 } /* Keyword.Pseudo */ .highlight .kr { color: #080; font-weight: bold } /* Keyword.Reserved */ .highlight .kt { color: #888; font-weight: bold } /* Keyword.Type */ .highlight .m { color: #00D; font-weight: bold } /* Literal.Number */ .highlight .s { color: #D20; background-color: #FFF0F0 } /* Literal.String */ .highlight .na { color: #369 } /* Name.Attribute */ .highlight .nb { color: #038 } /* Name.Builtin */ .highlight .nc { color: #B06; font-weight: bold } /* Name.Class */ .highlight .no { color: #036; font-weight: bold } /* Name.Constant */ .highlight .nd { color: #555 } /* Name.Decorator */ .highlight .ne { color: #B06; font-weight: bold } /* Name.Exception */ .highlight .nf { color: #06B; font-weight: bold } /* Name.Function */ .highlight .nl { color: #369; font-style: italic } /* Name.Label */ .highlight .nn { color: #B06; font-weight: bold } /* Name.Namespace */ .highlight .py { color: #369; font-weight: bold } /* Name.Property */ .highlight .nt { color: #B06; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #369 } /* Name.Variable */ .highlight .ow { color: #080 } /* Operator.Word */ .highlight .w { color: #BBB } /* Text.Whitespace */ .highlight .mb { color: #00D; font-weight: bold } /* Literal.Number.Bin */ .highlight .mf { color: #00D; font-weight: bold } /* Literal.Number.Float */ .highlight .mh { color: #00D; font-weight: bold } /* Literal.Number.Hex */ .highlight .mi { color: #00D; font-weight: bold } /* Literal.Number.Integer */ .highlight .mo { color: #00D; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #D20; background-color: #FFF0F0 } /* Literal.String.Affix */ .highlight .sb { color: #D20; background-color: #FFF0F0 } /* Literal.String.Backtick */ .highlight .sc { color: #D20; background-color: #FFF0F0 } /* Literal.String.Char */ .highlight .dl { color: #D20; background-color: #FFF0F0 } /* Literal.String.Delimiter */ .highlight .sd { color: #D20; background-color: #FFF0F0 } /* Literal.String.Doc */ .highlight .s2 { color: #D20; background-color: #FFF0F0 } /* Literal.String.Double */ .highlight .se { color: #04D; background-color: #FFF0F0 } /* Literal.String.Escape */ .highlight .sh { color: #D20; background-color: #FFF0F0 } /* Literal.String.Heredoc */ .highlight .si { color: #33B; background-color: #FFF0F0 } /* Literal.String.Interpol */ .highlight .sx { color: #2B2; background-color: #F0FFF0 } /* Literal.String.Other */ .highlight .sr { color: #080; background-color: #FFF0FF } /* Literal.String.Regex */ .highlight .s1 { color: #D20; background-color: #FFF0F0 } /* Literal.String.Single */ .highlight .ss { color: #A60; background-color: #FFF0F0 } /* Literal.String.Symbol */ .highlight .bp { color: #038 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #06B; font-weight: bold } /* Name.Function.Magic */ .highlight .vc { color: #369 } /* Name.Variable.Class */ .highlight .vg { color: #D70 } /* Name.Variable.Global */ .highlight .vi { color: #33B } /* Name.Variable.Instance */ .highlight .vm { color: #369 } /* Name.Variable.Magic */ .highlight .il { color: #00D; font-weight: bold } /* Literal.Number.Integer.Long */
// Avatar pictures are whatever their owner uploaded — often the full-size
// original (megabytes, to paint a 30px circle). We downscale each one once
// and keep the thumbnail beside the profile, so later boots paint the face
// from localStorage with no network at all.
//
// Here: a wallet is given a profile whose picture is a large cross-origin
// image, and we check that a thumbnail is made and persisted, that the small
// avatar then paints from it alone (the original is never requested again),
// and that a refresh of the profile doesn't throw the thumbnail away.
//
// Run: bun tools/avatar-thumb-test.js
import puppeteer from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { seedPubkey } from '../src/nostr.js';
import { buildHtml } from '../build.js';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };

// Generate a real PNG in the test browser; no external scratchpad fixture.
let big;
const html = await buildHtml({ minify: true, pwa: false });
let originalHits = 0;
let failedCorsHits = 0;
const server = Bun.serve({
  port: 5235,
  async fetch(req) {
    const u = new URL(req.url);
    if (u.pathname === '/fail.png') {
      if (req.headers.get('sec-fetch-mode') === 'cors') failedCorsHits++;
      return new Response('unavailable', { status: 503, headers: { 'access-control-allow-origin': '*' } });
    }
    if (u.pathname.startsWith('/punks')) {
      const f = Bun.file('dist' + u.pathname);
      if (await f.exists()) return new Response(await f.arrayBuffer(), { headers: { 'content-type': 'image/webp' } });
      return new Response('no', { status: 404 });
    }
    // the shape that broke a real avatar: the picture is behind a redirect,
    // so the bytes land in the HTTP cache under the FINAL url when the plain
    // background-image loads them — and a crossOrigin <img> for the same
    // picture then reuses that no-CORS entry and fails the CORS check
    if (u.pathname === '/redir.png') return new Response(null, { status: 302, headers: { location: '/big.png', 'access-control-allow-origin': '*' } });
    if (u.pathname === '/big.png') {
      originalHits++;
      return new Response(big, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*', 'cache-control': 'no-store' } });
    }
    return new Response(html, { headers: { 'content-type': 'text/html' } });
  },
});
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
try {
  big = Buffer.from(await page.evaluate(() => {
    const c = document.createElement('canvas');
    c.width = c.height = 1024;
    const ctx = c.getContext('2d');
    const pixels = ctx.createImageData(c.width, c.height);
    for (let i = 0; i < pixels.data.length; i += 4) {
      pixels.data[i] = (i * 13) % 251;
      pixels.data[i + 1] = (i * 29) % 253;
      pixels.data[i + 2] = (i * 7) % 255;
      pixels.data[i + 3] = 255;
    }
    ctx.putImageData(pixels, 0, 0);
    return c.toDataURL('image/png').split(',')[1];
  }), 'base64');
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5235/', { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click('create a new wallet'); await sleep(500);
  await click('to import'); await sleep(400);
  await page.waitForSelector('textarea');
  const mnemonic = generateMnemonic(wordlist);
  await page.type('textarea', mnemonic);
  await click('open wallet');
  await waitText('receive', 20000);

  // the face in the header is the seed's own nostr identity
  const pk = seedPubkey(mnemonic, '', 0);
  check('found the wallet identity', !!pk, pk ? pk.slice(0, 12) + '…' : 'none');

  // give it a picture: a large cross-origin image
  // the seed-keyed slot and the watch-only xpub mirror both look like
  // `btc-wallet-cache:<hash>` — seed the row under every one of them
  const bases = await page.evaluate((pkHex) => {
    const ks = Object.keys(localStorage).filter((k) => /^btc-wallet-cache:[0-9a-f]+$/.test(k));
    for (const b of ks) localStorage.setItem(b + ':profiles', JSON.stringify({
      [pkHex]: { name: 'Face Test', picture: 'http://localhost:5235/big.png', t: Date.now() },
    }));
    return ks.length;
  }, pk);
  check('seeded the profile cache', bases > 0, bases + ' cache slot(s)');
  originalHits = 0;
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await sleep(2500);

  const row = await page.evaluate(() => {
    const rows = window.coinosCache.keys().filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
      .flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')));
    const r = rows.find((x) => x && x.thumb) || rows[0] || {};
    return { hasThumb: typeof r.thumb === 'string', kind: (r.thumb || '').slice(0, 20), len: (r.thumb || '').length, thumbFor: r.thumbFor === r.picture };
  });
  check('a thumbnail was made and persisted', row.hasThumb, row.kind + ' ' + row.len + ' chars');
  check('it is pinned to the picture it came from', row.thumbFor);
  check('it is small', row.len > 0 && row.len < 16000, row.len + ' chars');

  // The whole point of the thumbnail is that no circle in the app ever has
  // to fetch the original again. The feed's face is the biggest of them, so
  // the thumbnail has to cover IT at phone pixel density — if someone grows
  // that circle without growing THUMB_PX, every feed row quietly starts
  // pulling down full-size originals again.
  const covers = await page.evaluate(() => {
    const el = document.createElement('div');
    el.className = 'chat-avatar note-avatar';
    el.style.position = 'absolute'; el.style.visibility = 'hidden';
    document.body.appendChild(el);
    const css = parseFloat(getComputedStyle(el).width);
    el.remove();
    const rows = window.coinosCache.keys().filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
      .flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')));
    const r = rows.find((x) => x && (x.thumb || '').length) || {};
    return new Promise((done) => {
      const i = new Image();
      i.onload = () => done({ css, thumb: i.naturalWidth });
      i.onerror = () => done({ css, thumb: 0 });
      i.src = r.thumb || '';
    });
  });
  check('the thumbnail covers the biggest circle at 3x',
    covers.thumb >= covers.css * 3, `${covers.thumb}px thumbnail for a ${covers.css}px circle`);

  // the small avatar paints from the thumbnail alone
  const style = await page.evaluate(() => {
    const a = document.querySelector('.header-avatar .ava-img') || document.querySelector('.ava-img');
    return a ? a.getAttribute('style') : 'NONE header=' + (document.querySelector('.header-avatar') || {}).innerHTML;
  });
  console.log('   header avatar:', String(style).slice(0, 160));
  check('the header avatar paints from the local copy', /data:image/.test(style));
  check('...and never asks for the original', !/localhost:5235\/big\.png/.test(style), style.slice(0, 40));

  // and a reload makes no request for the original at all
  originalHits = 0;
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await sleep(2000);
  check('a later boot fetches no picture at all', originalHits === 0, originalHits + ' request(s)');

  // A punk picture is our own art: painted from disk at the size being drawn,
  // never fetched from coinos.io (whose copies 502 for a third of the set).
  await page.evaluate((pkHex) => {
    for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
      localStorage.setItem(k + ':profiles', JSON.stringify({
        [pkHex]: { name: 'Punk Test', picture: 'https://v3.coinos.io/punks/7.webp', t: Date.now() },
      }));
  }, pk);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await sleep(1200);
  const punkStyle = await page.evaluate(() => {
    const a = document.querySelector('.header-avatar .ava-img') || document.querySelector('.ava-img');
    return a ? a.getAttribute('style') : '';
  });
  check('a punk picture paints from the local small copy', /punks-sm\/7\.webp/.test(punkStyle), punkStyle.slice(0, 60));
  check('...and never from coinos.io', !/coinos\.io/.test(punkStyle));

  // And someone with no picture at all still gets a punk — the small one.
  await page.evaluate((pkHex) => {
    for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
      localStorage.setItem(k + ':profiles', JSON.stringify({ [pkHex]: { name: 'Bare', picture: null, t: Date.now() } }));
  }, pk);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await sleep(1200);
  const punkSrc = await page.evaluate(() => {
    const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk');
    return i ? i.getAttribute('src') : '';
  });
  check('the default face is the small punk', /^\/punks-sm\//.test(punkSrc), punkSrc);

  // And someone we have never cached at all — the case that used to sit as a
  // blank circle until a relay answered — is drawn from their pubkey alone.
  await page.evaluate(async () => {
    for (const k of window.coinosCache.keys().filter((x) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(x))) window.coinosCache.removeItem(k);
    await window.coinosCache.flush();
  });
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  const strangerSrc = await page.evaluate(() => {
    const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk');
    return i ? i.getAttribute('src') : '';
  });
  check('an uncached face is drawn straight away, not left blank', /^\/punks-sm\//.test(strangerSrc), strangerSrc || 'blank circle');

  // A picture behind a redirect, loaded plainly first (as the avatar always
  // is) and only then thumbnailed.
  await page.evaluate((pkHex) => {
    for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
      localStorage.setItem(k + ':profiles', JSON.stringify({
        [pkHex]: { name: 'Redirect Test', picture: 'http://127.0.0.1:5235/redir.png', t: Date.now(),
          thumbFail: 'http://127.0.0.1:5235/redir.png', thumbFailAt: Date.now(), thumbFails: 5 },
      }));
  }, pk);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await sleep(3000);
  const redirThumb = await page.evaluate(() => {
    const rows = window.coinosCache.keys().filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
      .flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')));
    // The seed above wrote this profile into EVERY wallet's cache, but only
    // the one actually on screen paints the avatar and so makes the
    // thumbnail. Asking the first matching row was a coin toss on
    // localStorage key order — take the best copy, not an arbitrary one.
    const all = rows.filter((x) => x && /redir/.test(x.picture || ''));
    const r = all.find((x) => (x.thumb || '').length) || all[0] || {};
    return { thumb: (r.thumb || '').length, fail: !!r.thumbFail };
  });
  check('an old thumbnail failure retries immediately and clears on success', redirThumb.thumb > 0 && !redirThumb.fail,
    redirThumb.thumb ? redirThumb.thumb + ' chars' : 'failed');

  // If the corrected path also fails, start a new backoff and keep it
  // across boots rather than repeatedly downloading a broken picture.
  await page.evaluate((pkHex) => {
    for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
      localStorage.setItem(k + ':profiles', JSON.stringify({
        [pkHex]: { name: 'Failure Test', picture: 'http://127.0.0.1:5235/fail.png', t: Date.now(),
          thumbFail: 'http://127.0.0.1:5235/fail.png', thumbFailAt: Date.now(), thumbFails: 5 },
      }));
  }, pk);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await sleep(1500);
  const failure = await page.evaluate(() => window.coinosCache.keys()
    .filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
    .flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')))
    .find((r) => r.thumbFailVersion === 1));
  check('a fresh failure starts a new backoff', failedCorsHits === 1 && failure?.thumbFails === 1);
  failedCorsHits = 0;
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await sleep(1500);
  check('current failures still respect backoff after reload', failedCorsHits === 0);
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ faces come back instantly' : '\n❌ failed');
process.exit(ok ? 0 : 1);