Igneum Miner: over-the-air updates (the project lead's rule: every app updates itself and downloads the update by itself). Signed manifest (Ed25519, key on the Mac, public key compiled in), hourly check with jitter, download with resume and sha256, staged bundle on macOS, silent Inno upgrade on Windows, apply at a safe moment (node synced, no hour boundary within 3 min, no worker starting), red bar and no waiting near a consensus activation height, rollback to .previous / the previous installer, Settings: Check now, Install now, automatic switch; publish-manifest.sh, fetch-ci-artifacts.sh adds the Windows entry; dry run on a private devnet 0.3.0 -> 0.3.1 and back (rollback), screenshots; TEST.md for PC 2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 13:25:14 +00:00
parent c7a7aeff47
commit 53eac058db
28 changed files with 485 additions and 39 deletions

View file

@ -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,

View file

@ -124,7 +124,7 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
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());

View file

@ -72,7 +72,6 @@ pub struct Updater {
app_dir: PathBuf,
dir: PathBuf,
auto: bool,
wrapper: bool,
manifest: Option<Manifest>,
entry: Option<PlatformEntry>,
file: Option<PathBuf>,
@ -89,7 +88,7 @@ pub struct Updater {
}
impl Updater {
pub fn new(shared: &Arc<Shared>, wrapper: bool) -> Updater {
pub fn new(shared: &Arc<Shared>) -> 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<String> = 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<Shared>, 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 <engine pid> <host pid|0> <app bundle> <staged bundle> <version> <result json>
# bash ota-apply.sh apply|rollback <engine pid> <host pid|0> <app bundle> <staged bundle> <version> <result json> [env file]
# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running
# bundle to "<app>.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 <result json> 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 ;;

View file

@ -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}

View file

@ -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; }

View file

@ -28,10 +28,12 @@
<button class="btn small primary" id="clock-sync">Sync clock</button>
<span class="hint mono" id="clock-banner-hint"></span>
</div>
<div class="banner" id="update-banner" hidden>
<span><b id="update-version">A new version</b> of Igneum Miner is ready.</span>
<button class="btn small primary" id="update-get">Download</button>
<div class="banner update" id="update-banner" hidden>
<span id="update-text">A new version of Igneum Miner is ready.</span>
<button class="btn small primary" id="update-install" hidden>Install now</button>
<button class="btn small primary" id="update-open" hidden>Open the download</button>
<button class="btn small ghost" id="update-later">Later</button>
<span class="prog" id="update-prog" hidden><i></i></span>
</div>
<main id="main">
@ -260,7 +262,8 @@
</div>
<div class="field">
<div class="k">version</div>
<div class="row between"><span class="mono" id="s-version"></span><button class="btn small" id="s-update">Check for updates</button></div>
<div class="row between"><span class="mono" id="s-version"></span><span class="row"><button class="btn small primary" id="s-install" hidden>Install now</button><button class="btn small" id="s-update">Check now</button></span></div>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself at a safe moment</span></label>
<p class="note" id="s-update-note"></p>
</div>
<div class="field">

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View file

@ -36,10 +36,14 @@ default browser.
`igneum-app.json` (never in the UI source): `update_manifest` = `https://dl.igneum.network/dl/<token>/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 <v>
--mac dist/Igneum-Miner-<v>.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

View file

@ -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 <v> --mac dist/Igneum-Miner-<v>.dmg --notes "..." then publishes it to the apps
# NODE=<path> MINER=<path> WORKER=<path> ENGINE=<path> 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|<string>0\.3\.0</string>|<string>$VERSION</string>|" "$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"

107
packaging/ota/README.md Normal file
View file

@ -0,0 +1,107 @@
# Over-the-air updates (packaging/ota)
the project lead'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/<token>/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/<token>/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-<run>.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 data>/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-<v>.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/<token>/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`.

67
packaging/ota/TEST.md Normal file
View file

@ -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/<token>/Igneum-Miner-Setup-<v>.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 <v>", "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: <v> 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 <v+1> is available.
Downloading it." then "Downloading ... 43%" then "Igneum Miner <v+1> 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-<v+1>.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 <v+1>: the miners stop, then the
node, then the app opens again", the footer says stopping, then ONE UAC prompt "Igneum-Miner-Setup-<v+1>.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 <v>."; the event feed starts with
`updated to Igneum Miner <v+1> from <v>`; 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 <node daa + 1000>` (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.

161
packaging/ota/publish-manifest.sh Executable file
View file

@ -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/<token>/, 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:<port>/dl/<token>
# and --dest <folder> 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/<token>/)" >&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() { # <file> -> "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

View file

@ -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

View file

@ -59,6 +59,15 @@ downloads the installer and the payload zip from the latest green run on master
copies them into `dl/<token>/` 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

View file

@ -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-labs (gh auth switch --user igneum-labs).
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/<token>/$(basename "$SETUP")"