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>
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 ;;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
BIN
docs/design/app-screens/update-applying-live.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
docs/design/app-screens/update-applying.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
docs/design/app-screens/update-available.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/design/app-screens/update-downloading-live.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
docs/design/app-screens/update-downloading.png
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
docs/design/app-screens/update-error.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
docs/design/app-screens/update-manual.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/design/app-screens/update-ready-live.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
docs/design/app-screens/update-ready.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
docs/design/app-screens/update-rolledback-settings.png
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
docs/design/app-screens/update-settings.png
Normal file
|
After Width: | Height: | Size: 349 KiB |
BIN
docs/design/app-screens/update-updated.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
docs/design/app-screens/update-urgent.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
docs/design/app-screens/update-waiting.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")"
|
||||
|
|
|
|||