From a672725964fa99243baf5e32fea09b641ed4064c Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:33:29 +0000 Subject: [PATCH] Igneum Wallet, for 0.1.3: the update card, one centred card over the window for an update The banner under the header stays; the card is the first sight of an update. The same card as the miner's (one language: the mark with a progress ring, "Igneum Wallet 0.1.3", one line (is available, is downloading with the percent, is ready to install, Installing. The app restarts itself., did not install with the one-line cause and Try again), up to three lines of release notes from the manifest with the rest behind "What changed", the size, Install now and Later. Escape and the backdrop are Later. Reduced motion is honoured. Rules (ui/update-card.js, pure, ui/update-card.test.mjs): the card never opens while the send screen is open, while a Touch ID or Windows Hello line is on screen, while a wallet is being created or imported, or while the app quits; it waits and comes once the block lifts. Later hides this version at this stage and leaves the banner; the card comes back for a newer version, or when the download is ready and automatic updates are off (with them on it installs by itself). An open card follows its update through downloading, ready, installing and failed; installing and failed never open a card by themselves. The engine serves ui/update-card.js next to app.js (src/server.rs). ?update=[&auto=0][&card=1] on the page shows every state without a manifest. The version stays 0.1.2 until the next ship. CI runs the new test file. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 + app/igneum-wallet/README.md | 10 +++ app/igneum-wallet/src/server.rs | 2 + app/igneum-wallet/ui/app.css | 34 +++++++ app/igneum-wallet/ui/app.js | 78 ++++++++++++++++ app/igneum-wallet/ui/index.html | 22 +++++ app/igneum-wallet/ui/update-card.js | 104 ++++++++++++++++++++++ app/igneum-wallet/ui/update-card.test.mjs | 97 ++++++++++++++++++++ 8 files changed, 349 insertions(+) create mode 100644 app/igneum-wallet/ui/update-card.js create mode 100644 app/igneum-wallet/ui/update-card.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d1cb6931..54935857e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,5 @@ jobs: run: bash tools/ci/identity-check.sh - name: relay unit tests (parsers, secret compare) run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs + - name: wallet update card (wording, note lines, when the card shows) + run: node --test app/igneum-wallet/ui/update-card.test.mjs diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md index b3186dc92..ee79654e0 100644 --- a/app/igneum-wallet/README.md +++ b/app/igneum-wallet/README.md @@ -140,6 +140,16 @@ States (`state.update.status`, what the banner says): | manual | the engine cannot swap itself (not in a bundle, Applications not writable): "Open the download" | | error | what failed, in `state.update.error`; a version whose apply failed is never re-applied by itself | +The window shows an update twice over. The card (`ui/update-card.js`, `renderUpdateCard` in `ui/app.js`) is the +first sight: one centred card with the mark and a progress ring, "Igneum Wallet 0.1.3", one line, up to three lines of +the manifest's notes (the rest behind "What changed"), the size, Install now and Later. It never opens while the send +screen is open, while a Touch ID or Windows Hello line is on screen, or while a wallet is being created or imported; +it waits and comes once that is over. Later (also Escape and the backdrop) hides that version at that stage and +leaves the banner; the card comes back for a newer version, or when the download is ready and automatic updates are +off. An open card follows its update through downloading, ready, installing ("Installing. The app restarts itself.") +and failed (the one-line cause, Try again). The banner is the small strip that stays. `?update=[&auto=0][&card=1]` +on the page shows each state without a manifest; `ui/update-card.test.mjs` checks the words and the rules. + The setting "Install updates by itself when nothing is being sent" (`settings.json: auto_update`) defaults to on. The safe moment is no send in flight: no `/api/send` running, no quote given in the last 180 s (a confirm screen may be open), no sent transaction still waiting for its block, no create flow half way. A version below the manifest's diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index fd08312b2..aad764e89 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -10,6 +10,7 @@ use std::sync::Arc; const INDEX: &str = include_str!("../ui/index.html"); const CSS: &str = include_str!("../ui/app.css"); const JS: &str = include_str!("../ui/app.js"); +const CARD_JS: &str = include_str!("../ui/update-card.js"); const MARK: &str = include_str!("../ui/mark.svg"); const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png"); const FONT_MONO_400: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexMono-400.woff2"); @@ -44,6 +45,7 @@ fn handle(mut stream: TcpStream, shared: Arc) { ("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false), ("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false), ("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false), + ("GET", "/update-card.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", CARD_JS.as_bytes(), false), ("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true), ("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true), ("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true), diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index b3d9db29b..a4db10f98 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -419,3 +419,37 @@ body{user-select:text;-webkit-user-select:text} .bio-state.wait{color:var(--ash)} .fp-glyph{flex:0 0 auto;color:var(--ember)} .err .bio-state{font-family:var(--mono)} + +/* the update card (update-card.js, app.js renderUpdateCard; the miner carries the same block): the mark with its + ring, the name, one line, up to three note lines, the size, Install now and Later. Over every screen. */ +.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease} +@keyframes fade{from{opacity:0}to{opacity:1}} +.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:8px;box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none;user-select:none;-webkit-user-select:none} +.upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none} +.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:12px} +.upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))} +.upd-ring{position:absolute;inset:0;transform:rotate(-90deg)} +.upd-ring .track{fill:none;stroke:var(--line-2);stroke-width:3} +.upd-ring .arc{fill:none;stroke:var(--ember);stroke-width:3;stroke-linecap:round;stroke-dasharray:276.5;stroke-dashoffset:276.5;transition:stroke-dashoffset .4s linear,stroke .3s ease} +.upd-mark.none .track{stroke:var(--line)} +.upd-mark.full .arc{stroke:var(--molten);stroke-dashoffset:0} +.upd-mark.busy .arc{stroke-dasharray:69 207.5;stroke-dashoffset:0;animation:spin 1.1s linear infinite;transform-origin:50% 50%} +.upd-mark.failed .arc{stroke:var(--ember);stroke-dashoffset:0;opacity:.55} +.upd-pct{position:absolute;left:50%;bottom:-11px;transform:translateX(-50%);font-size:11px;font-weight:500;letter-spacing:.06em;color:var(--molten);background:var(--obsidian);border:1px solid var(--line-2);border-radius:999px;padding:2px 8px;font-variant-numeric:tabular-nums} +.upd-name{font-size:24px;font-weight:700;letter-spacing:-.01em;position:relative} +.upd-line{font-size:16px;color:var(--ink-2);line-height:1.4;position:relative} +.upd-cause{font-size:12px;color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word} +.upd-notes{list-style:none;margin:8px auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:14px;color:var(--bone);line-height:1.4} +.upd-notes:empty{display:none} +.upd-notes li{display:flex;align-items:baseline;gap:10px;text-align:left} +.upd-notes li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px} +.upd-more{font:inherit;font-size:12px;font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);background:transparent;border:0;padding:4px 8px;cursor:pointer;border-radius:6px;margin-top:2px} +.upd-more:hover{color:var(--bone)} +.upd-all{list-style:none;margin:0;padding:10px 14px;width:100%;max-height:150px;overflow:auto;text-align:left;font-size:13px;color:var(--ink-2);line-height:1.5;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;gap:4px;user-select:text;-webkit-user-select:text} +.upd-meta{font-size:12px;color:var(--ash);letter-spacing:.04em;margin-top:8px;min-height:18px;line-height:1.5;max-width:44ch} +.upd-meta:empty{display:none} +.upd-actions{display:flex;gap:12px;align-items:center;justify-content:center;flex-wrap:wrap;margin-top:16px;position:relative} +.upd-actions:empty{display:none} +.upd-actions .btn.primary{min-width:160px} +@media (prefers-reduced-motion:reduce){.upd-wrap,.upd-card{animation:none}.upd-ring .arc{transition:none}.upd-mark.busy .arc{animation:none;stroke-dasharray:207.5 69}} +@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:8px}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index e4f40a607..e37feb1be 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -105,7 +105,9 @@ function render() { if (s.quitting) { pillText = 'stopping'; pillCls = 'pill'; } $('pill-text').textContent = pillText; $('pill').className = pillCls; $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} · nothing is bought or sold`; + if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); renderUpdate(s); + renderUpdateCard(s); // unlock: the fingerprint button when enrolled and the app window is the host; the password form stays $('unlock-address').textContent = s.display; const b = s.biometric || {}; @@ -456,6 +458,82 @@ $('update-open').onclick = () => post('/api/update/open'); $('update-install').onclick = () => { post('/api/update/install'); toast('installing now'); }; $('update-install-s').onclick = () => { post('/api/update/install'); toast('installing now'); }; $('update-later').onclick = () => { sessionStorage.setItem('update-later', state.update.status + ':' + state.update.version); $('update-banner').hidden = true; }; + +// ---- the update card (update-card.js): one update, centred, over every screen; the banner above stays the small strip ---- +// mem: open (the card is up), later (the key Later was pressed on), seen (the version it was shown for); +// asked: Install now was pressed on this card and the engine has not flipped to applying yet +const updMem = { open: false, later: '', seen: '' }; +let updSig = '', updAsked = '', updMore = false; +// ?update=[&auto=0][&card=1]: the card and the banner for each state, without an engine (screenshots) +const forcedUpdate = new URLSearchParams(location.search).get('update'); +if (forcedUpdate && new URLSearchParams(location.search).get('card') === '1') updMem.open = true; +function sampleUpdate(kind) { + const u = { status: 'ready', version: '0.1.3', url: '', notes: 'The update card: one centred card with Install now and Later. Touch ID confirms every send on the Mac; Windows Hello is written but untested. The coin and the chain line sit on the balance card. The version shows in the header and in Settings.', file: '', error: '', checked_at: Date.now() / 1000 - 40, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 19479807, auto: new URLSearchParams(location.search).get('auto') !== '0', wait: 'installs as soon as nothing is being sent', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '' }; + if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)'; + if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; } + if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); } + if (kind === 'staging') { u.status = 'staging'; u.ready = false; } + if (kind === 'applying') { u.status = 'applying'; u.applying = true; } + if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission)'; } + if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.urgent_text = 'Igneum Wallet 0.1.2 is no longer supported by the network. Installing 0.1.3 now.'; } + if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; } + if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; } + return u; +} +function cardCtx(s) { + const bioLine = [...document.querySelectorAll('.bio-state')].some(e => e.offsetParent !== null); + return { phase: document.body.dataset.phase, view: document.body.dataset.view, bioBusy: bio.busy, bioLine, bioName: what(), quitting: !!s.quitting }; +} +function renderUpdateCard(s) { + const m = UpdateCard.model(s.update, s), d = UpdateCard.decide(m, cardCtx(s), updMem), wrap = $('upd'); + if (!d.show) { + if (updMem.open || !wrap.hidden) { updMem.open = false; wrap.hidden = true; updSig = ''; } + return; + } + if (!updMem.open) { updMem.open = true; updMem.seen = m.version; updMore = false; if (updAsked !== m.key) updAsked = ''; } + const asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed'; + const sig = [m.key, m.stage, m.line, m.note, m.cause, m.ring, asked ? 'asked' : '', m.lines.join('|')].join('\u0001'); + if (sig !== updSig) { + updSig = sig; + $('upd-name').textContent = m.name; + $('upd-line').textContent = m.line; + $('upd-cause').textContent = m.cause; $('upd-cause').hidden = !m.cause; + $('upd-notes').innerHTML = m.lines.map(l => `
  • ${esc(l)}
  • `).join(''); + $('upd-all').innerHTML = m.all.map(l => `
  • ${esc(l)}
  • `).join(''); + $('upd-more').hidden = !m.more; if (!m.more) updMore = false; + $('upd-more').textContent = updMore ? 'Less' : 'What changed'; $('upd-more').setAttribute('aria-expanded', updMore ? 'true' : 'false'); + $('upd-all').hidden = !updMore; + $('upd-mark').className = 'upd-mark ' + m.ring; + const acts = asked ? [{ act: 'install', label: m.stage === 'ready' || m.stage === 'deferred' ? 'Installing' : 'Installs when ready', primary: true, disabled: true }, { act: 'later', label: 'Later' }] : m.actions; + $('upd-actions').innerHTML = acts.map(a => ``).join(''); + wrap.dataset.dismiss = m.dismissable ? '1' : ''; + } + // the ring and the percent move every poll; the words above do not + if (m.ring === 'progress') { $('upd-arc').style.strokeDashoffset = (276.5 * (1 - m.pct / 100)).toFixed(1); $('upd-pct').textContent = m.pct + '%'; $('upd-pct').hidden = m.stage !== 'downloading'; } + else { $('upd-arc').style.strokeDashoffset = ''; $('upd-pct').hidden = true; } + const meta = []; + if (m.stage === 'downloading' && m.size) meta.push(m.pct + '% of ' + m.size); + else if (m.size && m.stage === 'available') meta.push(m.size + ' download'); + else if (m.size && (m.stage === 'ready' || m.stage === 'manual' || m.stage === 'deferred' || m.stage === 'staging')) meta.push(m.size + ', downloaded'); + if (m.note) meta.push(m.note); + $('upd-meta').textContent = meta.join(' · '); + if (wrap.hidden) { wrap.hidden = false; $('upd-card').focus({ preventScroll: true }); } +} +function cardLater() { + if (!updMem.open || !$('upd').dataset.dismiss) return; + const m = UpdateCard.model(state && state.update, state || {}); + updMem.later = m ? m.key : updMem.later; updMem.open = false; $('upd').hidden = true; updSig = ''; +} +$('upd').addEventListener('click', e => { + if (e.target === $('upd')) { cardLater(); return; } + const b = e.target.closest('[data-act]'); if (!b || b.disabled) return; + const act = b.dataset.act, m = UpdateCard.model(state && state.update, state || {}); + if (act === 'later') cardLater(); + else if (act === 'install' || act === 'retry') { post('/api/update/install').catch(() => {}); updAsked = m ? m.key : ''; updSig = ''; toast(act === 'retry' ? 'trying the update again' : m && m.stage === 'ready' ? 'installing now' : 'installs as soon as it is downloaded'); if (state) renderUpdateCard(state); } + else if (act === 'open') post('/api/update/open').catch(() => {}); +}); +$('upd-more').onclick = function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); }; +document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('upd').hidden) { e.preventDefault(); cardLater(); } }); $('auto-update').onchange = async ev => { try { await post('/api/update/auto', { on: ev.target.checked }); } catch (e) { toast(e.message); } }; $('remove-go').onclick = async () => { $('remove-err').textContent = ''; diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index 428729f7d..ca1ef5bd4 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -286,7 +286,29 @@ + + + + diff --git a/app/igneum-wallet/ui/update-card.js b/app/igneum-wallet/ui/update-card.js new file mode 100644 index 000000000..42052bcdb --- /dev/null +++ b/app/igneum-wallet/ui/update-card.js @@ -0,0 +1,104 @@ +/* The update card (pure; update-card.test.mjs loads this file): the same card, rules and words as the miner's + (app/igneum-app/ui/app.js, UpdateCard), with the wallet's name and the wallet's reasons to wait. + A centred card over the window for one update: the mark with a progress ring, "Igneum Wallet 0.1.3", one line + (is available, is downloading, is ready to install, Installing, did not install), up to three lines of release + notes with the rest behind "What changed", the size, Install now and Later. + model() turns state.update into what the card says. decide() says whether it shows now, given the window + (ctx) and what the card already did (mem: open, later, seen): + deferred while the send screen is open, while Touch ID (or Windows Hello) is on screen, while a wallet is + being created or imported, while the app quits + later Later, Escape or the backdrop hides this version at this stage; the banner keeps it + back a newer version, or the download ready while automatic updates are off (with them on it installs + by itself, so a dismissed download stays dismissed); an open card follows its update to the end + installing and failed show only on an open card (Install now was pressed here); the banner carries the rest */ +var UpdateCard = (function () { + 'use strict'; + var NAME = 'Igneum Wallet', LINE_MAX = 70, LINES = 3; + var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'later', label: 'Later' }; + var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true }; + function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } + function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '…' : t; } + // the manifest's notes as sentences: split on line breaks, then after . ! ? ; (no lookbehind: the Mac WebView) + function sentences(text) { + var out = [], parts = String(text || '').split(/\n+/); + for (var i = 0; i < parts.length; i++) { + var p = parts[i], at = 0; + for (var j = 0; j < p.length; j++) { + var ch = p.charAt(j), next = p.charAt(j + 1); + if ((ch === '.' || ch === '!' || ch === '?' || ch === ';') && (next === ' ' || next === '')) { push(p.slice(at, j + 1)); at = j + 1; } + } + push(p.slice(at)); + } + function push(s) { s = s.trim().replace(/[;:,]+$/, ''); if (s) out.push(cap(s)); } + return out; + } + // at most LINES lines, each under LINE_MAX characters (cut at a word); more = something is left for "What changed" + function splitNotes(text) { + var all = sentences(text), lines = [], more = all.length > LINES; + for (var i = 0; i < all.length && lines.length < LINES; i++) { + var s = all[i].replace(/\.$/, ''); + if (s.length >= LINE_MAX) { var cut = s.lastIndexOf(' ', LINE_MAX - 2); s = s.slice(0, cut > 20 ? cut : LINE_MAX - 2).replace(/[,;:]$/, '') + '…'; more = true; } + lines.push(s); + } + return { lines: lines, more: more, all: all }; + } + function size(bytes) { if (!(bytes > 0)) return ''; return bytes < 1e6 ? (bytes / 1e6).toFixed(1) + ' MB' : Math.round(bytes / 1e6) + ' MB'; } + function card(stage, u, over) { + var ver = u.version || ''; + var m = { stage: stage, version: ver, key: 'update:' + ver + ':' + (stage === 'available' || stage === 'downloading' || stage === 'staging' ? 'pending' : stage), name: NAME + ' ' + ver, line: '', note: '', cause: '', size: size(u.size), pct: -1, ring: 'none', actions: [], dismissable: true, auto: !!u.auto }; + var n = splitNotes(u.notes); m.lines = n.lines; m.more = n.more; m.all = n.all; + if (over) for (var k in over) m[k] = over[k]; + return m; + } + // u = state.update; s = { version, quitting } + function model(u, s) { + if (!u || !u.version) return null; + s = s || {}; + var pct = u.progress > 0 ? Math.min(100, Math.round(u.progress * 100)) : 0; + if (u.status === 'error') { + if (/no update manifest configured/.test(u.error || '')) return null; + return card('failed', u, { line: u.rolled_back ? 'did not stay up and was rolled back.' : 'did not install.', cause: firstLine(u.error, 120), ring: 'failed', actions: [RETRY, LATER], lines: [], more: false }); + } + if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) { + return card('installing', u, { line: 'Installing. The app restarts itself.', note: s.quitting ? 'A moment.' : 'Your key stays where it is.', ring: 'busy', dismissable: false, lines: [], more: false }); + } + var m = null; + switch (u.status) { + case 'available': m = card('available', u, { line: 'is available.', actions: [INSTALL, LATER] }); break; + case 'downloading': m = card('downloading', u, { line: 'is downloading.', pct: pct, ring: 'progress', actions: [INSTALL, LATER] }); break; + case 'staging': m = card('staging', u, { line: 'is being checked.', pct: 100, ring: 'progress', actions: [INSTALL, LATER] }); break; + case 'ready': + var why = /failed to install before/.test(u.wait || '') ? 'It failed to install before.' : u.auto ? 'It installs by itself when nothing is being sent.' : ''; + m = card('ready', u, { line: 'is ready to install.', note: why, ring: 'full', actions: [INSTALL, LATER] }); break; + case 'deferred': m = card('deferred', u, { line: 'is waiting for permission.', note: 'It installs the next time someone is at this PC.', ring: 'full', actions: [INSTALL, LATER] }); break; + case 'manual': m = card('manual', u, { line: 'is downloaded.', note: 'Open the disk image and drag the app over the old one.', ring: 'full', actions: [OPEN, LATER] }); break; + } + if (!m) return null; + if (u.urgent && u.urgent_text) { m.note = u.urgent_text; m.dismissable = false; m.actions = m.actions.filter(function (a) { return a.act !== 'later'; }); } + return m; + } + // why the card may not open now: ctx = { phase, view, bioBusy, bioLine, bioName, quitting } + function blocked(ctx) { + ctx = ctx || {}; + if (ctx.quitting) return 'the app is quitting'; + if (ctx.phase === 'create' || ctx.phase === 'import') return 'a wallet is being ' + (ctx.phase === 'create' ? 'created' : 'imported'); + if (ctx.view === 'send') return 'the send screen is open'; + if (ctx.bioBusy || ctx.bioLine) return (ctx.bioName || 'Touch ID') + ' is on screen'; + return ''; + } + // mem = { open: the card is up, later: the key Later was pressed on, seen: the version the card was shown for } + function decide(m, ctx, mem) { + mem = mem || {}; + if (!m) return { show: false, why: 'none' }; + var b = blocked(ctx); + if (b && m.stage !== 'installing') return { show: false, why: 'deferred', reason: b }; + if (m.stage === 'installing' || m.stage === 'failed') return mem.open ? { show: true, why: 'open' } : { show: false, why: 'strip' }; + if (!m.dismissable) return { show: true, why: 'urgent' }; + if (mem.later === m.key) return { show: false, why: 'later' }; + if (mem.open) return { show: true, why: 'open' }; + if (m.stage === 'ready' && m.auto && mem.seen === m.version) return { show: false, why: 'auto' }; + return { show: true, why: m.stage === 'ready' ? 'ready' : 'new' }; + } + return { NAME: NAME, LINE_MAX: LINE_MAX, LINES: LINES, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide }; +})(); +if (typeof module === 'object' && module && module.exports) module.exports = UpdateCard; diff --git a/app/igneum-wallet/ui/update-card.test.mjs b/app/igneum-wallet/ui/update-card.test.mjs new file mode 100644 index 000000000..4cc7b82c5 --- /dev/null +++ b/app/igneum-wallet/ui/update-card.test.mjs @@ -0,0 +1,97 @@ +// node --test app/igneum-wallet/ui/update-card.test.mjs (no dependencies) +// Loads ui/update-card.js (plain browser JS, run with `module` defined and no `document`) and checks what the card +// says for each update state, the release-note lines, and when it shows or waits (a send, Touch ID, a wallet flow). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'update-card.js'), 'utf8'); +const mod = { exports: {} }; +new Function('module', src)(mod); +const C = mod.exports; +const { model, decide, blocked, splitNotes, sentences, size } = C; + +const NOTES = 'The update card: one centred card with Install now and Later. Touch ID confirms every send on the Mac; Windows Hello is written but untested. The coin and the chain line sit on the balance card. The version shows in the header and in Settings.'; +const upd = (over) => ({ status: 'ready', version: '0.1.3', url: '', notes: NOTES, file: '', error: '', checked_at: 0, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 19_479_807, auto: true, wait: '', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '', ...over }); +const quiet = { phase: 'home', view: 'overview', bioBusy: false, bioLine: false, bioName: 'Touch ID', quitting: false }; + +test('release notes: sentences, three lines under 70 characters, the rest behind What changed', () => { + assert.deepEqual(sentences('one. two! three? four; five'), ['One.', 'Two!', 'Three?', 'Four', 'Five']); + assert.deepEqual(sentences('v0.1.3 ships. 24 words.'), ['V0.1.3 ships.', '24 words.']); + const n = splitNotes(NOTES); + assert.equal(n.lines.length, 3); + for (const l of n.lines) assert.ok(l.length < C.LINE_MAX, `${l.length}: ${l}`); + assert.deepEqual(n.lines, ['The update card: one centred card with Install now and Later', 'Touch ID confirms every send on the Mac', 'Windows Hello is written but untested']); + assert.equal(n.more, true); assert.equal(n.all.length, 5); + assert.deepEqual(splitNotes('coin on the home screen, updates install by themselves'), { lines: ['Coin on the home screen, updates install by themselves'], more: false, all: ['Coin on the home screen, updates install by themselves'] }); + assert.deepEqual(splitNotes('').lines, []); + assert.equal(size(19_479_807), '19 MB'); assert.equal(size(0), ''); +}); + +test('what the card says: available, downloading with the ring, ready, installing, failed, manual, waiting', () => { + const a = model(upd({ status: 'available', downloaded: false, ready: false, progress: 0 }), {}); + assert.equal(a.name, 'Igneum Wallet 0.1.3'); assert.equal(a.line, 'is available.'); assert.equal(a.key, 'update:0.1.3:pending'); + assert.equal(a.size, '19 MB'); assert.deepEqual(a.actions.map((x) => x.label), ['Install now', 'Later']); + const d = model(upd({ status: 'downloading', downloaded: false, ready: false, progress: 0.43 }), {}); + assert.equal(d.line, 'is downloading.'); assert.equal(d.pct, 43); assert.equal(d.ring, 'progress'); assert.equal(d.key, a.key); + const r = model(upd(), {}); + assert.equal(r.line, 'is ready to install.'); assert.equal(r.note, 'It installs by itself when nothing is being sent.'); assert.equal(r.key, 'update:0.1.3:ready'); + assert.equal(model(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), {}).note, ''); + assert.equal(model(upd({ wait: 'a send is in flight; installing after it' }), {}).note, 'It installs by itself when nothing is being sent.'); + const i = model(upd({ status: 'applying', applying: true }), {}); + assert.equal(i.stage, 'installing'); assert.equal(i.line, 'Installing. The app restarts itself.'); assert.deepEqual(i.actions, []); assert.equal(i.dismissable, false); + assert.equal(model(upd({ wait: 'installing now' }), {}).stage, 'installing'); + const f = model(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line', ready: false }), {}); + assert.equal(f.line, 'did not install.'); assert.equal(f.cause, 'sha256 mismatch: the file is not what the manifest signed'); + assert.deepEqual(f.actions.map((x) => x.label), ['Try again', 'Later']); + assert.equal(model(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.1.3: did not stay up' }), {}).line, 'did not stay up and was rolled back.'); + assert.equal(model(upd({ status: 'manual', ready: false }), {}).actions[0].label, 'Open the download'); + assert.equal(model(upd({ status: 'deferred' }), {}).line, 'is waiting for permission.'); + const u = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'This version is no longer supported. Installing 0.1.3 now.' }), {}); + assert.equal(u.dismissable, false); assert.deepEqual(u.actions.map((x) => x.label), ['Install now']); + assert.equal(model(upd({ status: 'current', available: false }), {}), null); + assert.equal(model(upd({ status: 'off', version: '' }), {}), null); + assert.equal(model(upd({ status: 'error', error: 'no update manifest configured in this build' }), {}), null); + assert.equal(model(null, {}), null); +}); + +test('deferred: the send screen, Touch ID on screen, a wallet being created or imported, quitting', () => { + assert.equal(blocked(quiet), ''); + assert.equal(blocked({ ...quiet, view: 'send' }), 'the send screen is open'); + assert.equal(blocked({ ...quiet, bioBusy: true }), 'Touch ID is on screen'); + assert.equal(blocked({ ...quiet, bioLine: true, bioName: 'Windows Hello' }), 'Windows Hello is on screen'); + assert.equal(blocked({ ...quiet, phase: 'create' }), 'a wallet is being created'); + assert.equal(blocked({ ...quiet, phase: 'import' }), 'a wallet is being imported'); + assert.equal(blocked({ ...quiet, phase: 'unlock' }), ''); + assert.equal(blocked({ ...quiet, quitting: true }), 'the app is quitting'); + const a = model(upd({ status: 'available' }), {}); + assert.deepEqual(decide(a, { ...quiet, view: 'send' }, {}), { show: false, why: 'deferred', reason: 'the send screen is open' }); + assert.deepEqual(decide(a, { ...quiet, bioBusy: true }, {}), { show: false, why: 'deferred', reason: 'Touch ID is on screen' }); + // the block lifts: the card comes (it was queued, not dismissed) + assert.equal(decide(a, quiet, { open: false, later: '', seen: '' }).show, true); + assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), { ...quiet, view: 'send' }, { open: true }).show, true); +}); + +test('Later: hides this version at this stage; back for a newer version or a ready download with auto off', () => { + const pend = model(upd({ status: 'downloading', progress: 0.5, ready: false }), {}); + const ready = model(upd(), {}); + const readyOff = model(upd({ auto: false }), {}); + assert.deepEqual(decide(pend, quiet, {}), { show: true, why: 'new' }); + const later = { open: false, later: pend.key, seen: '0.1.3' }; + assert.deepEqual(decide(pend, quiet, later), { show: false, why: 'later' }); + assert.deepEqual(decide(ready, quiet, later), { show: false, why: 'auto' }); + assert.deepEqual(decide(readyOff, quiet, later), { show: true, why: 'ready' }); + assert.deepEqual(decide(readyOff, quiet, { open: false, later: readyOff.key, seen: '0.1.3' }), { show: false, why: 'later' }); + assert.deepEqual(decide(model(upd({ status: 'available', version: '0.1.4' }), {}), quiet, later), { show: true, why: 'new' }); + assert.deepEqual(decide(ready, quiet, { open: false, later: '', seen: '' }), { show: true, why: 'ready' }); + const open = { open: true, later: '', seen: '0.1.3' }; + assert.equal(decide(pend, quiet, open).why, 'open'); assert.equal(decide(ready, quiet, open).why, 'open'); + assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, open).show, true); + assert.equal(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, open).show, true); + assert.deepEqual(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, {}), { show: false, why: 'strip' }); + assert.deepEqual(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, {}), { show: false, why: 'strip' }); + const urgent = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'Unsupported.' }), {}); + assert.deepEqual(decide(urgent, quiet, { later: urgent.key }), { show: true, why: 'urgent' }); +});