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' }); +});