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=<kind>[&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 <noreply@anthropic.com>
This commit is contained in:
parent
96a9729de4
commit
a672725964
8 changed files with 349 additions and 0 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=<state>[&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
|
||||
|
|
|
|||
|
|
@ -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<Shared>) {
|
|||
("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),
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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=<kind>[&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 => `<li>${esc(l)}</li>`).join('');
|
||||
$('upd-all').innerHTML = m.all.map(l => `<li>${esc(l)}</li>`).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 => `<button class="btn ${a.primary ? 'primary big' : 'ghost'}" data-act="${esc(a.act)}"${a.disabled ? ' disabled' : ''}>${esc(a.label)}</button>`).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 = '';
|
||||
|
|
|
|||
|
|
@ -286,7 +286,29 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<!-- the update card (update-card.js, app.js renderUpdateCard): one update, centred; Later, Escape or the backdrop
|
||||
leaves the banner above -->
|
||||
<div class="upd-wrap" id="upd" hidden>
|
||||
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
|
||||
<div class="upd-mark" id="upd-mark">
|
||||
<svg class="upd-ring" viewBox="0 0 96 96" aria-hidden="true"><circle class="track" cx="48" cy="48" r="44"></circle><circle class="arc" id="upd-arc" cx="48" cy="48" r="44"></circle></svg>
|
||||
<img src="mark.svg" width="40" height="40" alt="">
|
||||
<span class="upd-pct mono" id="upd-pct" hidden></span>
|
||||
</div>
|
||||
<div class="eyebrow ember">update</div>
|
||||
<h2 class="upd-name" id="upd-name"></h2>
|
||||
<p class="upd-line" id="upd-line"></p>
|
||||
<p class="upd-cause mono" id="upd-cause" hidden></p>
|
||||
<ul class="upd-notes" id="upd-notes"></ul>
|
||||
<button class="upd-more" id="upd-more" type="button" aria-expanded="false" hidden>What changed</button>
|
||||
<ul class="upd-all" id="upd-all" hidden></ul>
|
||||
<p class="upd-meta mono" id="upd-meta"></p>
|
||||
<div class="upd-actions" id="upd-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="update-card.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
104
app/igneum-wallet/ui/update-card.js
Normal file
104
app/igneum-wallet/ui/update-card.js
Normal file
|
|
@ -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;
|
||||
97
app/igneum-wallet/ui/update-card.test.mjs
Normal file
97
app/igneum-wallet/ui/update-card.test.mjs
Normal file
|
|
@ -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' });
|
||||
});
|
||||
Loading…
Reference in a new issue