2.0.2: PRODUCT=public|lab packaging switch, the kill-by-name refusal, the prove host's lease, the ten-minute check, the synced apply bound

PRODUCT=public|lab (the founder's word at 20:21 UK): one variable in packaging/mac/packaged-config.sh that every packager
reads: Igneum-Miner.iss AppId, AppName, folder and file name (/DProduct from build-installer.ps1 -Product), make-payload.sh
and make-hive-package.sh names (payload folder, Hive CUSTOM_NAME and archive), build-dmg.sh bundle id, app and dmg names,
the channel (igneum-2.0-devnet stays the public string), the manifest name and the signing root; the packager writes
edition, channel and ota_root_hex into igneum-app.json and the engine names a mismatch on its update card.

The job runner refuses a run-script body that ends a process by name (Stop-Process -Name, taskkill /IM, Get-Process |
Stop-Process, pkill, killall): exit 77 with the matched line, in the signer and in the runner; a pid is the only way.

Every igneum-prove-host spawn carries IGNEUM_PROVE_DEVICE, IGNEUM_PROVE_WORKLOAD, IGNEUM_PROVE_MEM_FREE_MB,
IGNEUM_PROVE_MEM_BUDGET_MB and IGNEUM_PROVE_DEADLINE_S (the V6-07 contract); exit 78 reads "proving needs N GB free".

The manifest check runs every ten minutes. A staged update applies at once on a synced node with an idle miner and
within two minutes of the sync otherwise; Install now passes the finality guard; publish-manifest.sh --urgent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 19:51:15 +00:00
parent af4302002f
commit af5ade4339
16 changed files with 476 additions and 63 deletions

View file

@ -344,6 +344,14 @@ fn machine_id(app_dir: &Path) -> String {
/// Written by the packager (build-dmg.sh, make-payload.sh). Missing fields disable the feature.
#[derive(Clone, Serialize, Deserialize, Default)]
pub struct Packaged {
/// PRODUCT=public|lab (packaging/mac/packaged-config.sh, 8 October 2026): the edition the package was made for, its
/// channel and the signing root its engine must carry; empty in a package from before the switch.
#[serde(default)]
pub edition: String,
#[serde(default)]
pub channel: String,
#[serde(default)]
pub ota_root_hex: String,
#[serde(default)]
pub update_manifest: String,
#[serde(default)]
@ -450,6 +458,22 @@ impl Packaged {
self
}
/// The words when the package was made for the other edition or another signing root (a public engine in a lab
/// package would never verify a lab manifest, and the other way round); None when the fields match or are absent.
pub fn edition_mismatch(&self) -> Option<String> {
let mut faults = Vec::new();
if !self.edition.is_empty() && self.edition != crate::edition::name() {
faults.push(format!("the package is the {} edition and this engine is the {} build", self.edition, crate::edition::name()));
}
if !self.channel.is_empty() && !crate::edition::channel_accepted(&self.channel) {
faults.push(format!("the package's channel {} is not this build's ({})", self.channel, crate::edition::channel()));
}
if !self.ota_root_hex.is_empty() && self.ota_root_hex != crate::edition::ota_key() {
faults.push(format!("the package's signing root {} is not this build's root {}", fingerprint8(&self.ota_root_hex), fingerprint8(crate::edition::ota_key())));
}
if faults.is_empty() { None } else { Some(format!("package mismatch: {}; updates will not verify until the matching build is installed", faults.join("; "))) }
}
/// The log header line: the intake URL with the key's fingerprint and the manifest URL with the folder's
/// fingerprint, each with its source; the values themselves never appear (the log is uploaded).
pub fn describe(&self) -> String {
@ -612,6 +636,21 @@ mod tests {
out
}
/// Known failed first (PRODUCT=public|lab, 8 October 2026): the packager writes the edition, the channel and the signing
/// root into igneum-app.json; an engine of the other build reports the mismatch in words (a public engine in a lab
/// package would never verify a lab manifest, and the other way round), and an older package without the fields says nothing.
#[test]
fn a_package_of_the_other_edition_is_named() {
let mine = Packaged { edition: crate::edition::name().into(), channel: crate::edition::channel().into(), ota_root_hex: crate::edition::ota_key().into(), ..Default::default() };
assert_eq!(mine.edition_mismatch(), None);
assert_eq!(Packaged::default().edition_mismatch(), None, "a package without the fields says nothing");
let other = Packaged { edition: "beta".into(), channel: "igneum-2.0-devnet-beta".into(), ota_root_hex: "ab".repeat(32), ..Default::default() };
let w = other.edition_mismatch().expect("named");
assert!(w.contains("beta") && w.contains(crate::edition::name()) && w.contains("root"), "{w}");
let root_only = Packaged { edition: crate::edition::name().into(), ota_root_hex: "cd".repeat(32), ..Default::default() };
assert!(root_only.edition_mismatch().unwrap().contains("signing root"));
}
#[test]
fn a_measurement_engine_overrides_the_copied_settings_in_memory() {
let mut s = Settings { remote_jobs: true, auto_update: true, prove: true, paused: true, sweep: false, power_control: true, address: "0xabc".into(), ..Default::default() };

View file

@ -136,6 +136,67 @@ impl Budget {
}
}
/// The job the host is admitted for (one per spawn): a shard proof, a segment aggregation, a whole chain run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Workload {
Shard,
Aggregate,
Chain,
}
impl Workload {
pub fn word(self) -> &'static str {
match self {
Workload::Shard => "shard",
Workload::Aggregate => "aggregate",
Workload::Chain => "chain",
}
}
}
/// The exit code the host answers with when the budget is under its profile's floor (the V6-07 sub-lane's contract,
/// 8 October 2026): the engine records it on the lease and the card reads "proving needs N GB free".
pub const HOST_FLOOR_EXIT: i32 = 78;
/// The five variables every igneum-prove-host spawn carries, exactly as the host reads them (the shipper's contract with
/// the V6-07 sub-lane, 8 October 2026): the card's ordinal as the host enumerates it, the one workload admitted, the free
/// memory the engine read at admission, the lease's grant (the host's hard ceiling) and the lease's deadline.
pub fn host_env(device: u32, workload: Workload, free_mib: u64, budget_mib: u64, deadline_s: u64) -> Vec<(&'static str, String)> {
vec![
("IGNEUM_PROVE_DEVICE", device.to_string()),
("IGNEUM_PROVE_WORKLOAD", workload.word().to_string()),
("IGNEUM_PROVE_MEM_FREE_MB", free_mib.to_string()),
("IGNEUM_PROVE_MEM_BUDGET_MB", budget_mib.to_string()),
("IGNEUM_PROVE_DEADLINE_S", deadline_s.to_string()),
]
}
/// The lease's grant for a proof on a card of `vram_mib` under `mode`: beside the miner the measured peak plus the
/// headroom; alone on the card everything above the free floor; nothing on a card that only mines.
pub fn proof_budget_mib(vram_mib: u64, mode: Mode) -> u64 {
match mode {
Mode::Simultaneous => PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100,
Mode::TimeShare | Mode::ProveOnly => vram_mib.saturating_sub(FREE_MIB),
Mode::MiningOnly => 0,
}
}
/// The card's words after exit 78: the figure the host printed ("needs N GB" or "needs N MB"/"MiB"; the host's own line
/// is "RESULT memory_profile refused: proving needs N GB free on the card for the <workload> workload (<floor> MiB
/// floor); <free> MiB free") when it did, else the budget it was offered, in whole GB rounded up.
pub fn floor_refusal_words(out: &str, budget_mib: u64) -> String {
let printed = out.lines().rev().find_map(|l| {
let i = l.find("needs ")?;
let rest = &l[i + 6..];
let n: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let n = n.parse::<u64>().ok().filter(|n| *n > 0)?;
let unit = rest[n.to_string().len()..].trim_start();
Some(if unit.starts_with("GB") || unit.starts_with("GiB") { n } else { n.div_ceil(1024) })
});
let gb = printed.unwrap_or(budget_mib.div_ceil(1024));
format!("proving needs {gb} GB free")
}
#[derive(Default, Debug)]
pub struct Coordinator {
leases: HashMap<String, Vec<Lease>>,
@ -229,6 +290,40 @@ impl Coordinator {
mod tests {
use super::*;
/// Known failed first (the shipper's contract with the V6-07 sub-lane, 8 October 2026): every igneum-prove-host spawn
/// carries the five names, exactly as written, with the lease's figures; a spawn without them is the old shape.
#[test]
fn the_host_reads_its_lease_from_five_named_variables() {
let env = host_env(1, Workload::Shard, 11_800, 7_532, 600);
let names: Vec<&str> = env.iter().map(|(k, _)| *k).collect();
assert_eq!(names, ["IGNEUM_PROVE_DEVICE", "IGNEUM_PROVE_WORKLOAD", "IGNEUM_PROVE_MEM_FREE_MB", "IGNEUM_PROVE_MEM_BUDGET_MB", "IGNEUM_PROVE_DEADLINE_S"]);
let values: Vec<&str> = env.iter().map(|(_, v)| v.as_str()).collect();
assert_eq!(values, ["1", "shard", "11800", "7532", "600"]);
assert_eq!(host_env(0, Workload::Aggregate, 1, 2, 3)[1].1, "aggregate");
assert_eq!(host_env(0, Workload::Chain, 1, 2, 3)[1].1, "chain");
}
/// The lease's grant per mode: the measured proof peak with headroom beside the miner; the card less the free floor
/// when the prover has the card to itself; nothing on a mining-only card.
#[test]
fn the_budget_is_the_grant_the_mode_allows() {
assert_eq!(proof_budget_mib(24_576, Mode::Simultaneous), PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100);
assert_eq!(proof_budget_mib(12_288, Mode::TimeShare), 12_288 - FREE_MIB);
assert_eq!(proof_budget_mib(8_192, Mode::ProveOnly), 8_192 - FREE_MIB);
assert_eq!(proof_budget_mib(8_192, Mode::MiningOnly), 0);
}
/// Exit 78 from the host is the floor refusal: the card's words name the floor the host printed, else the budget.
#[test]
fn the_floor_refusal_names_the_memory_proving_needs() {
assert_eq!(HOST_FLOOR_EXIT, 78);
assert_eq!(floor_refusal_words("RESULT refused: profile needs 9216 MB free, 7532 offered", 7_532), "proving needs 9 GB free");
// the host's own line (the V6-07 sub-lane, 8 October 2026): the GB figure is read as GB, not as MiB
assert_eq!(floor_refusal_words("RESULT memory_profile refused: proving needs 8 GB free on the card for the shard workload (7700 MiB floor); 6100 MiB free", 7_532), "proving needs 8 GB free");
assert_eq!(floor_refusal_words("nothing useful", 7_532), "proving needs 8 GB free");
assert_eq!(floor_refusal_words("", 12_000), "proving needs 12 GB free");
}
#[test]
fn the_modes_follow_the_measured_rows() {
// nvidia-smi's figures: 32 GB 32,607; 24 GB 24,564; 16 GB 16,376; 12 GB 12,208; 8 GB 8,188; 6 GB 6,144

View file

@ -1042,6 +1042,12 @@ impl Engine {
// which intake and which downloads folder this build reports to and checks (fingerprints, never the values;
// rotation phase 2 reads this line from every machine's upload: docs/plans/rotation-phase-2.md)
self.shared.log(&self.shared.packaged.describe());
if let Some(w) = self.shared.packaged.edition_mismatch() {
self.shared.log(&w);
let mut st = self.shared.state.lock().unwrap();
st.update.error = w;
st.update.status = "error".into();
}
// the prover service (proving v0): its own thread, idle until the setting is on
crate::prover::start(self.shared.clone(), self.bins.dir.clone());
self.shared.log(&format!("node binary: {} ({v})", self.bins.node.display()));
@ -4174,6 +4180,8 @@ impl Engine {
boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None },
// a remote job in progress counts as busy: no update applies under it (src/jobrun.rs)
miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting"),
// 2.0.2: no card mining or starting and nothing building: the staged update applies at once on a synced node
miner_idle: !self.miners.iter().any(|m| m.building) && !st.mining.cards.iter().any(|c| c.enabled && (c.state == "mining" || c.state == "starting")),
// a remote job in progress holds the update, urgent or not (src/manifest.rs safe_to_apply; PC 1, 6 October 2026)
job_active: self.jobs.active(),
daa: st.node.daa,

View file

@ -1332,6 +1332,12 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
let dir = jobs_dir.join(&job.id);
let shell = shell_for(job);
let body = job.str_param("script").replace("\r\n", "\n");
// the founder's word at 20:21 UK (8 October 2026): a body that ends a process by name never runs (the relay agent's
// Check-Task carries the same refusal, exit 77 with the matched line)
if let Some(line) = jobs::kill_by_name_line(&body) {
sink.line(&format!("refused: the script ends a process by name ({line}); a pid is the only way"));
return Ok(Done { status: "failed".into(), exit: 77, summary: format!("refused: the script ends a process by name ({line}); a pid is the only way"), extra: json!({ "refused": "kill_by_name", "line": line }) });
}
let script = dir.join(if shell == "powershell" { "script.ps1" } else { "script.sh" });
let text = if shell == "powershell" { body.replace('\n', "\r\n") } else { body };
std::fs::write(&script, if shell == "powershell" { [b"\xEF\xBB\xBF".as_slice(), text.as_bytes()].concat() } else { text.into_bytes() }).map_err(|e| format!("cannot write the script: {e}"))?;

View file

@ -373,12 +373,36 @@ fn sha_ok(s: &str) -> bool {
}
/// Per-kind checks of the params, so a job that cannot run is refused at signing time.
/// The founder's word at 20:21 UK, 8 October 2026 (fifteen Mac processes killed by a grep that evening; by construction,
/// not by rule): a run-script body that ends a process by NAME is refused before it runs; a pid is the only way. The
/// shapes: `Stop-Process -Name`, `taskkill /IM`, `Get-Process -Name … | Stop-Process`, `pkill`, `killall`, as commands
/// (the first word of a line or of a pipeline stage), never as a word inside a string. Returns the matched line.
pub fn kill_by_name_line(script: &str) -> Option<&str> {
for raw in script.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let lower = line.to_ascii_lowercase();
let stage_starts = |word: &str| lower.split(['|', ';', '&']).any(|stage| { let st = stage.trim_start(); st == word || st.starts_with(&format!("{word} ")) || st.starts_with(&format!("{word}\t")) });
let taskkill_im = lower.contains("taskkill") && lower.split_whitespace().any(|w| w == "/im");
let stop_by_name = lower.contains("stop-process") && (lower.contains("-name") || lower.contains("get-process -name") || lower.contains("get-process "));
if stage_starts("pkill") || stage_starts("killall") || taskkill_im || stop_by_name {
return Some(raw.trim());
}
}
None
}
pub fn validate_params(job: &Job) -> Result<(), String> {
match job.kind.as_str() {
"run" => {
if job.str_param("script").trim().is_empty() {
return Err("run: params.script is empty".into());
}
if let Some(line) = kill_by_name_line(&job.str_param("script")) {
return Err(format!("run: the script ends a process by name ({line}); a pid is the only way (Stop-Process -Id, taskkill /PID, kill <pid>)"));
}
let sh = job.str_param("shell");
if !sh.is_empty() && !["powershell", "bash"].contains(&sh.as_str()) {
return Err(format!("run: shell '{sh}' is not powershell or bash"));
@ -860,6 +884,15 @@ mod tests {
assert!(j("shard-benchmark", r#"{}"#).unwrap_err().contains("sha256"));
assert!(j("shard-benchmark", &format!(r#"{{"sha256":"{}","fixtures":["../x"]}}"#, "b".repeat(64))).unwrap_err().contains("fixture"));
assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell"));
// the founder's word at 20:21 UK, 8 October 2026 (by construction, not by rule): a run-script body that ends a
// process by name is refused before it runs; a pid is the only way. Known-failed first: every shape ran.
for body in ["Stop-Process -Name igneum-app -Force", "taskkill /IM igneum-app.exe /F", "Get-Process -Name igneumd | Stop-Process", "pkill -f igneumd", "killall igneum-worker", "echo ok\ntaskkill /f /im node.exe\necho done"] {
let e = j("run", &format!(r#"{{"script":{}}}"#, serde_json::Value::String(body.into()))).unwrap_err();
assert!(e.contains("ends a process by name") && e.contains("pid"), "{body}: {e}");
}
assert!(j("run", r#"{"script":"Stop-Process -Id 4242; taskkill /PID 4242 /F; kill -TERM $(cat run.pid)"}"#).is_ok(), "a pid is the way");
assert_eq!(kill_by_name_line("echo one\nGet-Process -Name igneumd | Stop-Process\necho two"), Some("Get-Process -Name igneumd | Stop-Process"));
assert_eq!(kill_by_name_line("echo pkill is a word in a string, not a command: 'the pkill rule'"), None, "the shape, not the word");
}
#[test]

View file

@ -410,8 +410,18 @@ pub struct Moment {
/// not, until the user presses Install now (`install_asked`)
pub auto_update_off: bool,
pub install_asked: bool,
/// 2.0.2 (the shipper, the founder's mini, 8 October 2026): no card is mining or starting; an idle or refused miner
/// is the strongest reason to apply, never a reason to wait.
pub miner_idle: bool,
/// How long the node has read synced, in seconds (0 when it does not).
pub synced_for_s: u64,
}
/// With automatic updates on, a staged update applies within this many seconds of the node reading synced (and of the
/// staging, whichever is later), whatever the miner is doing: the machine's slot minute, the boundary guard and a
/// starting worker hold it no longer than this (2.0.2; the mini held 2.0.1 for its slot minute with an idle miner).
pub const SYNCED_APPLY_BOUND_S: u64 = 120;
/// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes).
pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0;
@ -430,19 +440,27 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
if m.auto_update_off && !m.install_asked {
return Err("automatic updates are off: waiting for Install now".into());
}
// the finality rule next: a fork-close or unsupported urgency does not pass it, only the manifest's flag
if m.finality_paused && !m.manifest_urgent {
// the finality rule next: a fork-close or unsupported urgency does not pass it, only the manifest's flag or the
// operator's hand (Install now, 2.0.2: the held update can be the fix that restores the locks; the rule is for
// unattended machines)
if m.finality_paused && !m.manifest_urgent && !m.install_asked {
return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into());
}
if m.job_active {
return Err("a remote job is running; installing when it closes".into());
}
if m.urgent {
// the operator's Install now is urgent in its own right (the engine folds it into `urgent` too)
if m.urgent || m.install_asked {
return Ok(());
}
if m.network_drop_pct > NETWORK_DROP_HOLD_PCT {
return Err(format!("the network lost {:.0}% of its identities in the last 10 minutes; holding the update", m.network_drop_pct));
}
// 2.0.2: a synced node with an idle miner applies at once; a mining one within SYNCED_APPLY_BOUND_S of the sync
// and the staging, slot or no slot, boundary or no boundary
if m.node_synced && (m.miner_idle || (m.synced_for_s >= SYNCED_APPLY_BOUND_S && m.ready_for_s >= SYNCED_APPLY_BOUND_S)) {
return Ok(());
}
if !m.slot_ok {
return Err("waiting for this machine's own minute of the hour (machines take turns)".into());
}
@ -656,9 +674,37 @@ mod tests {
assert!(!newer("0.3.1.2", "0.3.0"));
}
/// Known failed first (the shipper, the founder's mini, 8 October 2026 20:30 to 20:40 UK): 2.0.1 staged at 20:30:22,
/// the node synced from 20:35, the miner idle the whole time, and nothing installed by 20:40 because the machine's
/// own minute of the hour had not come. The rule: with automatic updates on, a staged update applies within
/// SYNCED_APPLY_BOUND_S of the node reading synced, whatever the miner is doing; an idle or refused miner is the
/// strongest reason to apply, never a reason to wait.
#[test]
fn a_staged_update_applies_within_two_minutes_of_sync_and_at_once_on_an_idle_miner() {
let staged = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 300, urgent: false, slot_ok: false, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false, auto_update_off: false, install_asked: false, miner_idle: false, synced_for_s: 0 };
assert_eq!(SYNCED_APPLY_BOUND_S, 120);
// the mini's case: node synced, miner idle, outside the slot: applies at once
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 1, ..staged.clone() }).is_ok());
// a mining miner: within the bound of the sync, outside the slot and inside the boundary guard, it still applies
assert!(safe_to_apply(&Moment { synced_for_s: SYNCED_APPLY_BOUND_S, boundary_eta_s: Some(30), ..staged.clone() }).is_ok());
assert!(safe_to_apply(&Moment { synced_for_s: SYNCED_APPLY_BOUND_S - 1, ..staged.clone() }).unwrap_err().contains("own minute"));
// the bound counts from the later of the sync and the staging
assert!(safe_to_apply(&Moment { synced_for_s: 3600, ready_for_s: SYNCED_APPLY_BOUND_S - 1, ..staged.clone() }).is_err());
assert!(safe_to_apply(&Moment { synced_for_s: 3600, ready_for_s: SYNCED_APPLY_BOUND_S, ..staged.clone() }).is_ok());
// an unsynced node still waits, idle miner or not; a job, paused finality and updates-off still hold
assert!(safe_to_apply(&Moment { node_synced: false, miner_idle: true, synced_for_s: 0, ..staged.clone() }).is_err());
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, job_active: true, ..staged.clone() }).unwrap_err().contains("remote job"));
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, finality_paused: true, ..staged.clone() }).unwrap_err().contains("finality"));
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, auto_update_off: true, ..staged.clone() }).unwrap_err().contains("Install now"));
// 20:45 on the mini: the finality guard held the update that was the fix for the locks, and Install now could not
// pass it; the operator's hand outranks a rule for unattended machines (a fork-close urgency alone still does not)
assert!(safe_to_apply(&Moment { finality_paused: true, install_asked: true, ..staged.clone() }).is_ok());
assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, install_asked: false, ..staged.clone() }).unwrap_err().contains("finality"));
}
#[test]
fn safe_moments() {
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false, auto_update_off: false, install_asked: false };
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false, auto_update_off: false, install_asked: false, miner_idle: false, synced_for_s: 60 };
assert!(safe_to_apply(&base).is_ok());
// the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install;
// paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it
@ -687,7 +733,8 @@ mod tests {
// patience: an unsynced node for 6 h applies anyway
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
// the machine's slot: outside it nothing applies, not even with patience; urgent ignores it
// the machine's slot: outside it nothing applies in the first SYNCED_APPLY_BOUND_S of a synced node (2.0.2; before
// that nothing applied outside it at all, not even with patience); urgent ignores it
assert!(safe_to_apply(&Moment { slot_ok: false, ..base.clone() }).unwrap_err().contains("own minute"));
assert!(safe_to_apply(&Moment { slot_ok: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { slot_ok: false, urgent: true, ..base.clone() }).is_ok());

View file

@ -3,7 +3,7 @@
//! rule such as difficulty v2) reaches every node before its activation height.
//!
//! The loop, driven from the engine's tick:
//! check (on start, then hourly with jitter): fetch igneum-app-latest.json and its .sig, verify the Ed25519
//! check (on start, then every ten minutes with jitter (2.0.2; hourly before)): fetch igneum-app-latest.json and its .sig, verify the Ed25519
//! signature with the key compiled into src/manifest.rs, parse, compare versions
//! -> download (curl with resume into <app data>/app/updates/, then size and sha256 against the manifest)
//! -> stage (macOS: mount the DMG or unpack the zip, copy the new bundle next to the running one, check its
@ -24,7 +24,7 @@
//! the previous version (macOS: the .previous bundle; Windows: the previous installer kept in updates/).
//!
//! Environment (tests): IGNEUM_APP_UPDATE_MANIFEST overrides the manifest URL from igneum-app.json,
//! IGNEUM_APP_UPDATE_CHECK_SECS the hourly interval, IGNEUM_APP_UPDATE_FIRST_SECS the delay of the first check.
//! IGNEUM_APP_UPDATE_CHECK_SECS the check interval (600 s), IGNEUM_APP_UPDATE_FIRST_SECS the delay of the first check.
use crate::engine::{Cmd, Shared};
use crate::manifest::{self, Manifest, Moment, PlatformEntry};
@ -37,7 +37,7 @@ use std::time::{Duration, Instant};
/// A manifest published this long before the engine started is a catch-up: the hourly rollout slot does not apply.
const CATCH_UP_AFTER_S: u64 = 3600;
const HEALTHY_AFTER_S: u64 = 90;
const CHECK_EVERY_S: u64 = 3600;
const CHECK_EVERY_S: u64 = 600;
const RETRY_AFTER_ERROR_S: u64 = 600;
pub enum Event {
@ -72,6 +72,8 @@ pub struct Ctx {
pub finality_paused: bool,
pub boundary_eta_s: Option<i64>,
pub miner_busy: bool,
/// No card mines or starts (2.0.2): a staged update applies at once on a synced node.
pub miner_idle: bool,
/// A remote job is running (src/jobrun.rs): the install holds, urgent or not.
pub job_active: bool,
pub daa: u64,
@ -102,6 +104,8 @@ pub struct Updater {
busy: bool,
next_check: Instant,
ready_since: Option<Instant>,
/// when the node last went from unsynced to synced (None while it is not): manifest::SYNCED_APPLY_BOUND_S counts from it
synced_since: Option<Instant>,
last_safe_check: Instant,
install_asked: bool,
pending: Option<Pending>,
@ -165,6 +169,7 @@ impl Updater {
busy: false,
next_check: now + Duration::from_secs(first),
ready_since: None,
synced_since: None,
last_safe_check: now,
install_asked: false,
pending: None,
@ -614,8 +619,14 @@ impl Updater {
}
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
if ctx.node_synced {
self.synced_since.get_or_insert(now);
} else {
self.synced_since = None;
}
let synced_for = self.synced_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false);
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , auto_update_off: !self.auto, install_asked: self.install_asked, finality_paused: ctx.finality_paused, manifest_urgent };
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , auto_update_off: !self.auto, install_asked: self.install_asked, finality_paused: ctx.finality_paused, manifest_urgent, miner_idle: ctx.miner_idle, synced_for_s: synced_for };
if !self.auto && !self.install_asked {
// F14: off stays off, urgent or not; an unsupported version pauses mining (the engine reads update.hold_mining)
let mut st = shared.state.lock().unwrap();
@ -1248,6 +1259,14 @@ pub fn legacy_return_sequence(installer_exit: i32) -> Vec<ReturnStep> {
#[cfg(test)]
mod return_tests {
use super::*;
/// Known failed first (8 October 2026, 2.0.2): the hourly check made a user wait up to an hour for an entry the
/// fleet published (the 2.0.1 entry reached a machine after 14 minutes only because its api was poked).
/// Ten minutes, the first check after start unchanged, the staged update still applied at the next safe moment.
#[test]
fn the_manifest_check_runs_every_ten_minutes() {
assert_eq!(CHECK_EVERY_S, 600);
assert_eq!(CHECK_EVERY_S / 6 + 1, 101, "the jitter window stays a sixth of the interval");
}
fn done(steps: &[ReturnStep]) -> &ReturnStep {
steps.last().unwrap()
}

View file

@ -31,6 +31,10 @@ use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// The lease deadline a shard proof is given (the F07 budget row's clock: transfer, start, prove and rebuild inside
/// the exclusive window); a chain run and an aggregation carry their run limits.
const SHARD_DEADLINE_S: u64 = 600;
/// One shard the node lists for this machine's keys (`igneum_getAssignedShards`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Work {
@ -247,6 +251,42 @@ fn read_verifier(shared: &Shared) {
/// every second and killed when the app quits, the setting goes off or `limit` passes (a proof must never outlive
/// the app). Returns (exit ok, output).
fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (bool, String) {
let (code, out) = run_tool_code(shared, t, exe, args, env, limit, log);
(code == Some(0), out)
}
/// The lease every igneum-prove-host spawn carries (the V6-07 sub-lane's contract, src/device.rs host_env): the first
/// NVIDIA card as the host enumerates it, the free memory read now, the grant the card's mode allows, the deadline.
/// (free, budget, env) so the refusal words can name the budget; a machine without an NVIDIA card gets device 0 and
/// no grant, which the host reads as the CPU shape.
fn host_lease(shared: &Shared, workload: crate::device::Workload, deadline_s: u64) -> (u64, u64, Vec<(&'static str, String)>) {
let (index, vram, mining) = {
let st = shared.state.lock().unwrap();
st.mining.cards.iter().filter(|c| c.vendor == "nvidia").min_by_key(|c| c.index).map(|c| (c.index as u32, c.vram_mb, c.enabled && c.state == "mining")).unwrap_or((0, 0, false))
};
let used = crate::detect::nvidia_memory_used().get(&index.to_string()).copied().unwrap_or(0);
let free = vram.saturating_sub(used);
let budget = crate::device::proof_budget_mib(vram, crate::device::mode(vram, mining));
let env = crate::device::host_env(index, workload, free, budget, deadline_s);
shared.log(&format!("LEASE holder=prover device=nvidia:{index} workload={} free_mib={free} budget_mib={budget} deadline_s={deadline_s}", workload.word()));
(free, budget, env)
}
/// The host's floor refusal (exit 78): the card's words, the LEASE line with the refusal, Some(words); None otherwise.
fn host_floor_refusal(shared: &Shared, code: Option<i32>, out: &str, budget: u64) -> Option<String> {
if code != Some(crate::device::HOST_FLOOR_EXIT) {
return None;
}
let words = crate::device::floor_refusal_words(out, budget);
shared.log(&format!("LEASE holder=prover event=refused exit={} budget_mib={budget} words=\"{words}\"", crate::device::HOST_FLOOR_EXIT));
set(shared, |p| {
p.status = "waiting".into();
p.message = words.clone();
});
Some(words)
}
fn run_tool_code(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (Option<i32>, String) {
// Windows: a script file exports the environment and execs the host; the arguments travel as $1, $2... (the
// single-quoted rule of src/wslhost.rs). The file lives until the run ends.
let (mut cmd, _script) = if t.wsl {
@ -254,7 +294,7 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
body.push_str(&format!("exec {} \"$@\"", crate::wslhost::sq(&exe.display().to_string())));
let file = match crate::wslhost::write_script("prove-run", &body) {
Ok(f) => f,
Err(e) => return (false, format!("cannot write the WSL run script: {e}")),
Err(e) => return (None, format!("cannot write the WSL run script: {e}")),
};
let argv: Vec<&str> = args.iter().map(|a| a.as_str()).collect();
let c = crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &argv);
@ -268,12 +308,12 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
(c, None)
};
crate::platform::quiet(&mut cmd);
let Ok(file) = std::fs::File::create(log) else { return (false, format!("cannot write {}", log.display())) };
let Ok(err) = file.try_clone() else { return (false, "cannot clone the log handle".into()) };
let Ok(file) = std::fs::File::create(log) else { return (None, format!("cannot write {}", log.display())) };
let Ok(err) = file.try_clone() else { return (None, "cannot clone the log handle".into()) };
cmd.stdin(std::process::Stdio::null()).stdout(file).stderr(err);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return (false, format!("{}: {e}", exe.display())),
Err(e) => return (None, format!("{}: {e}", exe.display())),
};
let started = Instant::now();
let status = loop {
@ -282,7 +322,7 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
Ok(None) => {}
Err(e) => {
let _ = child.kill();
return (false, format!("{}: {e}", exe.display()));
return (None, format!("{}: {e}", exe.display()));
}
}
let stop = {
@ -298,8 +338,8 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
};
let out = std::fs::read_to_string(log).unwrap_or_default();
match status {
Some(st) => (st.success(), out),
None => (false, format!("{} stopped after {} s (quit, proving switched off, or the {} s limit)", exe.display(), started.elapsed().as_secs(), limit.as_secs())),
Some(st) => (st.code().or(Some(-1)), out),
None => (None, format!("{} stopped after {} s (quit, proving switched off, or the {} s limit)", exe.display(), started.elapsed().as_secs(), limit.as_secs())),
}
}
@ -774,7 +814,14 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
}
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() });
let prover_env = if t.cuda { "cuda" } else { "cpu" };
let (ok, out) = run_tool(&shared, t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &[("SP1_PROVER", prover_env), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join(format!("prove-{}-{}.log", w.number, w.shard)));
let (_free, budget, lease) = host_lease(&shared, crate::device::Workload::Shard, SHARD_DEADLINE_S);
let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", prover_env), ("RUST_LOG", "off")];
env.extend(lease.iter().map(|(k, v)| (*k, v.as_str())));
let (code, out) = run_tool_code(&shared, t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &env, Duration::from_secs(3 * 3600), &dir.join(format!("prove-{}-{}.log", w.number, w.shard)));
if let Some(words) = host_floor_refusal(&shared, code, &out, budget) {
return Err(format!("prover: {words}"));
}
let ok = code == Some(0);
if !ok || !results.exists() {
let last = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
// the root-socket class (5 October 2026, PC 2 at 20:00Z and 21:25Z): a job that ran the host as root
@ -975,7 +1022,14 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
args.push("--prev".into());
args.push(pf.to_string());
}
let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join("chain.log"));
let (_free, budget, lease) = host_lease(shared, crate::device::Workload::Chain, 3 * 3600);
let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")];
env.extend(lease.iter().map(|(k, v)| (*k, v.as_str())));
let (code, out) = run_tool_code(shared, t, &t.host, &args, &env, Duration::from_secs(3 * 3600), &dir.join("chain.log"));
if let Some(words) = host_floor_refusal(shared, code, &out, budget) {
return Err(format!("chain: {words}"));
}
let ok = code == Some(0);
if !ok || !results.exists() {
let last_line = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
let hint = if last_line.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user: the root-socket class)" } else { "" };
@ -1139,7 +1193,14 @@ fn aggregate_once(shared: &Shared, t: &Tools, label: &str, payout: &str, attempt
args.push("--prev".into());
args.push(pf);
}
let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(2 * 3600), &dir.join("aggregate.log"));
let (_free, budget, lease) = host_lease(shared, crate::device::Workload::Aggregate, 2 * 3600);
let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")];
env.extend(lease.iter().map(|(k, v)| (*k, v.as_str())));
let (code, out) = run_tool_code(shared, t, &t.host, &args, &env, Duration::from_secs(2 * 3600), &dir.join("aggregate.log"));
if let Some(words) = host_floor_refusal(shared, code, &out, budget) {
return Err(format!("segment {first}..{last}: {words}"));
}
let ok = code == Some(0);
if !ok || !results.exists() {
return Err(format!("segment {first}..{last}: aggregator: {}", out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed")));
}

View file

@ -135,3 +135,24 @@ One tree, two builds, chosen by the cargo feature `lab` on `app/igneum-app` (`sr
Each build refuses a manifest of the other channel before staging it. The engine reports `edition`, `product` and `channel` in `api/state` and `channel=`/`edition=` on the IGNEUM-APP intake line, so the relay agent and the jobs publisher refuse a public engine by what it says about itself. `tools/build-remote.sh` passes `--features lab` through its cargo arguments; the variable travels with the shipper's build environment (`bs_repro_env`).
### PRODUCT=public|lab (the founder's word at 20:21 UK, 8 October 2026)
One variable, read by every packager through `packaging/mac/packaged-config.sh` (the table lives there and nowhere else):
| | public (default) | lab |
|---|---|---|
| name | Igneum Miner | Igneum Miner Lab |
| channel | igneum-2.0-devnet | igneum-2.0-devnet-lab |
| manifest | dl/<token>/igneum-app-latest.json | dl/<token>/igneum-app-lab-latest.json |
| signing root | the release root (src/manifest.rs) | IGNEUM_LAB_PUBLIC_KEY or ~/.config/igneum/lab-signing/lab-signing-key.pub (refused when neither holds 64 hex) |
| Mac | Igneum Miner.app, network.igneum.miner, Igneum-Miner-<v>.dmg | Igneum Miner Lab.app, network.igneum.miner.lab, Igneum-Miner-Lab-<v>.dmg |
| Windows | AppId {A4C1F0E2-...}, Igneum-Miner-Setup-<v>.exe, payload igneum-windows-app | AppId {5E2B7C41-...}, Igneum-Miner-Lab-Setup-<v>.exe, payload igneum-windows-app-lab |
| Hive | igneum-hive-<v>.tar.gz, CUSTOM_NAME=igneum | igneum-lab-hive-<v>.tar.gz, CUSTOM_NAME=igneum-lab |
`PRODUCT=lab packaging/mac/build-dmg.sh`, `PRODUCT=lab packaging/windows/make-payload.sh`, `PRODUCT=lab packaging/hive/make-hive-package.sh`,
and on the PC `build-installer.ps1 -Product lab` (or `$env:PRODUCT`), which passes `/DProduct=lab` to Inno. The engine inside a
lab package must be the lab build (`--features lab`): the packager writes `edition`, `channel` and `ota_root_hex` into
igneum-app.json and the engine reports a mismatch on its update card (`Packaged::edition_mismatch`), since the other
build would never verify that package's manifest. The two products share %LOCALAPPDATA%\igneum and the engine port on
one PC: a lab install belongs on a lab machine, not beside the public one. Hive has no update manifest (no OTA on a rig).

View file

@ -13,9 +13,12 @@ NODE_OUT="${NODE_OUT:-$REPO/infra/cross/out-v2}"
WORKERS_OUT="${WORKERS_OUT:-$REPO/infra/cross/out-workers}"
OUT="${OUT:-$HERE/build}"
FAKE=0; [[ "${1:-}" == "--fake" ]] && FAKE=1
# PRODUCT=public|lab (packaging/mac/packaged-config.sh): the Hive custom-miner name is the slug (igneum or igneum-lab), so
# the archive, the directory inside it, the config and the log paths are the product's own
. "$REPO/packaging/mac/packaged-config.sh"
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; }
die() { log "ERROR: $*" >&2; exit 1; }
stage="$OUT/igneum"; rm -rf "$stage"; mkdir -p "$stage/bin"
stage="$OUT/$PRODUCT_SLUG"; rm -rf "$stage"; mkdir -p "$stage/bin"
if [[ $FAKE == 1 ]]; then
VERSION="${VERSION:-0.0.0-fake}"
for b in igneumd igneum-miner igneum-worker-cuda igneum-worker-opencl; do printf '#!/bin/sh\necho fake %s "$@"\n' "$b" > "$stage/bin/$b"; chmod +x "$stage/bin/$b"; done
@ -35,17 +38,17 @@ else
fi
[[ -n "$VERSION" ]] || die "no version (VERSION=... or a version.txt with 'igneumd <version>')"
cp "$HERE/h-config.sh" "$HERE/h-run.sh" "$HERE/h-stats.sh" "$HERE/README.md" "$stage/"
sed "s/^CUSTOM_VERSION=.*/CUSTOM_VERSION=$VERSION/" "$HERE/h-manifest.conf" > "$stage/h-manifest.conf"
sed -e "s/^CUSTOM_VERSION=.*/CUSTOM_VERSION=$VERSION/" -e "s/^CUSTOM_NAME=.*/CUSTOM_NAME=$PRODUCT_SLUG/" "$HERE/h-manifest.conf" > "$stage/h-manifest.conf"
chmod +x "$stage"/h-*.sh "$stage"/bin/*
for f in "$stage"/h-*.sh; do bash -n "$f"; done
tgz="$OUT/igneum-hive-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar -C "$OUT" --no-xattrs --no-mac-metadata -czf "$tgz" igneum 2>/dev/null || COPYFILE_DISABLE=1 tar -C "$OUT" -czf "$tgz" igneum
tgz="$OUT/$PRODUCT_SLUG-hive-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar -C "$OUT" --no-xattrs --no-mac-metadata -czf "$tgz" "$PRODUCT_SLUG" 2>/dev/null || COPYFILE_DISABLE=1 tar -C "$OUT" -czf "$tgz" "$PRODUCT_SLUG"
rm -rf "$stage"
sum="$(shasum -a 256 "$tgz" 2>/dev/null | awk '{print $1}' || sha256sum "$tgz" | awk '{print $1}')"
log "wrote $tgz ($(du -h "$tgz" | cut -f1), sha256 $sum)"
cat <<TXT
Flight Sheet (HiveOS): Miner = Custom, then Setup Miner Config:
Installation URL https://<your host>/igneum-hive-$VERSION.tar.gz (publish the archive on the download host)
Installation URL https://<your host>/$PRODUCT_SLUG-hive-$VERSION.tar.gz (publish the archive on the download host)
Miner name igneum
Wallet and worker 0x<your 40-hex payout address>.%WORKER_NAME%
Pool URL grpc://<your node>:26610 or local (the bundled node on the rig)

View file

@ -35,12 +35,13 @@ REBUILD_WORKER="${REBUILD_WORKER:-1}"
ICONS="$ROOT/brand/icons"
BUILD="$HERE/build"
DIST="$HERE/dist"
DMG="$DIST/Igneum-Miner-$VERSION.dmg"
STAGE="$BUILD/dmg"
APP="$STAGE/Igneum Miner.app"
STAMP="$(date -u +%Y%m%d%H%M)"
export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:$PATH"
# PRODUCT=public|lab (packaged-config.sh): the app name, the dmg name, the volume, the bundle id
. "$HERE/packaged-config.sh"
DMG="$DIST/$PRODUCT_FILE-$VERSION.dmg"
STAGE="$BUILD/dmg"
APP="$STAGE/$PRODUCT_NAME.app"
STAMP="$(date -u +%Y%m%d%H%M)"
if [ ! -x "$NODE" ]; then
if [ -x "$ROOT/vendor/igneum-node/target/release/kaspad" ]; then
@ -94,6 +95,9 @@ sed -e "s/VERSION_STAMP/$STAMP/" "$HERE/app/Info.plist" > "$APP/Contents/Info.pl
# the short version is $VERSION whatever the template says (tools/ship-app.mjs keeps the template equal to Cargo.toml;
# the sed that replaced a literal 0.3.0 here stopped matching at 0.3.1 and the bundles said 0.3.2 after that)
plutil -replace CFBundleShortVersionString -string "$VERSION" "$APP/Contents/Info.plist"
plutil -replace CFBundleIdentifier -string "$PRODUCT_BUNDLE_ID" "$APP/Contents/Info.plist"
plutil -replace CFBundleName -string "$PRODUCT_NAME" "$APP/Contents/Info.plist"
plutil -replace CFBundleDisplayName -string "$PRODUCT_NAME" "$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"
@ -130,17 +134,17 @@ v="$("$APP/Contents/MacOS/igneum-app" --version 2>&1 || true)"; case "$v" in "ig
# the rest of the image
cp "$HERE/dmg/README.txt" "$STAGE/README.txt"
cp "$HERE/app/Stop Igneum Miner.command" "$STAGE/Stop Igneum Miner.command"
chmod 755 "$STAGE/Stop Igneum Miner.command"
cp "$HERE/app/Stop Igneum Miner.command" "$STAGE/Stop $PRODUCT_NAME.command"
chmod 755 "$STAGE/Stop $PRODUCT_NAME.command"
rm -f "$DMG"
if command -v dmgbuild >/dev/null 2>&1; then
dmgbuild -s "$HERE/dmg/settings.py" -D "app=$APP" -D "stage=$STAGE" -D "icons=$ICONS" "Igneum Miner" "$DMG"
dmgbuild -s "$HERE/dmg/settings.py" -D "app=$APP" -D "stage=$STAGE" -D "icons=$ICONS" "$PRODUCT_NAME" "$DMG"
else
echo "note: dmgbuild is not installed (pip3 install dmgbuild); building a plain image without icon positions or background"
ln -s /Applications "$STAGE/Applications"
cp "$ICONS/igneum-volume.icns" "$STAGE/.VolumeIcon.icns"
hdiutil create -volname "Igneum Miner" -srcfolder "$STAGE" -ov -format ULFO -fs HFS+ "$DMG" >/dev/null
hdiutil create -volname "$PRODUCT_NAME" -srcfolder "$STAGE" -ov -format ULFO -fs HFS+ "$DMG" >/dev/null
fi
hdiutil verify "$DMG" >/dev/null
echo "built $DMG ($(du -h "$DMG" | cut -f1), $(stat -f %z "$DMG") bytes, version $VERSION build $STAMP)"

View file

@ -22,6 +22,38 @@ LOG_URL="${IGNEUM_INTAKE_URL:-https://igneum-six.vercel.app/api/log}"
LIVE_PAGE="https://igneum.network/live"
DOWNLOAD_PAGE="https://igneum.network/#mine"
DL_HOST="https://dl.igneum.network"
# ---- PRODUCT=public|lab (the founder's word at 20:21 UK, 8 October 2026): ONE variable the packagers read -------------
# public: "Igneum Miner", the release root, channel igneum-2.0-devnet, manifest igneum-app-latest.json.
# lab: "Igneum Miner Lab", the lab signing root (IGNEUM_LAB_PUBLIC_KEY or ~/.config/igneum/lab-signing/lab-signing-key.pub,
# the hex only, never the private key), channel igneum-2.0-devnet-lab, manifest igneum-app-lab-latest.json in the
# same downloads folder, its own bundle id, installer AppId, install folder and package names, so a lab install
# never updates from, or over, the public one. The engine built for the package must be the matching build
# (cargo --features lab for lab): the engine reads the edition and the root hex back from igneum-app.json and
# refuses a mismatch (src/config.rs Packaged::edition_mismatch).
PRODUCT="${PRODUCT:-public}"
# the release root the engine compiles in (app/igneum-app/src/manifest.rs OTA_PUBLIC_KEY_HEX); the lab root is read, never written here
PUBLIC_OTA_ROOT_HEX="b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd"
case "$PRODUCT" in
public)
PRODUCT_NAME="Igneum Miner"; PRODUCT_FILE="Igneum-Miner"; PRODUCT_SLUG="igneum"
PRODUCT_BUNDLE_ID="network.igneum.miner"; PRODUCT_CHANNEL="igneum-2.0-devnet"; PRODUCT_MANIFEST_NAME="igneum-app-latest.json"
PRODUCT_WINDOWS_APPID="{A4C1F0E2-6B8D-4E7A-9F31-2C5D8E7B9A01}"; PRODUCT_PAYLOAD_DIR="igneum-windows-app" ;;
lab)
PRODUCT_NAME="Igneum Miner Lab"; PRODUCT_FILE="Igneum-Miner-Lab"; PRODUCT_SLUG="igneum-lab"
PRODUCT_BUNDLE_ID="network.igneum.miner.lab"; PRODUCT_CHANNEL="igneum-2.0-devnet-lab"; PRODUCT_MANIFEST_NAME="igneum-app-lab-latest.json"
PRODUCT_WINDOWS_APPID="{5E2B7C41-9D3A-4F06-B8E7-61C4A2D9F0B3}"; PRODUCT_PAYLOAD_DIR="igneum-windows-app-lab" ;;
*) echo "PRODUCT must be public or lab (got '$PRODUCT')" >&2; exit 2 ;;
esac
# igneum_ota_root_hex -> the signing root the package's engine must carry: the release root, or the lab root from the
# variable or the key file (64 hex); empty with a note when a lab package has no root to read
igneum_ota_root_hex() {
if [ "$PRODUCT" = "public" ]; then printf '%s' "$PUBLIC_OTA_ROOT_HEX"; return 0; fi
local hex="${IGNEUM_LAB_PUBLIC_KEY:-}"
[ -n "$hex" ] || hex="$(igneum_read_trimmed "${IGNEUM_CONFIG_DIR:-$HOME/.config/igneum}/lab-signing/lab-signing-key.pub")"
printf '%s' "$hex" | grep -qE '^[0-9a-f]{64}$' || { echo "lab package: no lab root (IGNEUM_LAB_PUBLIC_KEY or ~/.config/igneum/lab-signing/lab-signing-key.pub, 64 hex)" >&2; return 1; }
printf '%s' "$hex"
}
# Consensus parameters pinned into the package for the bundled node: igneum-app.json "node_override_params"; the engine
# writes them to <app data>/override-params.json and starts igneumd with --override-params-file. Empty = no override
# file, the node runs the network's defaults. Difficulty v2 (4 Oct 2026): the version that bundles igneumd v2 must
@ -55,7 +87,7 @@ igneum_read_trimmed() {
}
# igneum_manifest_url <token> -> the manifest URL for that downloads folder; nothing for an empty token
igneum_manifest_url() {
[ -n "$1" ] && printf '%s/dl/%s/igneum-app-latest.json' "$DL_HOST" "$1" || true
[ -n "$1" ] && printf '%s/dl/%s/%s' "$DL_HOST" "$1" "$PRODUCT_MANIFEST_NAME" || true
}
# igneum_fingerprint <value> -> the first 8 hex of sha256 over the value, for logs and the app's header (never the value)
igneum_fingerprint() {
@ -64,7 +96,9 @@ igneum_fingerprint() {
# writes the JSON to $1; prints which files were used (names), the lengths and the fingerprints, never the values
write_packaged_config() {
local out="$1" token="" manifest="" key="" key_file="" token_file=""
local out="$1" token="" manifest="" key="" key_file="" token_file="" root=""
root="$(igneum_ota_root_hex)" || return 1
echo "product: $PRODUCT ($PRODUCT_NAME, channel $PRODUCT_CHANNEL, root fingerprint $(igneum_fingerprint "$root"))"
key_file="$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)"
token_file="$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)"
key="$(igneum_read_trimmed "$key_file")"
@ -83,6 +117,10 @@ write_packaged_config() {
{
$override_line
$network_lines
"edition": "$PRODUCT",
"product": "$PRODUCT_NAME",
"channel": "$PRODUCT_CHANNEL",
"ota_root_hex": "$root",
"update_manifest": "$manifest",
"log_intake_url": "$LOG_URL",
"log_intake_key": "$key",
@ -95,6 +133,8 @@ JSON
# ---- self-test: packaging/mac/packaged-config.sh --test (temporary files only; nothing under ~/.config is read) -----
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -euo pipefail
# --lab-probe <out>: writes one config with the environment's PRODUCT (the self-test's child for the lab rows)
if [ "${1:-}" = "--lab-probe" ]; then write_packaged_config "$2"; exit $?; fi
[ "${1:-}" = "--test" ] || { echo "usage: $0 --test (otherwise source this file)" >&2; exit 2; }
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
PY="$(command -v python3 || command -v python)" # the GitHub Windows runner's Git Bash may only have python
@ -146,5 +186,13 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
# 8. the override line
NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}' write_packaged_config "$T/d.json" >/dev/null
check "override params land" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["node_override_params"]["difficulty_v2_activation_daa"])' "$T/d.json")" "123456"
# 9. PRODUCT=public|lab (8 October 2026): the table, the manifest name, the root, the refusal without a lab root
check "public edition lands" "$("$PY" -c 'import json,sys; j=json.load(open(sys.argv[1])); print(j["edition"], j["product"], j["channel"], j["ota_root_hex"])' "$T/a.json")" "public Igneum Miner igneum-2.0-devnet $PUBLIC_OTA_ROOT_HEX"
lab_out="$(PRODUCT=lab IGNEUM_LAB_PUBLIC_KEY="$(printf 'ab%.0s' $(seq 32))" bash "${BASH_SOURCE[0]}" --lab-probe "$T/lab.json" 2>&1)"
check "lab edition lands" "$("$PY" -c 'import json,sys; j=json.load(open(sys.argv[1])); print(j["edition"], j["product"], j["channel"], j["ota_root_hex"][:6])' "$T/lab.json")" "lab Igneum Miner Lab igneum-2.0-devnet-lab ababab"
check "lab manifest has its own name" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["update_manifest"])' "$T/lab.json")" "$DL_HOST/dl/tok2new/igneum-app-lab-latest.json"
check "lab output never carries the root" "$(printf '%s' "$lab_out" | grep -c 'abababab')" "0"
check "lab without a root is refused" "$(PRODUCT=lab IGNEUM_LAB_PUBLIC_KEY= IGNEUM_CONFIG_DIR="$T/cfg" bash "${BASH_SOURCE[0]}" --lab-probe "$T/lab2.json" >/dev/null 2>&1; echo $?)" "1"
check "a bad PRODUCT is refused" "$(PRODUCT=beta bash "${BASH_SOURCE[0]}" --lab-probe "$T/lab3.json" >/dev/null 2>&1; echo $?)" "2"
if [ "$fails" = 0 ]; then echo "packaged-config: all checks passed"; else echo "packaged-config: $fails check(s) failed"; exit 1; fi
fi

View file

@ -7,6 +7,9 @@
# 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]
# [--urgent] the manifest's own urgent flag (2.0.2, 8 October 2026): the apps install at the first
# safe moment, through the finality guard too (manifest::safe_to_apply); for the entry
# that IS the fix for a chain that cannot lock; beside, not instead of, --min-supported
# [--allow-passed-activation] (Horizon polish Q4: an activation height at or below the live DAA is refused otherwise)
# --self-test-height proves the height check on known values (33000 against 201776 refused; 300000 passes) and exits
# [--override '{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":120000}']
@ -48,7 +51,7 @@ 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 VERIFY_ONLY=0 TRIES=12
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 UI_FILE="" NO_UI=0 DRIVERS_FILE="" NO_DRIVERS=0
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 UI_FILE="" NO_UI=0 DRIVERS_FILE="" NO_DRIVERS=0 URGENT=0
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
@ -61,6 +64,7 @@ while [ $# -gt 0 ]; do
--deadline-note) DEADLINE="$2"; shift 2 ;;
--override) OVERRIDE="$2"; shift 2 ;; # consensus.override: the exact JSON object every app writes to its override.json (all height switches, not just the new one)
--min-supported) MIN_SUPPORTED="$2"; shift 2 ;;
--urgent) URGENT=1; shift ;;
--channel) CHANNEL="$2"; shift 2 ;;
--tuning) TUNING_FILE="$2"; shift 2 ;;
--no-tuning) NO_TUNING=1; shift ;;
@ -258,9 +262,10 @@ if [ -n "$ACTIVATION" ]; then
fi
# the version pair: the UI tree this publish builds from stamps its interface version on every new app entry
UI_TREE_VERSION="$(tr -d '[:space:]' < "$ROOT/app/igneum-app/ui/VERSION" 2>/dev/null || true)"
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" "${UI_TREE_VERSION:-}" <<'PY'
[ "$URGENT" = 1 ] && echo "urgent: the apps install this entry at the first safe moment, finality guard included"
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" "${UI_TREE_VERSION:-}" "$URGENT" <<'PY'
import json, sys, datetime
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers, ui_tree = sys.argv[1:15]
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers, ui_tree, urgent = sys.argv[1:16]
override = json.loads(override) if override else None
if override is not None and (not isinstance(override, dict) or not override): raise SystemExit("--override must be a non-empty JSON object")
def entry(s, ui_version=None):
@ -282,6 +287,8 @@ m = {
"notes": notes,
"consensus": {"activation_height": int(activation) if activation else None, "deadline_note": deadline, **({"override": override} if override is not None else {})},
}
if urgent == "1":
m["urgent"] = True
if tuning:
m["tuning"] = json.loads(tuning)
if ui:

View file

@ -11,12 +11,28 @@
#ifndef AppVersion
#define AppVersion "2.0.1"
#endif
#define AppName "Igneum Miner"
; PRODUCT=public|lab (the founder's word, 8 October 2026; packaging/mac/packaged-config.sh is the table): build-installer.ps1
; passes /DProduct=<public|lab>. The lab product has its own AppId, name, install folder and Start Menu group, so a lab
; install never lands on top of a public one and neither updates the other.
#ifndef Product
#define Product "public"
#endif
#if Product == "lab"
#define AppName "Igneum Miner Lab"
#define AppIdGuid "{5E2B7C41-9D3A-4F06-B8E7-61C4A2D9F0B3}"
#define SetupBase "Igneum-Miner-Lab-Setup"
#elif Product == "public"
#define AppName "Igneum Miner"
#define AppIdGuid "{A4C1F0E2-6B8D-4E7A-9F31-2C5D8E7B9A01}"
#define SetupBase "Igneum-Miner-Setup"
#else
#error Product must be public or lab
#endif
#define Publisher "Igneum"
#define Url "https://igneum.network"
[Setup]
AppId={{A4C1F0E2-6B8D-4E7A-9F31-2C5D8E7B9A01}
AppId={{#AppIdGuid}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
@ -34,7 +50,7 @@ DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
LicenseFile=LICENSE.txt
OutputDir=dist
OutputBaseFilename=Igneum-Miner-Setup-{#AppVersion}
OutputBaseFilename={#SetupBase}-{#AppVersion}
SetupIconFile={#ArtDir}\igneum.ico
UninstallDisplayIcon={app}\igneum.ico
UninstallDisplayName={#AppName}
@ -61,7 +77,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
[Messages]
english.WelcomeLabel2=This will install [name/ver] on your computer.%n%nOne window runs your node and the GPU miner, and shows your hash rate, your blocks and the chain. Nothing is bought or sold on this network.
english.FinishedHeadingLabel=Igneum Miner is installed
english.FinishedHeadingLabel={#AppName} is installed
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
@ -77,16 +93,16 @@ Name: "autoupdate"; Description: "Update automatically (installs new versions at
Source: "{#Payload}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion; Excludes: "*.log,*.seeds,*.DS_Store,packs\*,build\*,dist\*"
Source: "{#ArtDir}\igneum.ico"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#Payload}\stop-igneum.ps1"; Flags: dontcopy
Source: "wrappers\Stop Igneum Miner.cmd"; DestDir: "{app}"; Flags: ignoreversion
Source: "wrappers\Stop Igneum Miner.cmd"; DestDir: "{app}"; DestName: "Stop {#AppName}.cmd"; Flags: ignoreversion
Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; igneum-app.exe --launch hands over to "Igneum Miner.exe" (the window) when it is installed, else opens the dashboard in the browser.
Name: "{group}\Igneum Miner"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Start the node and mine"
Name: "{group}\Stop Igneum Miner"; Filename: "{app}\Stop Igneum Miner.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Stop the miner and the node"
Name: "{group}\Igneum Miner logs"; Filename: "{localappdata}\igneum\logs"; IconFilename: "{app}\igneum.ico"; Comment: "The folder the log files land in"
Name: "{group}\Uninstall Igneum Miner"; Filename: "{uninstallexe}"; IconFilename: "{app}\igneum.ico"
Name: "{autodesktop}\Igneum Miner"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Tasks: desktopicon
Name: "{group}\{#AppName}"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Start the node and mine"
Name: "{group}\Stop {#AppName}"; Filename: "{app}\Stop {#AppName}.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Stop the miner and the node"
Name: "{group}\{#AppName} logs"; Filename: "{localappdata}\igneum\logs"; IconFilename: "{app}\igneum.ico"; Comment: "The folder the log files land in"
Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}"; IconFilename: "{app}\igneum.ico"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Tasks: desktopicon
[Run]
; 0.3.22 (the project lead, 7 October 2026: "all the rights need to be done on install, and then on update if anything new"): the app's own
@ -96,7 +112,7 @@ Name: "{autodesktop}\Igneum Miner"; Filename: "{app}\igneum-app.exe"; Parameters
Filename: "{app}\igneum-app.exe"; Parameters: "--rights"; Flags: waituntilterminated runasoriginaluser
; The inbound firewall rule needs an administrator and is asked for once by the app on its first run (declined = the node dials out and mines without it).
; 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
Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; Description: "Start {#AppName} 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.
; /IGNOTA=2 (helpers from 0.3.22 on, MF-11): the helper starts the app itself, polls the new engine and restores the kept
; exe set when nothing answers; this entry stays silent so the two never race for the single-instance window.
@ -170,7 +186,7 @@ end;
// without touching the app; the detached run installs and removes the task at its end. The job's proof is the --version
// wait and the detached log, not this process's exit code (1: "Setup failed to initialize").
const
DetachTask = 'Igneum Miner install';
DetachTask = '{#AppName} install';
function DetachedRun: Boolean;
begin
@ -352,7 +368,7 @@ begin
Exec('powershell.exe', '-NoProfile -ExecutionPolicy Bypass -File "' + StopScript + '" -Install "' + OldDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if (OldDir <> '') and (not WizardSilent) and FileExists(OldDir + '\unins000.exe') then
begin
if MsgBox('Igneum Miner now installs in your user folder, so updates need no administrator prompt.' + #13#10#13#10 +
if MsgBox('{#AppName} now installs in your user folder, so updates need no administrator prompt.' + #13#10#13#10 +
'An older copy is still in ' + OldDir + '. Remove it now? (one administrator prompt; your chain data, address and settings stay)',
mbConfirmation, MB_YESNO) = IDYES then
ShellExec('runas', OldDir + '\unins000.exe', '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);

View file

@ -14,6 +14,8 @@ param(
[string]$Payload = '',
[string]$PayloadUrl = $(if ($env:IGNEUM_PAYLOAD_URL) { $env:IGNEUM_PAYLOAD_URL } else { 'https://dl.igneum.network/igneum-windows-app.zip' }),
[string]$Version = '0.3.0',
# PRODUCT=public|lab (packaging/mac/packaged-config.sh, 8 October 2026): the installer's AppId, AppName, folder and file name
[ValidateSet('public', 'lab')][string]$Product = $(if ($env:PRODUCT) { $env:PRODUCT } else { 'public' }),
[switch]$NoRcedit,
[switch]$NoWinget
)
@ -101,12 +103,15 @@ function Test-PayloadDir([string]$dir) {
}
return $true
}
$productName = $(if ($Product -eq 'lab') { 'Igneum Miner Lab' } else { 'Igneum Miner' })
$payloadName = $(if ($Product -eq 'lab') { 'igneum-windows-app-lab' } else { 'igneum-windows-app' })
Say "product: $Product ($productName; payload folder $payloadName)"
$source = $null
if ($Payload) {
if (-not (Test-PayloadDir $Payload)) { throw "-Payload $Payload does not hold START-IGNEUM.bat, the .ps1 files and both exes." }
$source = (Resolve-Path $Payload).Path
} else {
foreach ($dir in @((Join-Path $here 'igneum-windows-app'), (Join-Path $here '..\igneum-windows-app'), (Join-Path $here 'payload'), (Join-Path $here '..\..\igneum-windows-app'))) {
foreach ($dir in @((Join-Path $here $payloadName), (Join-Path $here "..\$payloadName"), (Join-Path $here 'payload'), (Join-Path $here "..\..\$payloadName"))) {
if (Test-PayloadDir $dir) { $source = (Resolve-Path $dir).Path; break }
}
}
@ -152,7 +157,7 @@ foreach ($exeName in @('igneum-app.exe', 'igneumd.exe', 'igneum-miner.exe')) {
$exe = Join-Path $payloadDir $exeName
if (-not (Test-Path $exe)) { continue }
$info = (Get-Item $exe).VersionInfo
if ($info.ProductName -eq 'Igneum Miner') { Say "$exeName already carries the Igneum Miner version block (relinked on the Mac)"; continue }
if ($info.ProductName -eq $productName) { Say "$exeName already carries the $productName version block (relinked on the Mac)"; continue }
if ($NoRcedit) { Say "$exeName has no version block; -NoRcedit given, so Explorer shows it without the coin icon"; continue }
if (-not $rcedit) {
if ($env:RCEDIT -and (Test-Path $env:RCEDIT)) { $rcedit = $env:RCEDIT }
@ -161,10 +166,10 @@ foreach ($exeName in @('igneum-app.exe', 'igneumd.exe', 'igneum-miner.exe')) {
if (-not (Test-Path $rcedit)) { Say "downloading rcedit from $rceditUrl"; Invoke-WebRequest -Uri $rceditUrl -OutFile $rcedit -UseBasicParsing }
}
}
$desc = switch ($exeName) { 'igneumd.exe' { 'Igneum Miner node' } 'igneum-app.exe' { 'Igneum Miner engine' } default { 'Igneum Miner' } }
$desc = switch ($exeName) { 'igneumd.exe' { "$productName node" } 'igneum-app.exe' { "$productName engine" } default { $productName } }
$rcArgs = @($exe, '--set-icon', (Join-Path $art 'igneum.ico'),
'--set-version-string', 'CompanyName', 'Igneum',
'--set-version-string', 'ProductName', 'Igneum Miner',
'--set-version-string', 'ProductName', $productName,
'--set-version-string', 'FileDescription', $desc,
'--set-version-string', 'OriginalFilename', $exeName,
'--set-version-string', 'LegalCopyright', 'Igneum. Nothing is bought or sold.',
@ -177,7 +182,7 @@ foreach ($exeName in @('igneum-app.exe', 'igneumd.exe', 'igneum-miner.exe')) {
# ---- 6. compile ------------------------------------------------------------------------------------------------------
$iss = Join-Path $here 'Igneum-Miner.iss'
$isccArgs = @('/Qp', "/DPayload=$payloadDir", "/DArtDir=$art", "/DAppVersion=$Version", "/O$dist", $iss)
$isccArgs = @('/Qp', "/DPayload=$payloadDir", "/DArtDir=$art", "/DAppVersion=$Version", "/DProduct=$Product", "/O$dist", $iss)
Say "compiling $iss"
& $iscc @isccArgs
if ($LASTEXITCODE -ne 0) { throw "ISCC failed (exit $LASTEXITCODE)" }

View file

@ -26,15 +26,16 @@ set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-app/Cargo.toml" | head -1)"
OUT="${1:-$HOME/Desktop/igneum-windows-app.zip}"
# the packaged configuration (the manifest URL from the token file, the intake key from the key file, NODE_OVERRIDE_PARAMS)
# and the PRODUCT=public|lab table (the payload folder igneum-windows-app or igneum-windows-app-lab, the product name)
. "$ROOT/packaging/mac/packaged-config.sh"
OUT="${1:-$HOME/Desktop/$PRODUCT_PAYLOAD_DIR.zip}"
case "$OUT" in /*) ;; *) OUT="$PWD/$OUT" ;; esac
mkdir -p "$(dirname "$OUT")"
REL="${IGNEUM_WIN_RELEASE:-$ROOT/vendor/igneum-node/target-integration/x86_64-pc-windows-gnu/release}"
ENGINE="${IGNEUM_APP_EXE:-$ROOT/app/igneum-app/target/x86_64-pc-windows-gnu/release/igneum-app.exe}"
MINGW=/opt/homebrew/opt/mingw-w64/toolchain-x86_64/x86_64-w64-mingw32
STAGE="$HERE/igneum-windows-app"
# the packaged configuration (the manifest URL from the token file, the intake key from the key file, NODE_OVERRIDE_PARAMS)
. "$ROOT/packaging/mac/packaged-config.sh"
STAGE="$HERE/$PRODUCT_PAYLOAD_DIR"
[ -f "$ENGINE" ] || { echo "no $ENGINE: build it first (cd app/igneum-app && cargo build --release --target x86_64-pc-windows-gnu, see proto-cuda/windows-node/cross-build.sh for the environment)" >&2; exit 1; }
[ -f "$REL/igneumd.exe" ] || { echo "no $REL/igneumd.exe; cross-compile the node first" >&2; exit 1; }
@ -128,19 +129,19 @@ cp "$ROOT"/proving/fixtures/*.json "$STAGE/wsl2/fixtures/"
# the fingerprints, never the values)
write_packaged_config "$STAGE/igneum-app.json"
cat > "$STAGE/README.txt" <<TXT
Igneum Miner $VERSION for Windows (payload). Devnet v4. Test network; nothing is bought or sold.
$PRODUCT_NAME $VERSION for Windows (payload). Devnet v4. Test network; nothing is bought or sold.
Nobody from Igneum will ever ask for your seed.
This folder is what the installer packs. To build the installer on a PC:
1. app\\windows\\BUILD-APP.bat builds "Igneum Miner.exe" (the window; needs Visual Studio; optional)
2. packaging\\windows\\BUILD-INSTALLER.bat builds Igneum-Miner-Setup-$VERSION.exe (Inno Setup via winget)
2. packaging\\windows\\BUILD-INSTALLER.bat builds $PRODUCT_FILE-Setup-$VERSION.exe (Inno Setup via winget; PRODUCT=$PRODUCT)
To run without the installer: double-click igneum-app.exe (the dashboard opens in your browser).
TXT
for f in "$STAGE"/*.bat "$STAGE/README.txt" "$STAGE/stop-igneum.ps1" "$STAGE/app/windows/BUILD-APP.bat" "$STAGE/proto-cuda/build.bat" "$STAGE/proto-opencl/build.bat"; do
[ -f "$f" ] && perl -pi -e 's/\r?\n/\r\n/' "$f"
done
rm -f "$OUT"
if command -v zip >/dev/null 2>&1; then (cd "$HERE" && zip -qr "$OUT" "igneum-windows-app" -x '*.DS_Store')
elif command -v 7z >/dev/null 2>&1; then (cd "$HERE" && 7z a -tzip -bso0 -bsp0 "$OUT" "igneum-windows-app" '-xr!.DS_Store')
if command -v zip >/dev/null 2>&1; then (cd "$HERE" && zip -qr "$OUT" "$PRODUCT_PAYLOAD_DIR" -x '*.DS_Store')
elif command -v 7z >/dev/null 2>&1; then (cd "$HERE" && 7z a -tzip -bso0 -bsp0 "$OUT" "$PRODUCT_PAYLOAD_DIR" '-xr!.DS_Store')
else echo "neither zip nor 7z is on PATH" >&2; exit 1; fi
echo "staged $STAGE"
ls -la "$OUT"