diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b37b644e..c8a795fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,5 +65,5 @@ jobs: run: bash tools/ci/copied-sources-check.sh - name: relay unit tests (parsers, secret compare, the wake endpoint) run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs - - name: miner app notice strip (ordering, keys, wording, timers) - run: node --test app/igneum-app/ui/notices.test.mjs + - name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows) + run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 73ed4fd19..31b2ac855 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -298,6 +298,40 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(-- .feed .k.block{color:var(--ember)} .feed .k.build{color:var(--ink-2)} +/* the update card (app.js, UpdateCard; the wallet 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 the key sheet and the settings panel. */ +.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:var(--s-2);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} +.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:var(--s-3)} +.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:var(--t-xs);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:var(--t-xl);color:var(--ink-2);line-height:1.4;position:relative} +.upd-cause{font-size:var(--t-sm);color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word} +.upd-notes{list-style:none;margin:var(--s-2) auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:var(--t-md);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:var(--t-sm);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:var(--t-base);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:var(--t-sm);color:var(--ash);letter-spacing:.04em;margin-top:var(--s-2);min-height:18px;line-height:1.5;max-width:44ch} +.upd-meta:empty{display:none} +.upd-actions{display:flex;gap:var(--s-3);align-items:center;justify-content:center;flex-wrap:wrap;margin-top:var(--s-4);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:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} + /* sheet (the key) */ .sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;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} .sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index cd372c3b5..7443b07ad 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -112,7 +112,110 @@ var Notices = (function () { } return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot }; })(); -if (typeof module === 'object' && module && module.exports) module.exports = Notices; + +/* ---------- the update card (pure; update-card.test.mjs loads this block) ---------- + A centred card over the window for one update: the mark with a progress ring, "Igneum Ember 0.3.7", 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. The same card, the same rules and the + same words sit in the wallet (app/igneum-wallet/ui/update-card.js). + 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 a job runs, in the engine's first 60 s, while the key sheet is up, while the app quits + later Later, Escape or the backdrop hides this version at this stage; the strip (Notices) 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 strip carries the rest */ +var UpdateCard = (function () { + 'use strict'; + var NAME = 'Igneum Ember', LINE_MAX = 70, LINES = 3, FIRST_S = 60; + 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, uptime_s, 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.' : 'Mining continues until then.', 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 at a quiet moment.' : ''; + 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. Mining continues.', 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 = { jobActive, uptime_s, quitting, sheetOpen } + function blocked(ctx) { + ctx = ctx || {}; + if (ctx.quitting) return 'the app is quitting'; + if (ctx.sheetOpen) return 'the key sheet is open'; + if (ctx.jobActive) return 'a job is running'; + if (ctx.uptime_s >= 0 && ctx.uptime_s < FIRST_S) return 'the engine started under a minute ago'; + 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, FIRST_S: FIRST_S, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide }; +})(); +if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; } if (typeof document !== 'undefined') (function () { 'use strict'; @@ -843,6 +946,63 @@ if (typeof document !== 'undefined') (function () { else if (act === 'sync') api('api/clock/sync', {}); }); + // ---------- the update card (UpdateCard, top of this file): one update, centred, over everything ---------- + // 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 + var updMem = { open: false, later: '', seen: '' }, updSig = '', updAsked = '', updMore = false; + if (forcedUpdate && params.get('card') === '1') updMem.open = true; // screenshots: installing and failed only show on an open card + function cardCtx(s) { return { jobActive: !!(s.jobs && s.jobs.active), uptime_s: s.uptime_s, quitting: !!s.quitting, sheetOpen: !$('sheet-key').hidden }; } + function renderUpdateCard(s) { + var 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 = ''; } + var asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed'; + var 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(function (l) { return '