diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 9b225bd0f..061fcb805 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -376,7 +376,7 @@ impl Engine { }; // labels never carry the hostname: two cloned PCs with the same COMPUTERNAME would sign with the same keys let label_base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.id8()); - let ota = crate::ota::Updater::new(&shared, wrapper); + let ota = crate::ota::Updater::new(&shared); Engine { shared, bins, diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index f27c4d8eb..890a72ca0 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -124,7 +124,7 @@ pub fn parse(text: &str) -> Result { return Ok(None); } let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind") }; - if !e.url.starts_with("https://") { + if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") { return Err(format!("{name}: the url is not https")); } if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) { @@ -322,6 +322,7 @@ mod tests { assert!(m.mac.is_none() && m.windows.is_none()); assert_eq!(m.activation_height, None); assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("https")); + assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://127.0.0.1:29790/x.dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"dmg"}}}"#).is_ok()); assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("sha256")); assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"tar"}}}"#).unwrap_err().contains("kind")); assert!(parse(r#"{"version":"latest"}"#).is_err()); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index a714b2e90..e87f1b602 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -72,7 +72,6 @@ pub struct Updater { app_dir: PathBuf, dir: PathBuf, auto: bool, - wrapper: bool, manifest: Option, entry: Option, file: Option, @@ -89,7 +88,7 @@ pub struct Updater { } impl Updater { - pub fn new(shared: &Arc, wrapper: bool) -> Updater { + pub fn new(shared: &Arc) -> Updater { let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); let manifest_url = env("IGNEUM_APP_UPDATE_MANIFEST").unwrap_or_else(|| shared.packaged.update_manifest.clone()); let app_dir = shared.runtime.app_dir.clone(); @@ -105,7 +104,6 @@ impl Updater { app_dir, dir, auto, - wrapper, manifest: None, entry: None, file: None, @@ -271,6 +269,7 @@ impl Updater { self.healthy_marked = true; let p = self.pending.take().unwrap(); let _ = std::fs::remove_file(self.pending_path()); + let _ = std::fs::remove_file(self.result_path()); // the helper's "ok" lands after this engine started shared.log(&format!("update to {} complete (from {}); keeping the previous version for a rollback", p.to, p.from)); self.tidy(&p); } @@ -473,7 +472,7 @@ impl Updater { st.update.wait = if self.auto { "installs at the next safe moment".into() } else { "waiting for Install now".into() }; st.update.progress = 1.0; } - shared.event("ok", &format!("Igneum Miner {v} is ready; it installs at the next safe moment{}", if self.auto { "" } else { " (automatic updates are off: use Install now)" })); + shared.event("ok", &format!("Igneum Miner {v} is ready; {}", if self.auto { "it installs at the next safe moment" } else { "automatic updates are off, so it waits for Install now" })); self.publish(shared); } }, @@ -551,6 +550,18 @@ impl Updater { self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default() } + /// The engine's own IGNEUM_APP_* environment (a test on a private devnet) for the helper's relaunch; "" when + /// there is none, and the helper opens the bundle through LaunchServices. + #[cfg(target_os = "macos")] + fn write_env_file(&self) -> String { + let vars: Vec = std::env::vars().filter(|(k, _)| k.starts_with("IGNEUM_APP_")).map(|(k, v)| format!("{k}={v}")).collect(); + if vars.is_empty() { + return String::new(); + } + let p = self.app_dir.join("ota-relaunch.env"); + if std::fs::write(&p, vars.join("\n") + "\n").is_ok() { p.display().to_string() } else { String::new() } + } + /// Writes update-pending.json and the helper, starts the helper detached. The engine exits right after. /// `host_pid` is the window host when the engine runs under one. pub fn launch_apply(&mut self, shared: &Arc, host_pid: u32) -> Result<(), String> { @@ -566,7 +577,8 @@ impl Updater { let app = crate::platform::bundle_path().ok_or("not running from Igneum Miner.app")?; let script = self.app_dir.join("ota-apply.sh"); std::fs::write(&script, MAC_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; - let args = ["apply".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), staged.display().to_string(), to.clone(), result.display().to_string()]; + let env_file = self.write_env_file(); + let args = ["apply".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), staged.display().to_string(), to.clone(), result.display().to_string(), env_file]; shared.log(&format!("update: starting the helper: bash {} {}", script.display(), args.join(" "))); spawn_detached(Command::new("nohup").arg("bash").arg(&script).args(&args))?; Ok(()) @@ -602,7 +614,8 @@ impl Updater { let app = crate::platform::bundle_path().ok_or("not running from Igneum Miner.app")?; let script = self.app_dir.join("ota-apply.sh"); std::fs::write(&script, MAC_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; - let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), String::new(), p.to.clone(), result.display().to_string()]; + let env_file = self.write_env_file(); + let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), String::new(), p.to.clone(), result.display().to_string(), env_file]; spawn_detached(Command::new("nohup").arg("bash").arg(&script).args(&args))?; Ok(()) } @@ -628,10 +641,6 @@ impl Updater { Err("rollback is not supported on this platform".into()) } } - - pub fn is_wrapper(&self) -> bool { - self.wrapper - } } // ---- the threads --------------------------------------------------------------------------------------------------- @@ -808,11 +817,11 @@ fn spawn_detached(c: &mut Command) -> Result<(), String> { #[cfg(target_os = "macos")] const MAC_HELPER: &str = r#"#!/bin/bash # Igneum Miner update helper, written by the engine (src/ota.rs). Not for running by hand. -# bash ota-apply.sh apply|rollback +# bash ota-apply.sh apply|rollback [env file] # apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running # bundle to ".previous" and the staged one in, opens the new app; if the new app does not start twice, puts the # previous one back. rollback: the previous bundle back, the failed one aside. Writes for the engine. -MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7" +MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7"; ENVF="${8:-}" LOG="$(dirname "$RESULT")/ota-apply.log" exec >>"$LOG" 2>&1 echo "$(date -u +%FT%TZ) $MODE: engine $EPID host $HPID app '$APP' new '$NEW' version $VER" @@ -823,6 +832,8 @@ PREV="$APP.previous" FAILED="$APP.failed" ENGINE="$APP/Contents/MacOS/igneum-app" started_ok() { local n=60; while [ "$n" -gt 0 ]; do pgrep -f "$ENGINE" >/dev/null 2>&1 && return 0; sleep 0.5; n=$((n-1)); done; return 1; } +# a test run carries its private-devnet environment to the relaunch (open -n cannot); a normal run goes through LaunchServices +launch() { if [ -n "$ENVF" ] && [ -f "$ENVF" ]; then (set -a; . "$ENVF"; set +a; nohup "$APP/Contents/MacOS/Igneum Miner" >/dev/null 2>&1 &); else open -n "$APP"; fi; } wait_gone "$EPID" 240 || { echo "engine $EPID still running after 120 s; ending it"; kill -9 "$EPID" 2>/dev/null; sleep 1; } if [ -n "$HPID" ] && [ "$HPID" != 0 ] && ! gone "$HPID"; then osascript -e 'tell application id "network.igneum.miner" to quit' >/dev/null 2>&1 || kill -TERM "$HPID" 2>/dev/null @@ -832,27 +843,27 @@ fi pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 0.5 case "$MODE" in apply) - [ -d "$NEW" ] || { result false "the staged app is missing" false; open -n "$APP"; exit 1; } + [ -d "$NEW" ] || { result false "the staged app is missing" false; launch; exit 1; } rm -rf "$PREV" - mv "$APP" "$PREV" || { result false "could not move the old app aside" false; open -n "$APP"; exit 1; } - mv "$NEW" "$APP" || { mv "$PREV" "$APP"; result false "could not move the new app in" false; open -n "$APP"; exit 1; } + mv "$APP" "$PREV" || { result false "could not move the old app aside" false; launch; exit 1; } + mv "$NEW" "$APP" || { mv "$PREV" "$APP"; result false "could not move the new app in" false; launch; exit 1; } xattr -dr com.apple.quarantine "$APP" 2>/dev/null echo "swapped; opening $APP" - open -n "$APP" || echo "open failed" + launch || echo "open failed" if started_ok; then result true "" false; echo "$VER is running"; exit 0; fi echo "the new app did not start within 30 s; opening it once more" - open -n "$APP" || true + launch || true if started_ok; then result true "" false; echo "$VER is running (second try)"; exit 0; fi echo "the new app did not start twice; restoring the previous version" pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 1 rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP" - open -n "$APP" + launch result false "Igneum Miner $VER did not start twice; the previous version was restored" true ;; rollback) - [ -d "$PREV" ] || { result false "no previous version kept to restore" false; open -n "$APP"; exit 1; } + [ -d "$PREV" ] || { result false "no previous version kept to restore" false; launch; exit 1; } rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP" - open -n "$APP" + launch result false "Igneum Miner $VER did not stay up twice; the previous version was restored" true ;; *) echo "unknown mode $MODE"; exit 2 ;; diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 6f371335c..bedb3cb2c 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -70,6 +70,10 @@ body.mac .top{padding-left:92px} .banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap} .banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} .banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center} +.banner.update{flex-wrap:wrap;row-gap:8px} +.banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600} +.banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px} +.banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} .clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px} .clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)} .clock-msg{font-size:14px;color:var(--bone);line-height:1.45} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 7894ee2cd..557f6c735 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -9,6 +9,7 @@ var state = null, phase = 'welcome', forced = null, cardsSeen = false, keyShownThisRun = false; var params = new URLSearchParams(location.search); if (params.get('screen')) forced = params.get('screen'); + var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated if (params.get('host') === 'mac') document.body.classList.add('mac'); // ---------- helpers ---------- @@ -107,7 +108,9 @@ $('s-reveal').hidden = state.address.source !== 'generated'; $('s-live').hidden = !state.live_page; var u = state.update; - $('s-update-note').textContent = u.available ? ('Version ' + u.version + ' is available.') : (u.error ? u.error : (u.checked_at ? 'This is the latest version.' : '')); + $('s-auto-update').checked = !!state.settings.auto_update; + $('s-install').hidden = !((u.ready || u.downloaded || u.status === 'error') && !u.applying); + $('s-update-note').textContent = settingsUpdateNote(u); } $('s-address-save').addEventListener('click', function () { var a = $('s-address-input').value.trim(); @@ -127,9 +130,12 @@ $('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); }); $('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); }); $('s-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); }); + $('s-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); }); + $('s-auto-update').addEventListener('change', function () { api('api/update/auto', { on: this.checked }); }); $('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); }); - $('update-get').addEventListener('click', function () { if (state && state.update.url) api('api/open', { url: state.update.url }); }); - $('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = '1'; layoutBanners(); }); + $('update-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); }); + $('update-open').addEventListener('click', function () { api('api/update/open', {}); }); + $('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = (state && state.update) ? (state.update.status + ':' + state.update.version) : '1'; layoutBanners(); }); // ---------- bottom bar ---------- $('btn-pause').addEventListener('click', function () { @@ -385,11 +391,66 @@ window.addEventListener('resize', layoutBanners); $('clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); $('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); + // ---------- over-the-air updates (src/ota.rs): one banner, the settings note ---------- + function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } + function updateLine(u) { + var v = 'Igneum Miner ' + u.version; + if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; + switch (u.status) { + case 'available': return { text: v + ' is available. Downloading it.' }; + case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true }; + case 'staging': return { text: v + ' downloaded and verified. Preparing it.' }; + case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs at the next safe moment.'), install: true }; + case 'applying': return { text: 'Installing ' + v + ': the miners stop, then the node, then the app opens again.' }; + case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true }; + case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) }; + default: return null; + } + } + function settingsUpdateNote(u) { + var l = updateLine(u), parts = []; + if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); + if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); + if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text); + else if (u.status === 'checking') parts.push('Checking.'); + else if (u.status === 'current') parts.push('This is the latest version' + (u.checked_at ? ' (checked ' + rel(state.now - u.checked_at) + ')' : '') + '.'); + else if (u.error) parts.push(u.error); + if (u.activation_height && !u.urgent) parts.push('Consensus upgrade at height ' + withCommas(u.activation_height) + '.'); + return parts.join(' '); + } + function renderUpdate(s) { + var u = s.update, b = $('update-banner'), l = updateLine(u); + var key = u.status + ':' + u.version; + var show = !!l && (u.urgent || u.applying || b.dataset.dismissed !== key); + if (show) { + $('update-text').textContent = l.text; + b.classList.toggle('urgent', !!l.urgent); + $('update-install').hidden = !l.install || u.applying; + $('update-open').hidden = !l.open; + $('update-later').hidden = !!l.urgent || !!u.applying; + $('update-prog').hidden = !l.prog; + $('update-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%'; + } + if (b.hidden === show) { b.hidden = !show; layoutBanners(); } + } + function sampleUpdate(kind) { + var u = { available: true, version: '0.3.1', notes: 'difficulty v2, OTA updates', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' }; + 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 = 0.43; } + if (kind === 'waiting') { u.wait = 'hourly program boundary in 97 s; installing after it'; } + if (kind === 'applying') { u.status = 'applying'; u.applying = true; } + if (kind === 'urgent') { u.urgent = true; u.activation_height = 120000; u.urgent_text = 'Consensus upgrade at height 120000 (difficulty v2): the node is 1,240 blocks away. Installing 0.3.1 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; } + if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.0'; u.version = '0.3.1'; } + return u; + } function render(s) { state = s; stateAt = performance.now(); + if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') s.version = '0.3.1'; } renderPill(s); renderClock(s); - if (s.update.available && !$('update-banner').dataset.dismissed) { $('update-version').textContent = 'Igneum Miner ' + s.update.version; $('update-banner').hidden = false; layoutBanners(); } + renderUpdate(s); if (phase === 'cards') renderCards(s); if (phase === 'dashboard') renderDashboard(s); if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; } diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index ebbc7ef44..50755ae9f 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -28,10 +28,12 @@ -
version
-
+
+

diff --git a/docs/design/app-screens/update-applying-live.png b/docs/design/app-screens/update-applying-live.png new file mode 100644 index 000000000..87dbf1cf3 Binary files /dev/null and b/docs/design/app-screens/update-applying-live.png differ diff --git a/docs/design/app-screens/update-applying.png b/docs/design/app-screens/update-applying.png new file mode 100644 index 000000000..c9cc15463 Binary files /dev/null and b/docs/design/app-screens/update-applying.png differ diff --git a/docs/design/app-screens/update-available.png b/docs/design/app-screens/update-available.png new file mode 100644 index 000000000..8c232c2f7 Binary files /dev/null and b/docs/design/app-screens/update-available.png differ diff --git a/docs/design/app-screens/update-downloading-live.png b/docs/design/app-screens/update-downloading-live.png new file mode 100644 index 000000000..e8aaf9401 Binary files /dev/null and b/docs/design/app-screens/update-downloading-live.png differ diff --git a/docs/design/app-screens/update-downloading.png b/docs/design/app-screens/update-downloading.png new file mode 100644 index 000000000..e424c8012 Binary files /dev/null and b/docs/design/app-screens/update-downloading.png differ diff --git a/docs/design/app-screens/update-error.png b/docs/design/app-screens/update-error.png new file mode 100644 index 000000000..61d035d3b Binary files /dev/null and b/docs/design/app-screens/update-error.png differ diff --git a/docs/design/app-screens/update-manual.png b/docs/design/app-screens/update-manual.png new file mode 100644 index 000000000..0846ad553 Binary files /dev/null and b/docs/design/app-screens/update-manual.png differ diff --git a/docs/design/app-screens/update-ready-live.png b/docs/design/app-screens/update-ready-live.png new file mode 100644 index 000000000..9f7565665 Binary files /dev/null and b/docs/design/app-screens/update-ready-live.png differ diff --git a/docs/design/app-screens/update-ready.png b/docs/design/app-screens/update-ready.png new file mode 100644 index 000000000..d64d44dab Binary files /dev/null and b/docs/design/app-screens/update-ready.png differ diff --git a/docs/design/app-screens/update-rolledback-settings.png b/docs/design/app-screens/update-rolledback-settings.png new file mode 100644 index 000000000..00cbdbe6f Binary files /dev/null and b/docs/design/app-screens/update-rolledback-settings.png differ diff --git a/docs/design/app-screens/update-settings.png b/docs/design/app-screens/update-settings.png new file mode 100644 index 000000000..57732403b Binary files /dev/null and b/docs/design/app-screens/update-settings.png differ diff --git a/docs/design/app-screens/update-updated.png b/docs/design/app-screens/update-updated.png new file mode 100644 index 000000000..9e1ec91fc Binary files /dev/null and b/docs/design/app-screens/update-updated.png differ diff --git a/docs/design/app-screens/update-urgent.png b/docs/design/app-screens/update-urgent.png new file mode 100644 index 000000000..f57284231 Binary files /dev/null and b/docs/design/app-screens/update-urgent.png differ diff --git a/docs/design/app-screens/update-waiting.png b/docs/design/app-screens/update-waiting.png new file mode 100644 index 000000000..efe00b067 Binary files /dev/null and b/docs/design/app-screens/update-waiting.png differ diff --git a/packaging/mac/README.md b/packaging/mac/README.md index 06efbfbd6..e3c2b1868 100644 --- a/packaging/mac/README.md +++ b/packaging/mac/README.md @@ -36,10 +36,14 @@ default browser. `igneum-app.json` (never in the UI source): `update_manifest` = `https://dl.igneum.network/dl//igneum-app-latest.json` with the token read from `~/.config/igneum/dl-token` at build time (missing file = update check off), the log intake -URL and key (the key only authorises uploads, as the 0.2.0 launchers shipped it), the live page. The manifest the -engine expects: `{"version":"0.3.1","mac":{"url":"...dmg","notes":"..."},"windows":{"url":"...exe","notes":"..."}}`; -a newer version shows a banner with a Download button (opens the URL in the browser). Checked 20 s after start and -every 6 h, and from Settings. +URL and key (the key only authorises uploads, as the 0.2.0 launchers shipped it), the live page. + +Over-the-air updates (4 October 2026, `packaging/ota/README.md`): the engine checks the signed manifest on start and +hourly, downloads the newer DMG into `~/Library/Application Support/Igneum/app/updates`, verifies size, sha256 and the +Ed25519 signature, stages the new bundle next to the running one, and at a safe moment (node synced, no hourly +boundary within 3 minutes, no worker starting) stops the miners and the node, swaps the bundle (the old one stays as +`Igneum Miner.app.previous`) and opens the new one. Publish with `packaging/ota/publish-manifest.sh --version +--mac dist/Igneum-Miner-.dmg --notes "..."`. The 0.1.0 and 0.2.0 Terminal launchers are not auto-updated. Gatekeeper: the app is unsigned (ad hoc). Right-click > Open the first time. The engine strips `com.apple.quarantine` from the bundle's Contents on start, so the binaries inside are not refused one by one; that diff --git a/packaging/mac/build-dmg.sh b/packaging/mac/build-dmg.sh index bf10816a8..5217525ac 100755 --- a/packaging/mac/build-dmg.sh +++ b/packaging/mac/build-dmg.sh @@ -15,14 +15,15 @@ # Copies of the binaries are stripped and re-signed ad hoc (strip invalidates the linker signature and arm64 macOS # refuses an unsigned binary); the originals are not touched. # -# packaging/mac/build-dmg.sh build +# packaging/mac/build-dmg.sh build (the version is app/igneum-app/Cargo.toml's; VERSION= overrides it for a test build) +# packaging/ota/publish-manifest.sh --version --mac dist/Igneum-Miner-.dmg --notes "..." then publishes it to the apps # NODE= MINER= WORKER= ENGINE= use other binaries; REBUILD_WORKER=0 ships proto-metal/igneum-bench-hotswap # Needs: brand/icons/igneum.icns, igneum-volume.icns and dmg-background.tiff (python3 brand/icons/make-icons.py), swiftc, cargo (rustup), and # dmgbuild (pip3 install dmgbuild) for the icon layout. Without dmgbuild a plain hdiutil image is built and said so. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" -VERSION="0.3.0" +VERSION="${VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-app/Cargo.toml" | head -1)}" NODE="${NODE:-$ROOT/vendor/igneum-node/target-integration/release/igneumd}" MINER="${MINER:-$ROOT/vendor/igneum-node/target-integration/release/igneum-miner}" WORKER="${WORKER:-}" @@ -86,7 +87,7 @@ echo "building the window (app/mac/IgneumMiner.swift)" [ -x "$BUILD/window/Igneum Miner" ] || { echo "the window did not build"; exit 1; } # the bundle -sed "s/VERSION_STAMP/$STAMP/" "$HERE/app/Info.plist" > "$APP/Contents/Info.plist" +sed -e "s/VERSION_STAMP/$STAMP/" -e "s|0\.3\.0|$VERSION|" "$HERE/app/Info.plist" > "$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" >/dev/null printf 'APPL????' > "$APP/Contents/PkgInfo" cp "$BUILD/window/Igneum Miner" "$APP/Contents/MacOS/Igneum Miner" @@ -113,7 +114,7 @@ v="$("$APP/Contents/Resources/bin/igneum-miner" 2>&1 || true)"; case "$v" in usa case "$v" in *"--evm-address"*) ;; *) echo "igneum-miner copy is not the devnet-v4 miner (no --evm-address in its usage)"; exit 1 ;; esac v="$(echo quit | "$APP/Contents/Resources/bin/igneum-bench" --serve 2>&1)"; case "$v" in "ready metal"*) echo "worker: $v" ;; *) echo "igneum-bench copy does not serve: $v"; exit 1 ;; esac case "$v" in *"prepare 1"*) ;; *) echo "note: this worker has no prepare support (no hot swap at the hour boundary); the miner falls back to exit 42" ;; esac -v="$("$APP/Contents/MacOS/igneum-app" --version 2>&1 || true)"; case "$v" in igneum-app*) echo "engine: $v" ;; *) echo "the engine copy does not run: $v"; exit 1 ;; esac +v="$("$APP/Contents/MacOS/igneum-app" --version 2>&1 || true)"; case "$v" in "igneum-app $VERSION") echo "engine: $v" ;; igneum-app*) echo "the engine says '$v' but this DMG is $VERSION (the update manifest would not match; set VERSION= or rebuild the engine)"; exit 1 ;; *) echo "the engine copy does not run: $v"; exit 1 ;; esac # the rest of the image cp "$HERE/dmg/README.txt" "$STAGE/README.txt" diff --git a/packaging/ota/README.md b/packaging/ota/README.md new file mode 100644 index 000000000..2ec1851e1 --- /dev/null +++ b/packaging/ota/README.md @@ -0,0 +1,107 @@ +# Over-the-air updates (packaging/ota) + +Josh's rule (4 October 2026): every app updates itself and downloads the update without being asked. The Igneum +Miner app on Windows and macOS does, and the node, the miner and the GPU workers ship inside it, so a consensus +upgrade (a height-activated rule such as difficulty v2) reaches every node before its activation height. + +The launcher packages (`proto-cuda/windows-app`, the `igneum-windows-v4.zip` console launchers, the Terminal DMGs +0.1.0 and 0.2.0) are NOT auto-updated, by design: they are the engineering path and are replaced by hand. + +## The pieces + +| Piece | Where | What it does | +|---|---|---| +| manifest | `dl.igneum.network/dl//igneum-app-latest.json` + `.sig` | version, per-platform file (url, sha256, size, kind), min_supported_version, notes, consensus activation height | +| signer | `app/igneum-app/src/bin/ota-sign.rs` (`igneum-ota-sign`, built with the app, never shipped) | keygen, sign, verify, sha256; includes `src/manifest.rs` so it signs what the app verifies | +| publisher | `packaging/ota/publish-manifest.sh` | copies the DMG or installer into the downloads folder, writes the canonical manifest, signs it, prints or runs the deploy | +| verifier | `app/igneum-app/src/manifest.rs` | Ed25519 check of the manifest bytes with the compiled-in public key, parse, version compare, safe-moment rule, unit tests | +| updater | `app/igneum-app/src/ota.rs` | check, download with resume, verify, stage, apply at a safe moment, rollback; `update` in `/api/state` | +| dashboard | `app/igneum-app/ui` | one banner (available, downloading, ready, applying, red bar for a close fork), Settings: Check now, Install now, automatic switch | +| Windows installer | `packaging/windows/Igneum-Miner.iss` | `CloseApplications=yes`, `RestartApplications=no`, a `[Run]` relaunch on `/IGNOTA=1` | +| CI loop | `packaging/windows/fetch-ci-artifacts.sh` | after copying the installer it calls `publish-manifest.sh --win` (the Mac entry is carried over); `--deploy` ships both | + +## Keys + +Generated once on the Mac (4 October 2026), never in the repo or in CI: + + app/igneum-app/target/release/igneum-ota-sign keygen ~/.config/igneum/ota-signing-key ~/.config/igneum/ota-signing-key.pub + +`ota-signing-key` is the 32-byte seed as hex, mode 0600. The public key is the constant `OTA_PUBLIC_KEY_HEX` in +`app/igneum-app/src/manifest.rs`; `igneum-ota-sign embedded` prints it with its fingerprint (SHA-256 of the 32 key +bytes). `publish-manifest.sh` refuses to sign when the embedded key is not the one in `~/.config/igneum`. + +Key rotation: a new key means a new app build (the constant), published and signed with the OLD key, then the next +manifest signed with the new one. Apps that skipped the bridge build stop updating and show "manifest signature does +not verify"; they are updated by hand from the download page. + +## Publishing a version + +1. Bump `version` in `app/igneum-app/Cargo.toml` (and `app/windows/version.h`, `resources/igneum-app.rc`, as the + CI smoke run demands). Commit, push: the Windows installer builds on GitHub. +2. Mac: `packaging/mac/build-dmg.sh`, then + + packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \ + --notes "difficulty v2 and over-the-air updates" [--activation-height 120000 --deadline-note "difficulty v2"] + + writes `dl//igneum-app-latest.json` with the Mac entry only and prints the deploy command. Deploying now is + fine: a Windows app finds no `windows` entry and does nothing. +3. Windows: `packaging/windows/fetch-ci-artifacts.sh --deploy` copies the installer, adds the Windows entry to the + same manifest (same version, Mac entry carried over), deploys the downloads folder. +4. Every app checks within the hour (`Settings > Check now` at once): it downloads, verifies and installs at the next + safe moment. The event feed shows each step; `app-.log` has the detail. + +`--min-supported 0.3.0` marks older versions unsupported: they install at once, without waiting for a safe moment, +and show the red bar. `--activation-height N` does the same once a node's DAA score is within 1,800 blocks of N. + +## What the app does + +Check on start (20 to 50 s in) and every 60 minutes plus up to 10 minutes of per-machine jitter; after an error, +again in 10 minutes. Both files come through curl (the engine carries no TLS stack); the signature is checked over +the manifest bytes before parsing; a version that is not newer, or a manifest without this platform, ends the round. + +Download into `/app/updates/` (`~/Library/Application Support/Igneum/app/updates`, +`%LOCALAPPDATA%\igneum\app\updates`) with `curl -C -` (resume) and `--retry 3`; then the size and the sha256 from +the manifest; a file already there with the right hash is not fetched again. The banner shows the percentage. + +Stage. macOS: mount the DMG (or unpack the zip), copy `Igneum Miner.app` to `.Igneum Miner.app.new` next to the +running bundle (same volume: the swap is two renames), run its engine with `--version` and demand the manifest's +version. When the folder is not writable the state is `manual`: the banner says so and offers "Open the download". +Windows: the installer is the staged file. + +Safe moment (`manifest::safe_to_apply`): node synced, no hourly program boundary within 180 s (`program.eta_s`), +no worker starting. Urgent (fork within 1,800 blocks, or unsupported version, or Install now) skips the wait. A +ready update that found no safe moment for 6 hours applies anyway (an unsynced node mines nothing). + +Apply. The engine writes `update-pending.json` (from, to, starts), starts the helper detached and leaves through its +normal quit path: miners first (8 s grace), then the node (30 s), the last log upload, `EXIT` for the window. +- macOS helper `ota-apply.sh`: waits for the engine, asks the window (`network.igneum.miner`) to quit, moves the + bundle to `Igneum Miner.app.previous`, the staged one in, strips quarantine, `open -n`. If the new engine is not + running after 30 s it opens once more; if that fails too it puts `.previous` back and reports. +- Windows helper `ota-apply.ps1`: waits for the engine, runs `Igneum-Miner-Setup-.exe /VERYSILENT + /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /IGNOTA=1 /LOG=...` as administrator (ONE UAC prompt: the + installer is `PrivilegesRequired=admin` because of Program Files and the firewall rule). The installer's + `PrepareToInstall` runs `stop-igneum.ps1` (ends the window and anything left), replaces the files, and the + `[Run]` entry on `/IGNOTA=1` relaunches `igneum-app.exe --launch` as the signed-in user. A declined prompt or a + non-zero exit relaunches the old app and reports the error in the banner (Install now retries). + +Rollback. The helper writes `update-result.json`; the new engine reads it on start and reports "updated to X from +Y" or the error. The new engine counts its starts in `update-pending.json` and deletes the file after 90 healthy +seconds; a third start without reaching that point restores the previous version (macOS: the `.previous` bundle; +Windows: the previous version's installer kept in `updates/`, so the FIRST update from 0.3.0 has no rollback target +on Windows, said so in the state) and shows "rolled back" in Settings. + +Settings: `auto_update` (default on). Off: downloads still happen, the banner waits for Install now. The forced +screenshots: `?update=available|downloading|ready|waiting|applying|urgent|manual|error|updated` on the dashboard URL. + +## Testing + +Unit tests: `cargo test` in `app/igneum-app` (manifest parse, a bad signature and a tampered manifest refused, +sha256 of a file, version ordering incl. pre-releases, safe-moment rules, fork closeness, unsupported versions). + +Dry run on the Mac, 4 October 2026 (`packaging/mac/README.md` has the private-devnet recipe; ports 29700+): +the 0.3.0 bundle from the tree ran under its window host against a private devnet with +`IGNEUM_APP_UPDATE_MANIFEST=http://127.0.0.1:29790/dl//igneum-app-latest.json` (a `python3 -m http.server` +over a folder written by `publish-manifest.sh --base-url ... --dest ...`; loopback http is the one non-https URL the +parser accepts), found the 0.3.1 manifest, downloaded and verified the DMG, staged the bundle, waited for the worker +to start, applied, and came back as 0.3.1 with "updated to Igneum Miner 0.3.1 from 0.3.0" in the event feed. The +screenshots are `docs/design/app-screens/update-*.png`. Windows: reviewed only, see `TEST.md`. diff --git a/packaging/ota/TEST.md b/packaging/ota/TEST.md new file mode 100644 index 000000000..26da26826 --- /dev/null +++ b/packaging/ota/TEST.md @@ -0,0 +1,67 @@ +# Testing the over-the-air update on Windows (PC 2, machine id 1ccfe586) + +The Windows apply path could not be run from the Mac. It was reviewed against `packaging/windows/Igneum-Miner.iss`, +`stop-igneum.ps1` and `app/windows/host.cpp`; these steps run it for real. Allow 20 minutes. + +## What is untested on Windows + +- `ota-apply.ps1` end to end: the wait for the engine, `Start-Process -Verb RunAs` of the installer, the UAC prompt, + the exit code, the relaunch through the `[Run]` entry on `/IGNOTA=1`. +- `CloseApplications=yes` with the window host: the host hides on `WM_CLOSE` instead of quitting, so the Restart + Manager cannot close it; `PrepareToInstall` (`stop-igneum.ps1`, `Stop-Process -Force` on "Igneum Miner") is what + ends it. Watch for an installer dialog about files in use. +- The rollback: the previous installer is kept in `updates/` only from the second OTA on; a first update has none. +- `powershell` 5.1 parsing of the helper (`tools/ci/windows/check-ps51.ps1` cannot see it: it is a string in + `src/ota.rs`). The helper avoids `"$x: y"` and uses nothing newer than 5.1. + +## Before + +PC 2 runs Igneum Miner 0.3.0, which has no updater at all. Step 0 is therefore a hand install of the first +OTA-capable build; from then on every update is automatic. + +0. On the Mac: push master, wait for the green `windows-ci` run, then + `packaging/windows/fetch-ci-artifacts.sh --deploy` (writes the Windows entry into the manifest and deploys). + Note the version in the installer name (0.3.1 or later). On PC 2: download that installer from + `https://dl.igneum.network/dl//Igneum-Miner-Setup-.exe`, run it over the running 0.3.0 (it stops the + old app itself), let it start the app. + +## The test + +1. Settings (gear) shows "Igneum Miner ", "Check now", "Install now" (hidden until a download exists), the + "Install updates by itself at a safe moment" switch ON, and a note. Click Check now: within 10 s the note says + "This is the latest version (checked ...)" and the log drawer (Logs) has `update check: is current`. + If it says `no manifest at the update URL yet` the deploy did not land; if `manifest signature does not verify` + the installer was built from a tree with a different `OTA_PUBLIC_KEY_HEX` than the key that signed. +2. On the Mac, publish a test version: bump `version` in `app/igneum-app/Cargo.toml`, `app/windows/version.h` and + `app/igneum-app/resources/igneum-app.rc` (patch level only), push, wait for CI, `fetch-ci-artifacts.sh --deploy` + with `OTA_NOTES="OTA test"`. (The Mac entry is carried over only when it has the same version; without a Mac + build the manifest carries the Windows entry alone, which is fine.) +3. On PC 2: Settings > Check now. Expected within a minute: the banner "Igneum Miner is available. + Downloading it." then "Downloading ... 43%" then "Igneum Miner is ready. Installs at the next safe moment." + with Install now and Later. Events: `is available: downloading (44 MB)`, `is ready; it installs at the next safe + moment`. `%LOCALAPPDATA%\igneum\app\updates\` holds `Igneum-Miner-Setup-.exe` (and `manifest.json`). +4. Wait. The node is synced and a worker is mining, so the only wait is an hourly boundary within 3 minutes (the + "next program" tile). Expected: the banner changes to "Installing Igneum Miner : the miners stop, then the + node, then the app opens again", the footer says stopping, then ONE UAC prompt "Igneum-Miner-Setup-.exe". + Click Yes. The window closes (stop-igneum.ps1 ends it), about 20 s later the app opens again on its own. + Check: Settings shows the new version and "Updated from ."; the event feed starts with + `updated to Igneum Miner from `; the node and the miner are back (same chain data, same address). + Files: `%LOCALAPPDATA%\igneum\app\ota-apply.log` (the helper), `ota-setup.log` (Inno), no `update-pending.json` + after 90 s. +5. The declined prompt: repeat 2 and 3 with another patch bump, and click No on the UAC prompt. Expected: the app + comes back by itself on the OLD version within 15 s with the banner "Update: Windows did not let the installer + run: ..." and Install now; clicking it brings the prompt again, Yes installs. +6. Install now: with automatic off (the switch), the banner waits "waiting for Install now"; Install now installs + at once, ignoring the boundary wait. +7. The red bar (consensus): publish with `--activation-height ` (the node tile shows the DAA + score): the banner turns solid ember "Consensus upgrade at height ...: the node is N blocks away. Installing + ... now." and the apply skips the safe-moment wait. + +## If something goes wrong + +- The app does not come back: Start Menu > Igneum Miner. `ota-apply.log` says which step failed. The old files are + still in place when the installer did not run; when it ran and the new app fails, reinstall from the download page. +- A UAC prompt every hour: the installer failed or was declined and the retry loop runs at the next check; Settings + shows the error. Switch automatic off to stop it, or install by hand. +- "update-pending.json" stays and the app keeps restarting: the new version dies early; on the third start the + helper reinstalls the previous installer when one is in `updates/`, else reinstall by hand. diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh new file mode 100755 index 000000000..00e037e83 --- /dev/null +++ b/packaging/ota/publish-manifest.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Publishes the over-the-air update manifest for Igneum Miner: igneum-app-latest.json (canonical JSON, sorted keys, +# no whitespace) and its detached Ed25519 signature igneum-app-latest.json.sig in the downloads folder +# (dl//, next to the DMG and the installer), signed on this Mac with ~/.config/igneum/ota-signing-key. The +# apps verify the bytes with the public key compiled into app/igneum-app/src/manifest.rs before they parse anything. +# +# packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \ +# [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \ +# [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy] +# +# A platform you do not pass is carried over from the manifest already in the folder when that one has the same +# version (the Windows build lands later than the Mac one: publish the Mac entry first, add the Windows entry when +# fetch-ci-artifacts.sh brings the installer), else left out; an app whose platform is missing does nothing. +# The installer or DMG is copied into the downloads folder when it is not there already. +# Without --deploy the script prints the deploy command for the main session; with --deploy it runs the Vercel CLI +# from the downloads folder and verifies the live manifest. Testing: --base-url http://127.0.0.1:/dl/ +# and --dest write a manifest for a local server (the app accepts loopback http for this). +# +# Reads: ~/.config/igneum/ota-signing-key (private, 0600; make it once with +# app/igneum-app/target/release/igneum-ota-sign keygen ~/.config/igneum/ota-signing-key ~/.config/igneum/ota-signing-key.pub), +# ~/.config/igneum/dl-token, ~/.config/igneum/dlsite-dir (IGNEUM_DLSITE overrides), ~/.config/igneum/vercel for --deploy. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +export PATH="$HOME/.cargo/bin:$PATH" +KEY="$HOME/.config/igneum/ota-signing-key" +PUB="$HOME/.config/igneum/ota-signing-key.pub" +TOKEN_FILE="$HOME/.config/igneum/dl-token" +SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign" + +VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --mac) MAC="$2"; shift 2 ;; + --win) WIN="$2"; shift 2 ;; + --notes) NOTES="$2"; shift 2 ;; + --activation-height) ACTIVATION="$2"; shift 2 ;; + --deadline-note) DEADLINE="$2"; shift 2 ;; + --min-supported) MIN_SUPPORTED="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --base-url) BASE="$2"; shift 2 ;; + --dest) DEST="$2"; shift 2 ;; + --deploy) DEPLOY=1; shift ;; + --no-deploy) DEPLOY=0; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$VERSION" ] || { echo "--version is required" >&2; exit 2; } +case "$VERSION" in [0-9]*.[0-9]*.[0-9]*) ;; *) echo "--version must be major.minor.patch" >&2; exit 2 ;; esac +[ -f "$KEY" ] || { echo "no $KEY: run $SIGNER keygen $KEY $PUB once (the public key then goes into src/manifest.rs)" >&2; exit 1; } +[ -f "$PUB" ] || { echo "no $PUB" >&2; exit 1; } +[ -f "$TOKEN_FILE" ] || { echo "no $TOKEN_FILE" >&2; exit 1; } +TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")" +if [ -z "$DEST" ]; then + DLSITE="${IGNEUM_DLSITE:-}" + [ -n "$DLSITE" ] || { [ -f "$HOME/.config/igneum/dlsite-dir" ] && DLSITE="$(tr -d '[:space:]' < "$HOME/.config/igneum/dlsite-dir")"; } || true + [ -n "$DLSITE" ] && [ -d "$DLSITE/dl/$TOKEN" ] || { echo "no downloads folder: set IGNEUM_DLSITE or ~/.config/igneum/dlsite-dir (must hold dl//)" >&2; exit 1; } + DEST="$DLSITE/dl/$TOKEN" +else + DLSITE="" + mkdir -p "$DEST" +fi +[ -n "$BASE" ] || BASE="https://dl.igneum.network/dl/$TOKEN" +BASE="${BASE%/}" +command -v python3 >/dev/null || { echo "python3 is needed for the canonical JSON" >&2; exit 1; } + +# the signer, built from the app crate (it includes src/manifest.rs, so it signs what the app verifies) +if [ ! -x "$SIGNER" ]; then + echo "building igneum-ota-sign" + (cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet) +fi +EMBEDDED="$("$SIGNER" embedded | head -1)" +OURS="$(tr -d '[:space:]' < "$PUB")" +if [ "$EMBEDDED" != "$OURS" ]; then + echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB ($OURS); the apps would refuse this manifest" >&2 + exit 1 +fi + +# the platform entries: the files given here, else carried over from the current manifest at the same version +entry() { # -> "url sha256 size kind" + local f="$1" name kind sum size + [ -f "$f" ] || { echo "missing: $f" >&2; exit 1; } + name="$(basename "$f")" + case "$name" in + *.dmg) kind="dmg" ;; + *.zip) kind="zip" ;; + *.exe) kind="inno-setup" ;; + *) echo "$f: not a .dmg, .zip or .exe" >&2; exit 1 ;; + esac + if [ "$(cd "$(dirname "$f")" && pwd)/$name" != "$DEST/$name" ]; then + cp "$f" "$DEST/$name" + fi + read -r sum size < <("$SIGNER" sha256 "$DEST/$name") + echo "$BASE/$name $sum $size $kind" +} +MAC_ENTRY=""; WIN_ENTRY="" +[ -n "$MAC" ] && MAC_ENTRY="$(entry "$MAC")" +[ -n "$WIN" ] && WIN_ENTRY="$(entry "$WIN")" +OLD="$DEST/igneum-app-latest.json" +if [ -f "$OLD" ]; then + OLD_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version",""))' "$OLD" 2>/dev/null || true)" + if [ "$OLD_VERSION" = "$VERSION" ]; then + for p in mac windows; do + carried="$(python3 -c 'import json,sys; e=json.load(open(sys.argv[1])).get("platforms",{}).get(sys.argv[2]); print(" ".join([e["url"],e["sha256"],str(e["size"]),e["kind"]]) if e else "")' "$OLD" "$p" 2>/dev/null || true)" + if [ "$p" = mac ] && [ -z "$MAC_ENTRY" ] && [ -n "$carried" ]; then MAC_ENTRY="$carried"; echo "mac: carried over from the current manifest"; fi + if [ "$p" = windows ] && [ -z "$WIN_ENTRY" ] && [ -n "$carried" ]; then WIN_ENTRY="$carried"; echo "windows: carried over from the current manifest"; fi + done + fi +fi +[ -n "$MAC_ENTRY" ] || [ -n "$WIN_ENTRY" ] || { echo "nothing to publish: give --mac and/or --win" >&2; exit 2; } +if [ -z "$MIN_SUPPORTED" ] && [ -f "$OLD" ]; then + MIN_SUPPORTED="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("min_supported_version",""))' "$OLD" 2>/dev/null || true)" +fi + +# canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes +NEW="$DEST/igneum-app-latest.json.new" +python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" <<'PY' +import json, sys, datetime +out, version, channel, notes, min_supported, activation, deadline, mac, win = sys.argv[1:10] +def entry(s): + if not s: return None + url, sha, size, kind = s.split() + return {"url": url, "sha256": sha, "size": int(size), "kind": kind} +m = { + "version": version, + "published_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "channel": channel, + "platforms": {k: v for k, v in (("mac", entry(mac)), ("windows", entry(win))) if v}, + "min_supported_version": min_supported, + "notes": notes, + "consensus": {"activation_height": int(activation) if activation else None, "deadline_note": deadline}, +} +open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False)) +PY +"$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig" +"$SIGNER" verify "$PUB" "$NEW" "$NEW.sig" +mv "$NEW" "$DEST/igneum-app-latest.json" +mv "$NEW.sig" "$DEST/igneum-app-latest.json.sig" +echo "manifest: $DEST/igneum-app-latest.json" +cat "$DEST/igneum-app-latest.json"; echo +echo "signature: $(cat "$DEST/igneum-app-latest.json.sig")" +echo "key fingerprint: $("$SIGNER" fingerprint "$PUB" | tail -1)" + +if [ "$DEPLOY" = 1 ]; then + [ -n "$DLSITE" ] || { echo "--deploy needs the real downloads folder (no --dest)" >&2; exit 1; } + echo "deploying $DLSITE" + (cd "$DLSITE" && npx --yes vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | grep -v "$TOKEN" || true) + TMP="$(mktemp -d)" + curl -fsSL -o "$TMP/m.json" "$BASE/igneum-app-latest.json" && curl -fsSL -o "$TMP/m.sig" "$BASE/igneum-app-latest.json.sig" \ + && "$SIGNER" verify "$PUB" "$TMP/m.json" "$TMP/m.sig" && echo "live manifest verified at $BASE/igneum-app-latest.json" \ + || { echo "the live manifest is not reachable or does not verify yet; check the deploy output" >&2; rm -rf "$TMP"; exit 1; } + rm -rf "$TMP" +else + if [ -n "$DLSITE" ]; then + echo "not deployed: cd $DLSITE && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes" + echo "then the apps see it at $BASE/igneum-app-latest.json (checked hourly, and from Settings > Check now)" + else + echo "written to $DEST for $BASE (test manifest; not the downloads folder)" + fi +fi diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index d4f661914..1583763d6 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -48,7 +48,8 @@ ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=admin MinVersion=10.0 -CloseApplications=no +CloseApplications=yes +RestartApplications=no [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -84,6 +85,8 @@ Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""{#Fi Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""{#FirewallRule}"" dir=in action=allow enable=yes profile=private,domain protocol=TCP program=""{app}\igneumd.exe"""; Flags: runhidden; Tasks: firewall; StatusMsg: "Adding the firewall rule for the node" ; Started as the signed-in user, not as administrator (the data lands in that user's %LOCALAPPDATA%). Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; Description: "Start Igneum Miner now"; Flags: postinstall nowait skipifsilent runasoriginaluser +; The over-the-air updater (packaging/ota, src/ota.rs) runs this installer /VERYSILENT /IGNOTA=1 and the app must come back by itself. +Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; Flags: nowait runasoriginaluser; Check: OtaRelaunch [UninstallRun] Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\stop-igneum.ps1"""; Flags: runhidden waituntilterminated; RunOnceId: "StopIgneum" @@ -94,6 +97,12 @@ Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""{#Fi Type: filesandordirs; Name: "{app}" [Code] +// /IGNOTA=1: the app's own updater started this install; relaunch the app when the files are in. +function OtaRelaunch: Boolean; +begin + Result := ExpandConstant('{param:IGNOTA|0}') = '1'; +end; + // Stops a running copy before the files are replaced (an upgrade over a running miner). function PrepareToInstall(var NeedsRestart: Boolean): String; var diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 4e7a847ae..d9b98f031 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -59,6 +59,15 @@ downloads the installer and the payload zip from the latest green run on master copies them into `dl//` next to the Mac-built packages, writes `igneum-windows-ci.json` (run URL, time), and prints the deploy command, or deploys with `--deploy`. +### Over-the-air updates (4 October 2026) + +The installed app updates itself: `packaging/ota/README.md`. `fetch-ci-artifacts.sh` adds the installer it copies to +the signed manifest (`igneum-app-latest.json`), `--deploy` ships both, and every app downloads the installer within +the hour and runs it `/VERYSILENT /IGNOTA=1` at a safe moment (one UAC prompt; `Igneum-Miner.iss` has +`CloseApplications=yes` and a `[Run]` relaunch for that flag). PC 2 steps: `packaging/ota/TEST.md`. The console +launcher packages (`proto-cuda/windows-app`, `igneum-windows-v4.zip`) are not auto-updated, by design: they are the +engineering path and are replaced by hand. + ### What still needs a human A code-signing certificate. Until Igneum has one, the installer and the exes are unsigned and SmartScreen shows diff --git a/packaging/windows/fetch-ci-artifacts.sh b/packaging/windows/fetch-ci-artifacts.sh index 28c349d55..093f7064d 100755 --- a/packaging/windows/fetch-ci-artifacts.sh +++ b/packaging/windows/fetch-ci-artifacts.sh @@ -6,6 +6,8 @@ # # Without --deploy it prints the deploy command for the main session to run; with --deploy it deploys the folder with # the Vercel CLI itself. A run id (gh run list) picks a specific run instead of the latest green one. +# The installer also goes into the over-the-air update manifest (packaging/ota/publish-manifest.sh, Windows entry; +# OTA_NOTES= for the changelog line, OTA_SKIP=1 to leave the manifest alone), so the deploy ships both. # Reads ~/.config/igneum/dl-token, ~/.config/igneum/dlsite-dir (IGNEUM_DLSITE overrides) and the gh login, which must # be igneum-josh (gh auth switch --user igneum-josh). set -euo pipefail @@ -44,6 +46,12 @@ JSON rm -rf "$TMP" echo "copied into $DEST:" ls -la "$DEST/$(basename "$SETUP")" "$DEST/igneum-windows-app.zip" +# the over-the-air manifest (packaging/ota): the Windows entry for this installer; the Mac entry of the same version is +# carried over. OTA_NOTES= sets the changelog line; OTA_SKIP=1 leaves the manifest alone. +if [ "${OTA_SKIP:-0}" != 1 ]; then + SETUP_VERSION="$(basename "$SETUP" | sed -n 's/^Igneum-Miner-Setup-\(.*\)\.exe$/\1/p')" + "$(dirname "$0")/../ota/publish-manifest.sh" --version "$SETUP_VERSION" --win "$DEST/$(basename "$SETUP")" --notes "${OTA_NOTES:-Windows build $SETUP_VERSION from CI run $RUN_ID}" --no-deploy +fi if [ "$DEPLOY" = 1 ]; then (cd "$DLSITE" && npx vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | grep -v "$TOKEN" || true) echo "live: https://dl.igneum.network/dl//$(basename "$SETUP")"