diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index cf559c3c4..0d02a1e85 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.3" +version = "0.1.4" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index 882354a57..22357afff 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.3" +version = "0.1.4" edition = "2021" description = "Igneum Wallet engine: keeps the key encrypted, signs in Rust, reads a node, verifies finality certificates itself, and serves the window on 127.0.0.1" license = "MIT" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md index 54db43b5d..ab0ed5f28 100644 --- a/app/igneum-wallet/README.md +++ b/app/igneum-wallet/README.md @@ -13,6 +13,33 @@ packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet | 0.1.1 | 5 Oct 2026 | coin served from `brand/igneum-coin-1024.png`; over-the-air updates v2 (unattended install) | | 0.1.2 | 5 Oct 2026 | Touch ID (macOS) and Windows Hello (Windows, untested): unlock, confirm sends, show the backup, idle lock; the coin and the "reading the chain" line on the balance card; the version in the header and in Settings | | 0.1.3 | 5 Oct 2026 | the update card: one centred card over the window when a new version is ready, with what changed and one tap to install (`ui/update-card.js`) | +| 0.1.4 | 5 Oct 2026 | the lock screen (`ui/lock-screen.js`): the coin on the ember glow, the name, the short address, one control; the Touch ID sheet on a tap, once by itself when the wallet opens (setting), never on an idle lock; locking is a 250 ms transition; the idle lock's minutes in Settings (1, 5, 15, 60, never) | + +## The lock screen (ui/lock-screen.js, index.html #screen-unlock, app.js onLockScreen; 0.1.4) + +The coin large and centred on the obsidian ground with the ember glow (it breathes over 6 s; reduced motion stops +it), "Igneum Wallet" in Unbounded, the wallet's short address in mono, one primary control. With Touch ID (or Windows +Hello) on and the app window as the host: the fingerprint button "Unlock with Touch ID" in ember, its line under it, +and a quiet "Use password" that reveals the password field in place (the control area keeps one height, so nothing +above it moves). Otherwise the password field is the control and the button is absent. Locking (the Lock button, +the menu bar, the idle lock) is a 250 ms transition from the home screen to the lock screen, not a cut. + +When the system sheet appears (`LockScreen.autoPrompt`, the rules in `ui/lock-screen.test.mjs`): + +| Moment | The sheet | +|---|---| +| a tap on the button, or Return or Space on it (it has the focus on arrival, and again when the window comes back) | yes | +| the first arrival after the person opened the app, with "Ask for Touch ID when the wallet opens" on (Settings, default on) | once, 600 ms after the lock screen is fully drawn | +| the idle lock, the Lock button or menu item, the window coming back from the menu bar or the Dock | never | +| after a cancelled or unmatched sheet | never; the line says "Touch ID cancelled, tap to try again" | +| the first run after an over-the-air update (the updater opened the app, not the person) | never | +| the window not visible at arrival (opened at login behind another app) | never | + +After the sheet the line under the button, in our words and never a modal: "Touch ID confirmed, unlocking", "Touch +ID cancelled, tap to try again", "Touch ID did not match, tap to try again", "Enter your password" (the sheet's +Use password button reveals the field), "Touch ID is locked, use your password". A sheet that cannot help (locked, +invalidated, not set up, a browser tab) reveals the password field and focuses it. Escape in the password field +returns to the button. `?bio=touch` on the page shows the Touch ID layout without a host (screenshots). ## Touch ID and Windows Hello (src/engine.rs, igneum-common/src/biometric.rs, app/mac/Biometric.swift, app/windows/biometric.h) @@ -63,9 +90,11 @@ parks it under a one-time token for 120 s; the host shows the prompt ("Turn on T the password with the token (once), seals it and writes the file, then posts `/api/biometric/enrolled`. A password change deletes the sealed file and Settings asks to turn Touch ID on again. Removing the wallet deletes it too. -The idle lock: with Touch ID on and "Lock after 5 minutes idle" on (the default), the engine zeroes the key after 5 -minutes without the window reporting input (mouse, keys; `/api/activity` every 20 s while there is some), never -during a send or with a confirm screen open. The next unlock is the prompt again, or the password. +The idle lock: with Touch ID on, Settings > "Lock when idle" chooses 1, 5 (the default), 15 or 60 minutes, or never +(`settings.json: idle_lock_min`, 0 for never; `idle_lock` mirrors on/off for 0.1.2 and 0.1.3). The engine zeroes +the key after that long without the window reporting input (mouse, keys; `/api/activity` every 20 s while there is +some), never during a send or with a confirm screen open. The next unlock is a tap on the button, or the password; +the sheet is never raised by itself after an idle lock. ### What is stored, and where (macOS) @@ -177,6 +206,15 @@ Files in `~/Library/Application Support/Igneum/wallet/` (Windows: `%LOCALAPPDATA - For real on the project lead's Mac, through LaunchServices with the real window host: 0.1.1 -> 0.1.2 (5 Oct 2026, 09:14Z) and 0.1.2 -> 0.1.3 (5 Oct 2026: check posted 13:23:18Z, staged 13:23:31Z, applied 13:23:40Z, 0.1.3 up 13:23:46Z, 28 s end to end; `/api/state` then showed `updated_from` 0.1.2 and `current`). +- 0.1.3 -> 0.1.4 (5 Oct 2026): check posted 15:48:50Z, downloaded and verified 15:48:52Z (`staging` in `/api/state`), + staged bundle digested 15:49:15Z, helper started 15:49:16Z (the 0.1.3 engine gone), 0.1.4 up 15:49:19Z with + `updated_from` 0.1.3 and `unknown`, `current` at 15:49:45Z after its own check. 0.1.4 raised no Touch ID sheet on + that first run (the updater's relaunch, not the person's: `last_op` stayed empty). +- The download token rotated on 5 Oct 2026 (docs/plans/rotation-phase-2.md): `publish-manifest.sh` writes the NEW + folder (it reads `~/.config/igneum/dl-token`), and a 0.1.4 bundle points there. The installed 0.1.3 polls the OLD + folder's `igneum-wallet-latest.json`, which phase 2 had removed (the plan's section 8b kept only the miner's bridge), + so the 0.1.4 manifest, its signature and the DMG were copied into the OLD folder too, as a bridge, until the 0.1.3 + wallets have moved; the old folder goes on 7 October (section 8f). ### Untested diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs index a9ba0aae0..f5f586dc5 100644 --- a/app/igneum-wallet/src/engine.rs +++ b/app/igneum-wallet/src/engine.rs @@ -29,23 +29,43 @@ pub struct Settings { /// the last block the window scrolled to; display only #[serde(default)] pub display_name: String, - /// lock after IDLE_LOCK_MIN minutes without the window reporting activity; only acts while Touch ID or Windows - /// Hello is enrolled (the password still works) + /// 0.1.2 and 0.1.3 wrote on or off; since 0.1.4 `idle_lock_min` carries the minutes and this mirrors it + /// (minutes > 0), so an older build still reads the file #[serde(default = "yes")] pub idle_lock: bool, + /// lock after this many minutes without the window reporting activity (1, 5, 15, 60; 0 is never); only acts + /// while Touch ID or Windows Hello is enrolled (the password still works). None: a file from before 0.1.4 + #[serde(default)] + pub idle_lock_min: Option, + /// the first arrival after the person opened the app may raise the Touch ID sheet once, by itself + #[serde(default = "yes")] + pub ask_on_open: bool, } fn yes() -> bool { true } +/// The idle lock's choices, minutes; 0 is never (ui/lock-screen.js IDLE_CHOICES). +pub const IDLE_CHOICES: [u64; 5] = [1, 5, 15, 60, 0]; impl Default for Settings { fn default() -> Settings { - Settings { auto_update: true, display_name: String::new(), idle_lock: true } + Settings { auto_update: true, display_name: String::new(), idle_lock: true, idle_lock_min: Some(biometric::IDLE_LOCK_MIN), ask_on_open: true } } } impl Settings { pub fn load(p: &std::path::Path) -> Settings { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() } + /// The idle lock in minutes, 0 for never: the 0.1.4 field, else the older on/off (on = IDLE_LOCK_MIN). + pub fn idle_minutes(&self) -> u64 { + match self.idle_lock_min { + Some(m) => m, + None => if self.idle_lock { biometric::IDLE_LOCK_MIN } else { 0 }, + } + } + pub fn set_idle_minutes(&mut self, min: u64) { + self.idle_lock_min = Some(min); + self.idle_lock = min > 0; + } pub fn save(&self, p: &std::path::Path) { if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); @@ -155,6 +175,8 @@ impl Shared { st.finality.message = "no verified checkpoint yet".into(); st.settings.start_at_login = igneum_common::platform::start_at_login_is_on(APP); st.settings.network = std::env::var("IGNEUM_WALLET_NETWORK").unwrap_or_else(|_| "devnet".into()); + st.settings.idle_lock_min = settings.idle_minutes(); + st.settings.ask_on_open = settings.ask_on_open; st.miner_wallet_file = paths.miner_wallet.display().to_string(); st.miner_wallet_present = paths.miner_wallet.is_file(); st.add_network_page = packaged.add_network_page.clone(); @@ -163,7 +185,7 @@ impl Shared { st.host = igneum_common::platform::host_label(); st.log_dir = paths.log_dir.display().to_string(); st.app_dir = paths.app_dir.display().to_string(); - st.biometric = BiometricState { enrolled: biometric_file_present(&paths.biometric), idle_lock: settings.idle_lock, idle_lock_min: biometric::IDLE_LOCK_MIN, ..Default::default() }; + st.biometric = BiometricState { enrolled: biometric_file_present(&paths.biometric), idle_lock: settings.idle_minutes() > 0, idle_lock_min: settings.idle_minutes(), ..Default::default() }; st.update.status = if packaged.update_manifest.is_empty() { "off".into() } else { "unknown".into() }; Shared { token, @@ -700,33 +722,93 @@ impl Shared { self.event("info", &format!("{} is off", self.what())); Ok(json!({ "ok": true })) } + /// Settings (0.1.2, 0.1.3 pages): on or off; on keeps the minutes already chosen, or the default. pub fn set_idle_lock(&self, on: bool) -> Result { + let min = if on { self.settings.lock().unwrap().idle_minutes().max(1) } else { 0 }; + let min = if on && !IDLE_CHOICES.contains(&min) { biometric::IDLE_LOCK_MIN } else { min }; + self.set_idle_lock_min(min) + } + /// Settings > Lock when idle: 1, 5, 15 or 60 minutes, or 0 for never. + pub fn set_idle_lock_min(&self, min: u64) -> Result { + if !IDLE_CHOICES.contains(&min) { + return Err(format!("the idle lock is 1, 5, 15 or 60 minutes, or 0 for never (not {min})")); + } { let mut s = self.settings.lock().unwrap(); - s.idle_lock = on; + s.set_idle_minutes(min); s.save(&self.paths.settings); } - self.state.lock().unwrap().biometric.idle_lock = on; + let mut st = self.state.lock().unwrap(); + st.biometric.idle_lock = min > 0; + st.biometric.idle_lock_min = min; + st.settings.idle_lock_min = min; + drop(st); + self.touch_activity(); + self.log(&format!("idle lock: {}", if min == 0 { "never".to_string() } else { format!("{min} min") })); + Ok(json!({ "ok": true })) + } + /// Settings > Ask for Touch ID when the wallet opens: the one automatic sheet on the first arrival. + pub fn set_ask_on_open(&self, on: bool) -> Result { + { + let mut s = self.settings.lock().unwrap(); + s.ask_on_open = on; + s.save(&self.paths.settings); + } + self.state.lock().unwrap().settings.ask_on_open = on; + self.log(&format!("ask for {} when the wallet opens: {}", self.what(), if on { "on" } else { "off" })); Ok(json!({ "ok": true })) } /// The window reports a person at it (mouse, keys), every few seconds while active. pub fn touch_activity(&self) { *self.last_activity.lock().unwrap() = Instant::now(); } - /// The idle lock: enrolled, the setting on, unlocked, nothing being sent, and IDLE_LOCK_MIN minutes without - /// activity. Only the engine thread calls this. - pub fn idle_lock_due(&self) -> bool { + /// The idle lock: enrolled, minutes chosen (not never), unlocked, nothing being sent, and that many minutes + /// without activity. Some(minutes) when it is due. Only the engine thread calls this. + pub fn idle_lock_due(&self) -> Option { if !self.unlocked() || self.send_in_flight() || self.creating() { - return false; + return None; } - let st = self.state.lock().unwrap(); - if !(st.biometric.enrolled && st.biometric.idle_lock) { - return false; + if !self.state.lock().unwrap().biometric.enrolled { + return None; + } + let min = self.settings.lock().unwrap().idle_minutes(); + if min == 0 { + return None; } - drop(st); // IGNEUM_WALLET_IDLE_LOCK_S: tests only, a shorter period - let secs = std::env::var("IGNEUM_WALLET_IDLE_LOCK_S").ok().and_then(|v| v.parse().ok()).unwrap_or(biometric::IDLE_LOCK_MIN * 60); - self.last_activity.lock().unwrap().elapsed() >= Duration::from_secs(secs) + let secs = std::env::var("IGNEUM_WALLET_IDLE_LOCK_S").ok().and_then(|v| v.parse().ok()).unwrap_or(min * 60); + if self.last_activity.lock().unwrap().elapsed() >= Duration::from_secs(secs) { Some(min) } else { None } + } +} + +#[cfg(test)] +mod settings_tests { + use super::*; + + #[test] + fn idle_minutes_old_and_new_files() { + // 0.1.2 and 0.1.3 wrote on or off + let on: Settings = serde_json::from_str(r#"{"auto_update":true,"idle_lock":true}"#).unwrap(); + assert_eq!(on.idle_minutes(), 5); + assert!(on.ask_on_open); + let off: Settings = serde_json::from_str(r#"{"idle_lock":false}"#).unwrap(); + assert_eq!(off.idle_minutes(), 0); + // 0.1.4 writes the minutes; they win over the mirror + let fifteen: Settings = serde_json::from_str(r#"{"idle_lock":true,"idle_lock_min":15,"ask_on_open":false}"#).unwrap(); + assert_eq!(fifteen.idle_minutes(), 15); + assert!(!fifteen.ask_on_open); + let never: Settings = serde_json::from_str(r#"{"idle_lock":true,"idle_lock_min":0}"#).unwrap(); + assert_eq!(never.idle_minutes(), 0); + // the default, and what a save writes for an older build to read + let mut d = Settings::default(); + assert_eq!(d.idle_minutes(), 5); + d.set_idle_minutes(0); + assert!(!d.idle_lock); + let back: Settings = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back.idle_minutes(), 0); + d.set_idle_minutes(60); + assert!(d.idle_lock); + assert_eq!(IDLE_CHOICES, [1, 5, 15, 60, 0]); } } @@ -818,9 +900,9 @@ impl Engine { self.last_scan = Instant::now(); self.scan(); } - if self.shared.idle_lock_due() { + if let Some(min) = self.shared.idle_lock_due() { self.shared.lock(); - self.shared.event("info", &format!("locked after {} minutes idle", biometric::IDLE_LOCK_MIN)); + self.shared.event("info", &format!("locked after {} idle", if min == 1 { "1 minute".to_string() } else { format!("{min} minutes") })); } let ctx = crate::updater::Ctx { send_in_flight: self.shared.send_in_flight() || !self.watch.is_empty(), creating: self.shared.creating() }; if let Some(crate::updater::Action::Apply) = self.updater.tick(&self.shared, &ctx) { diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index aad764e89..d302620aa 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -11,6 +11,7 @@ const INDEX: &str = include_str!("../ui/index.html"); const CSS: &str = include_str!("../ui/app.css"); const JS: &str = include_str!("../ui/app.js"); const CARD_JS: &str = include_str!("../ui/update-card.js"); +const LOCK_JS: &str = include_str!("../ui/lock-screen.js"); const MARK: &str = include_str!("../ui/mark.svg"); const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png"); const FONT_MONO_400: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexMono-400.woff2"); @@ -46,6 +47,7 @@ fn handle(mut stream: TcpStream, shared: Arc) { ("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false), ("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false), ("GET", "/update-card.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", CARD_JS.as_bytes(), false), + ("GET", "/lock-screen.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", LOCK_JS.as_bytes(), false), ("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true), ("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true), ("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true), @@ -166,6 +168,12 @@ fn api_post(shared: &Arc, path: &str, body: Value, from_host: bool) -> R if let Some(on) = b("idle_lock") { shared.set_idle_lock(on)?; } + if let Some(min) = body.get("idle_lock_min").and_then(|v| v.as_u64()) { + shared.set_idle_lock_min(min)?; + } + if let Some(on) = b("ask_on_open") { + shared.set_ask_on_open(on)?; + } Ok(json!({ "ok": true })) } // ---- Touch ID / Windows Hello: the page's side and the host's side (HOST_ONLY) ---- diff --git a/app/igneum-wallet/src/state.rs b/app/igneum-wallet/src/state.rs index 5ba6bfe9e..e4d812ab9 100644 --- a/app/igneum-wallet/src/state.rs +++ b/app/igneum-wallet/src/state.rs @@ -64,6 +64,10 @@ pub struct SettingsState { pub start_at_login: bool, pub network: String, pub auto_update: bool, + /// the idle lock in minutes (1, 5, 15, 60), 0 for never; acts only while Touch ID or Windows Hello is enrolled + pub idle_lock_min: u64, + /// the first arrival after the person opened the app may raise the Touch ID sheet once, by itself + pub ask_on_open: bool, } #[derive(Clone, Serialize)] diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index a4db10f98..356b35332 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -397,7 +397,9 @@ body{user-select:text;-webkit-user-select:text} .warn-box b{font-size:14px} .danger-card{border-color:rgba(242,84,27,.35)} .switch{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer} -.switch input{width:18px;height:18px;accent-color:var(--ember)} +/* the miner's switch hides its input behind a .track span the wallet does not render: the box itself shows here + (fixed 5 October 2026: the three wallet switches were invisible, only their words could be clicked) */ +.switch input{position:static;opacity:1;width:18px;height:18px;margin:0;accent-color:var(--ember);flex:0 0 18px} .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bone);color:var(--obsidian);font-family:var(--mono);font-size:13px;padding:10px 16px;border-radius:999px;z-index:30} .step .card{margin-top:12px} #view-settings .step{max-width:760px} @@ -453,3 +455,44 @@ body{user-select:text;-webkit-user-select:text} .upd-actions .btn.primary{min-width:160px} @media (prefers-reduced-motion:reduce){.upd-wrap,.upd-card{animation:none}.upd-ring .arc{transition:none}.upd-mark.busy .arc{animation:none;stroke-dasharray:207.5 69}} @media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:8px}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} + +/* ---- the lock screen (0.1.4, 5 October 2026; ui/lock-screen.js, index.html #screen-unlock) ---- + The coin large and centred on the obsidian ground with the ember glow, the name in Unbounded, the short address + in mono, one primary control. It lies over the main area (absolute, the header stays), enters in ENTER_MS (250 ms) + and the home screen leaves beneath it in the same 250 ms, so locking is a transition, not a cut. The glow breathes + slowly; reduced motion stops it. */ +#screen-unlock{position:absolute;inset:0;z-index:5;max-width:none;margin:0;padding:0 var(--gutter);background:var(--obsidian);overflow:auto;animation:lockin .25s ease both} +body.locking #screen-unlock{display:block} +body.locking #screen-home{animation:lockout .25s ease both;pointer-events:none} +@keyframes lockin{from{opacity:0;transform:scale(1.015)}to{opacity:1;transform:none}} +@keyframes lockout{to{opacity:0;transform:scale(.985);filter:blur(4px)}} +.lock-body{min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:10px;padding:28px 0 36px} +.lock-coin{position:relative;width:220px;height:220px;margin-bottom:22px;flex:0 0 auto} +.lock-coin::before{content:"";position:absolute;inset:-190px;border-radius:50%;background:radial-gradient(circle,rgba(255,179,92,.10) 0,rgba(242,84,27,.05) 40%,rgba(242,84,27,0) 68%);pointer-events:none} +.lock-glow{position:absolute;inset:-96px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.38) 0,rgba(242,84,27,.14) 36%,rgba(242,84,27,0) 66%);animation:lockbreathe 6s ease-in-out infinite;pointer-events:none} +@keyframes lockbreathe{0%,100%{opacity:.75;transform:scale(1)}50%{opacity:1;transform:scale(1.06)}} +.lock-coin .coin{position:relative;width:220px;height:220px;filter:drop-shadow(0 18px 48px rgba(242,84,27,.45))} +.lock-title{font-family:var(--head);font-weight:700;font-size:30px;letter-spacing:-.01em;line-height:1.1;margin-top:4px} +.lock-address{font-size:14px;color:var(--ash);letter-spacing:.06em;margin-top:2px} +/* the control area keeps one height, so "Use password" reveals the field in place and nothing above it moves */ +.lock-controls{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:22px;min-height:120px} +.lock-controls.touch{min-height:200px;justify-content:flex-start} +.lock-touch{display:flex;flex-direction:column;align-items:center;gap:10px} +.lock-btn{min-width:272px;gap:10px} +.lock-line{min-height:20px;font-family:var(--mono);font-size:12px;letter-spacing:.04em;color:var(--ash);text-align:center;max-width:60ch;line-height:1.5;text-wrap:balance} +.lock-line .bio-state{display:inline} +.lock-line .fp-glyph{display:inline-block;vertical-align:-3px;margin-right:6px} +.lock-link{font:inherit;font-size:13px;color:var(--ash);background:transparent;border:0;cursor:pointer;padding:6px 10px;border-radius:6px;text-decoration:underline;text-underline-offset:4px;text-decoration-color:var(--line-2);transition:color .15s ease} +.lock-link:hover{color:var(--bone);text-decoration-color:var(--ash)} +.lock-form{margin-top:0;animation:rise .2s ease} +.lock-form input{min-width:260px} +.lock-err{min-height:0} +.lock-foot{margin-top:18px;opacity:.85} +@media (max-height:720px){.lock-coin,.lock-coin .coin{width:176px;height:176px}.lock-coin{margin-bottom:14px}.lock-glow{inset:-70px}.lock-coin::before{inset:-150px}.lock-title{font-size:26px}.lock-controls{margin-top:14px}.lock-body{padding:16px 0 24px}} +@media (prefers-reduced-motion:reduce){#screen-unlock,body.locking #screen-home,.lock-form{animation:none}.lock-glow{animation:none}} + +/* Settings > Touch ID: the idle lock's choices and "Ask for Touch ID when the wallet opens" */ +.select{font:inherit;font-size:14px;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:7px 12px;min-height:38px;cursor:pointer} +.select:focus{outline:none;border-color:var(--ember)} +.idle-row{margin-top:4px} +.idle-label{font-size:14px} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index e37feb1be..1fdb9660f 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -54,17 +54,72 @@ function bioLine(r, okText) { return `${FP}${esc(t)}`; } function setLine(el, html) { el.innerHTML = html; } -function bioEnrolled() { return !!(state && state.biometric && state.biometric.enrolled); } -let promptPending = false, lastPrompt = 0; +// ?bio=touch: the enrolled look without a host (screenshots); a tap then gets the "needs the app window" line +const forcedBio = new URLSearchParams(location.search).get('bio') === 'touch'; +function hostHere() { return !!bio.host || forcedBio; } + +// ---- the lock screen (ui/lock-screen.js): the sheet on a tap, once by itself on the first arrival ---- +// firstPhase: the first phase this page saw ('unlock' means the app opened locked); autoMem.used: the one chance went +let firstPhase = null, locking = false, autoTimer = 0, pwRevealed = false; +const autoMem = { used: false }; +const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); +function lockLayout() { + const s = forcedBio && state ? Object.assign({}, state, { biometric: Object.assign({}, state.biometric, { enrolled: true, available: true, kind: 'touchid' }) }) : state; + return LockScreen.layout(s, hostHere()); +} +function renderLock() { + const lay = lockLayout(); + $('unlock-address').textContent = lay.address; + $('unlock-touch-text').textContent = 'Unlock with ' + what(); + $('unlock-bio').hidden = !lay.touch; + document.querySelector('.lock-controls').classList.toggle('touch', lay.touch); + const showForm = !lay.touch || pwRevealed; + $('unlock-form').hidden = !showForm; + $('unlock-use-pw').hidden = !lay.touch || pwRevealed; +} +function showPasswordField() { + pwRevealed = true; renderLock(); + $('unlock-pw').focus({ preventScroll: true }); +} +function hidePasswordField() { + if (!lockLayout().touch) return; + pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; renderLock(); + $('unlock-touch').focus({ preventScroll: true }); +} +function focusLock() { + const lay = lockLayout(); + if (lay.touch && !pwRevealed) $('unlock-touch').focus({ preventScroll: true }); else $('unlock-pw').focus({ preventScroll: true }); +} +// the home screen leaves and the lock screen enters together, ENTER_MS (250 ms); then the phase flips +function lockTransition() { + locking = true; document.body.classList.add('locking'); $('main').scrollTop = 0; + pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); renderLock(); + setTimeout(() => { locking = false; document.body.classList.remove('locking'); setPhase('unlock'); onLockScreen('lock'); }, reducedMotion() ? 0 : LockScreen.ENTER_MS); +} +// the lock screen is up: reason is arrival (the page opened on it), lock (idle or by hand) or focus +function onLockScreen(reason) { + clearTimeout(autoTimer); + if (reason !== 'lock') { pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); } + renderLock(); focusLock(); + const lay = lockLayout(); + const d = LockScreen.autoPrompt({ reason, phase: 'unlock', touch: lay.touch, askOnOpen: !!(state.settings && state.settings.ask_on_open), hidden: document.hidden, busy: bio.busy, firstPhase, updatedFrom: state.update && state.update.updated_from }, autoMem); + if (reason === 'arrival') autoMem.used = true; + if (d.prompt) autoTimer = setTimeout(() => { if (state && state.phase === 'unlock' && !document.hidden && !bio.busy && !pwRevealed) unlockWithTouch(); }, (reducedMotion() ? 0 : LockScreen.ENTER_MS) + d.delay); +} async function unlockWithTouch() { if (bio.busy) return; - lastPrompt = Date.now(); + clearTimeout(autoTimer); $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), `${FP}${esc('Waiting for ' + what())}`); const r = await bio.call('unlock'); $('unlock-touch').disabled = false; - setLine($('unlock-bio-note'), bioLine(r, 'confirmed, unlocking')); - if (r.ok) await poll(); else $('unlock-pw').focus(); + const l = LockScreen.line(r, what()); + setLine($('unlock-bio-note'), l.text ? `${FP}${esc(l.text)}` : ''); + if (r.ok) { await poll(); return; } + // a cancel or a miss: the button again, never another sheet by itself; the password when the sheet cannot help + if (l.password) showPasswordField(); else $('unlock-touch').focus({ preventScroll: true }); } +$('unlock-use-pw').onclick = showPasswordField; +$('unlock-pw').addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); hidePasswordField(); } }); // the idle lock: the window tells the engine a person is here, every 20 s while there is input let active = false; ['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(e => document.addEventListener(e, () => { active = true; }, { passive: true })); @@ -91,7 +146,11 @@ function render() { const s = state; const phase = s.phase; const inFlow = ['create', 'import'].includes(document.body.dataset.phase); - if (!inFlow || phase === 'home') { if (lastPhase !== phase) { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') promptPending = true; } } + if (firstPhase === null) firstPhase = phase; + if ((!inFlow || phase === 'home') && lastPhase !== phase && !locking) { + if (lastPhase === 'home' && phase === 'unlock') lockTransition(); + else { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') onLockScreen(lastPhase === null ? 'arrival' : 'lock'); } + } lastPhase = phase; $('ver').textContent = 'v' + s.version; $('btn-settings').hidden = phase !== 'home'; @@ -108,13 +167,8 @@ function render() { if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); renderUpdate(s); renderUpdateCard(s); - // unlock: the fingerprint button when enrolled and the app window is the host; the password form stays - $('unlock-address').textContent = s.display; - const b = s.biometric || {}; - const bioHere = b.enrolled && !!bio.host; - $('unlock-bio').hidden = !bioHere; - $('unlock-touch-text').textContent = 'Unlock with ' + what(); - if (phase === 'unlock' && bioHere && promptPending && !document.hidden && !bio.busy) { promptPending = false; unlockWithTouch(); } + // the lock screen: the fingerprint button when enrolled and the app window is the host, else the password field + if (phase === 'unlock' || locking) renderLock(); // home $('balance').textContent = s.balance_known ? s.balance : '0'; $('balance').classList.toggle('dim', !s.balance_known); @@ -349,12 +403,15 @@ function renderBio(s) { $('version-row').innerHTML = versionLine(s); $('bio-title').textContent = w; $('bio-enrol-text').textContent = 'Turn on ' + w; - $('idle-lock-text').textContent = `Lock after ${b.idle_lock_min || 5} minutes idle`; + $('ask-on-open-text').textContent = `Ask for ${w} when the wallet opens`; + const idleMin = s.settings && s.settings.idle_lock_min != null ? s.settings.idle_lock_min : (b.idle_lock ? (b.idle_lock_min || 5) : 0); + if (document.activeElement !== $('idle-lock')) $('idle-lock').value = String(LockScreen.IDLE_CHOICES.includes(Number(idleMin)) ? idleMin : LockScreen.IDLE_DEFAULT_MIN); + $('idle-lock-note').textContent = Number(idleMin) > 0 ? `The key is cleared after ${LockScreen.idleLabel(idleMin)} without you; ${w} or the password opens it again.` : 'The wallet stays open until you lock it.'; + if (document.activeElement !== $('ask-on-open')) $('ask-on-open').checked = !!(s.settings && s.settings.ask_on_open); $('backup-touch').hidden = !(b.enrolled && bio.host); $('backup-touch').querySelector('span').textContent = 'Show with ' + w; $('export-touch').hidden = !(b.enrolled && bio.host); $('export-touch').querySelector('span').textContent = 'Show with ' + w; $('backup-note-text').textContent = b.enrolled ? `Show the 24 words (or the key) again, with ${w} or the password.` : 'Show the 24 words (or the key) again. Needs the password.'; $('bio-on').hidden = !b.enrolled; $('bio-off').hidden = b.enrolled; - if (document.activeElement !== $('idle-lock')) $('idle-lock').checked = !!b.idle_lock; let off = ''; if (!bio.host) off = w + ' needs the Igneum Wallet app window; this page is open in a browser.'; else if (!b.available) off = b.message || (w === 'Touch ID' ? 'Touch ID is not set up on this Mac.' : 'Windows Hello is not set up on this PC.'); @@ -379,8 +436,9 @@ $('bio-enrol').onclick = async () => { $('bio-enrol').disabled = false; }; $('bio-remove').onclick = async () => { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } }; -$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('unlock-touch').onclick = unlockWithTouch; +$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock_min: Number(ev.target.value) }); await poll(); } catch (e) { toast(e.message); } }; +$('ask-on-open').onchange = async ev => { try { await post('/api/settings', { ask_on_open: ev.target.checked }); } catch (e) { toast(e.message); } }; +$('unlock-touch').onclick = () => unlockWithTouch(); $('pw-change').onclick = async () => { $('pw-err').textContent = ''; try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('password changed'); } @@ -544,8 +602,8 @@ $('remove-go').onclick = async () => { document.addEventListener('visibilitychange', () => { if (document.hidden) return; poll(); - // the window came back (menu bar, Dock): the prompt once more on the unlock screen, not within 10 s of the last - if (state && state.phase === 'unlock' && bioEnrolled() && bio.host && Date.now() - lastPrompt > 10000) promptPending = true; + // the window came back (menu bar, Dock): the button gets the focus so Return unlocks; never a sheet by itself + if (state && state.phase === 'unlock' && !locking) focusLock(); }); new MutationObserver(() => { if (document.body.dataset.view === 'settings') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-view'] }); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index ca1ef5bd4..c931301cc 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -104,22 +104,29 @@ - -
-
-
-

Unlock

-

-
@@ -309,6 +323,7 @@ + diff --git a/app/igneum-wallet/ui/lock-screen.js b/app/igneum-wallet/ui/lock-screen.js new file mode 100644 index 000000000..45356314c --- /dev/null +++ b/app/igneum-wallet/ui/lock-screen.js @@ -0,0 +1,78 @@ +/* The lock screen (pure; lock-screen.test.mjs loads this file): what the screen shows for a wallet state, the line + under the button after the host answered, and when the system Touch ID (or Windows Hello) sheet may be raised + without a tap. 5 October 2026, for 0.1.4. + + The sheet appears when the person taps the button or presses Return or Space on it. One exception: the first + arrival after the person opened the app may raise it once, AUTO_DELAY_MS after the lock screen is fully drawn, + when the setting "Ask for Touch ID when the wallet opens" is on. Never on an idle lock, on a hand lock, when the + window comes back, after a cancelled sheet, or when the updater relaunched the app. */ +const LockScreen = (function () { + 'use strict'; + /** after the lock screen is fully drawn */ + const AUTO_DELAY_MS = 600; + /** the transition from the home screen to the lock screen, and the lock screen's own entry */ + const ENTER_MS = 250; + /** the idle lock's choices in Settings, minutes; 0 is never */ + const IDLE_CHOICES = [1, 5, 15, 60, 0]; + const IDLE_DEFAULT_MIN = 5; + + function idleLabel(min) { + const m = Number(min) || 0; + if (m <= 0) return 'never'; + if (m === 60) return '1 hour'; + if (m % 60 === 0) return (m / 60) + ' hours'; + return m === 1 ? '1 minute' : m + ' minutes'; + } + + function shortAddress(a) { + return a ? a.slice(0, 8) + '…' + a.slice(-6) : ''; + } + + /** What the lock screen shows: the fingerprint button when Touch ID or Windows Hello is enrolled and the app + window is the host (a browser tab cannot raise the sheet); else the password field is the primary control. */ + function layout(s, hostHere) { + const b = (s && s.biometric) || {}; + const touch = !!(b.enrolled && hostHere); + return { touch: touch, passwordPrimary: !touch, address: shortAddress(s && s.display) }; + } + + /** The line under the button after the host answered: {text, cls, password}. cls is '' (ember), 'ok' or + 'wait'; password says the password field should be revealed (the sheet cannot unlock this time). */ + function line(r, name) { + if (!r) return { text: '', cls: '', password: false }; + if (r.ok) return { text: name + ' confirmed, unlocking', cls: 'ok', password: false }; + const c = r.code; + if (c === 'cancelled') return { text: name + ' cancelled, tap to try again', cls: '', password: false }; + if (c === 'failed') return { text: name + ' did not match, tap to try again', cls: '', password: false }; + if (c === 'timeout') return { text: name + ' did not answer, tap to try again', cls: '', password: false }; + if (c === 'fallback') return { text: 'Enter your password', cls: 'wait', password: true }; + if (c === 'locked') return { text: name + ' is locked, use your password', cls: '', password: true }; + if (c === 'invalidated') return { text: name + ' was turned off (a fingerprint changed): use your password, then turn it on again in Settings', cls: '', password: true }; + if (c === 'not_set_up' || c === 'unavailable' || c === 'not_enrolled') return { text: r.message || (name + ' is not set up on this Mac'), cls: '', password: true }; + if (c === 'nohost') return { text: name + ' needs the Igneum Wallet app window', cls: '', password: true }; + if (c === 'engine') return { text: r.message || ('the wallet did not unlock'), cls: '', password: true }; + return { text: r.message || (name + ' did not succeed, tap to try again'), cls: '', password: false }; + } + + /** Whether the sheet may be raised now without a tap. ctx: reason (arrival | lock | focus | cancel), phase, + touch (the button is on screen), askOnOpen (the setting), hidden (the window is not visible), busy (a sheet is + up), firstPhase (the first phase this page saw: 'unlock' means the app opened locked), updatedFrom (set on + the first run after an update: the updater opened the app, not the person). mem.used: the one chance went. */ + function autoPrompt(ctx, mem) { + const c = ctx || {}, m = mem || {}; + const no = function (why) { return { prompt: false, why: why, delay: 0 }; }; + if (c.phase !== 'unlock') return no('not-locked'); + if (!c.touch) return no('no-touch'); + if (m.used) return no('once'); + if (c.reason !== 'arrival') return no(c.reason || 'not-arrival'); + if (c.firstPhase !== 'unlock') return no('not-arrival'); + if (c.updatedFrom) return no('updater'); + if (!c.askOnOpen) return no('setting-off'); + if (c.hidden) return no('hidden'); + if (c.busy) return no('busy'); + return { prompt: true, why: 'arrival', delay: AUTO_DELAY_MS }; + } + + return { AUTO_DELAY_MS: AUTO_DELAY_MS, ENTER_MS: ENTER_MS, IDLE_CHOICES: IDLE_CHOICES, IDLE_DEFAULT_MIN: IDLE_DEFAULT_MIN, idleLabel: idleLabel, shortAddress: shortAddress, layout: layout, line: line, autoPrompt: autoPrompt }; +})(); +if (typeof module === 'object' && module && module.exports) module.exports = LockScreen; diff --git a/app/igneum-wallet/ui/lock-screen.test.mjs b/app/igneum-wallet/ui/lock-screen.test.mjs new file mode 100644 index 000000000..57fffca16 --- /dev/null +++ b/app/igneum-wallet/ui/lock-screen.test.mjs @@ -0,0 +1,93 @@ +// node --test app/igneum-wallet/ui/lock-screen.test.mjs (no dependencies) +// Loads ui/lock-screen.js (plain browser JS, run with `module` defined and no `document`) and checks what the lock +// screen shows, the line under the button after the host answered, and when the system sheet may be raised +// without a tap: once, on the first arrival after the person opened the app, with the setting on; never on an +// idle lock, a hand lock, the window coming back, a cancelled sheet, or the updater's relaunch. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'lock-screen.js'), 'utf8'); +const mod = { exports: {} }; +new Function('module', src)(mod); +const L = mod.exports; +const { layout, line, autoPrompt, idleLabel, shortAddress } = L; + +const ADDR = '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf'; +const st = (over) => ({ phase: 'unlock', display: ADDR, biometric: { enrolled: true, available: true, kind: 'touchid' }, settings: { ask_on_open: true, idle_lock_min: 5 }, update: { updated_from: '' }, ...over }); +const arrival = (over) => ({ reason: 'arrival', phase: 'unlock', touch: true, askOnOpen: true, hidden: false, busy: false, firstPhase: 'unlock', updatedFrom: '', ...over }); + +test('what the screen shows: the button when enrolled in the app window, else the password field first', () => { + const a = layout(st(), true); + assert.equal(a.touch, true); assert.equal(a.passwordPrimary, false); assert.equal(a.address, '0x7E5F45…395Bdf'); + // enrolled, but the page is open in a browser tab: no host to raise the sheet + const b = layout(st(), false); + assert.equal(b.touch, false); assert.equal(b.passwordPrimary, true); + // not enrolled in the app window + const c = layout(st({ biometric: { enrolled: false, available: true } }), true); + assert.equal(c.touch, false); assert.equal(c.passwordPrimary, true); + assert.deepEqual(layout(null, true), { touch: false, passwordPrimary: true, address: '' }); + assert.equal(shortAddress(''), ''); +}); + +test('the line under the button: cancel and no-match keep the button, fallback and lockout reveal the password', () => { + assert.deepEqual(line({ ok: true }, 'Touch ID'), { text: 'Touch ID confirmed, unlocking', cls: 'ok', password: false }); + assert.deepEqual(line({ ok: false, code: 'cancelled' }, 'Touch ID'), { text: 'Touch ID cancelled, tap to try again', cls: '', password: false }); + assert.deepEqual(line({ ok: false, code: 'failed' }, 'Windows Hello'), { text: 'Windows Hello did not match, tap to try again', cls: '', password: false }); + assert.deepEqual(line({ ok: false, code: 'timeout' }, 'Touch ID'), { text: 'Touch ID did not answer, tap to try again', cls: '', password: false }); + assert.deepEqual(line({ ok: false, code: 'fallback' }, 'Touch ID'), { text: 'Enter your password', cls: 'wait', password: true }); + assert.equal(line({ ok: false, code: 'locked' }, 'Touch ID').password, true); + assert.equal(line({ ok: false, code: 'invalidated' }, 'Touch ID').password, true); + assert.equal(line({ ok: false, code: 'nohost' }, 'Touch ID').text, 'Touch ID needs the Igneum Wallet app window'); + assert.equal(line({ ok: false, code: 'not_set_up', message: 'Touch ID is not set up on this Mac. Add a fingerprint in System Settings > Touch ID & Password.' }, 'Touch ID').password, true); + assert.equal(line({ ok: false, code: 'engine', message: 'the engine did not unlock' }, 'Touch ID').text, 'the engine did not unlock'); + assert.deepEqual(line({ ok: false, code: 'weird' }, 'Touch ID'), { text: 'Touch ID did not succeed, tap to try again', cls: '', password: false }); + assert.deepEqual(line(null, 'Touch ID'), { text: '', cls: '', password: false }); + // never a full stop at the end, never a modal: one line in our words + for (const c of ['cancelled', 'failed', 'timeout', 'fallback', 'locked', 'nohost']) assert.ok(!line({ ok: false, code: c }, 'Touch ID').text.endsWith('.'), c); +}); + +test('the one automatic prompt: first arrival after the person opened the app, setting on, 600 ms after drawn', () => { + const mem = { used: false }; + assert.deepEqual(autoPrompt(arrival(), mem), { prompt: true, why: 'arrival', delay: 600 }); + assert.equal(L.AUTO_DELAY_MS, 600); assert.equal(L.ENTER_MS, 250); + // once per launch + assert.equal(autoPrompt(arrival(), { used: true }).why, 'once'); + // the setting off + assert.equal(autoPrompt(arrival({ askOnOpen: false }), mem).why, 'setting-off'); + // the window is not visible at arrival (start at login, the window behind): nothing + assert.equal(autoPrompt(arrival({ hidden: true }), mem).why, 'hidden'); + // a sheet is already up + assert.equal(autoPrompt(arrival({ busy: true }), mem).why, 'busy'); + // not enrolled, or a browser tab: no button, no sheet + assert.equal(autoPrompt(arrival({ touch: false }), mem).why, 'no-touch'); + // not locked + assert.equal(autoPrompt(arrival({ phase: 'home' }), mem).why, 'not-locked'); +}); + +test('never on an idle lock, a hand lock, the window coming back, a cancelled sheet, or the updater relaunch', () => { + const mem = { used: false }; + // the page opened on the home screen (the wallet was unlocked at arrival) and locked later: idle or by hand + assert.equal(autoPrompt(arrival({ reason: 'lock', firstPhase: 'home' }), mem).why, 'lock'); + assert.equal(autoPrompt(arrival({ reason: 'lock' }), mem).why, 'lock'); + // the window came back from the menu bar or the Dock + assert.equal(autoPrompt(arrival({ reason: 'focus' }), mem).why, 'focus'); + // after a cancelled sheet the page waits for a tap + assert.equal(autoPrompt(arrival({ reason: 'cancel' }), mem).why, 'cancel'); + // the first phase the page saw was not the lock screen (welcome, home): a later lock is not an arrival + assert.equal(autoPrompt(arrival({ firstPhase: 'home' }), mem).why, 'not-arrival'); + assert.equal(autoPrompt(arrival({ firstPhase: 'welcome' }), mem).why, 'not-arrival'); + // the updater opened the app (first run after an update), not the person + assert.equal(autoPrompt(arrival({ updatedFrom: '0.1.3' }), mem).why, 'updater'); + // nothing in the context is read as a prompt + assert.equal(autoPrompt(null, null).prompt, false); +}); + +test('the idle lock choices and their labels', () => { + assert.deepEqual(L.IDLE_CHOICES, [1, 5, 15, 60, 0]); + assert.equal(L.IDLE_DEFAULT_MIN, 5); + assert.deepEqual(L.IDLE_CHOICES.map(idleLabel), ['1 minute', '5 minutes', '15 minutes', '1 hour', 'never']); + assert.equal(idleLabel('15'), '15 minutes'); assert.equal(idleLabel(undefined), 'never'); assert.equal(idleLabel(120), '2 hours'); +}); diff --git a/app/windows/wallet-version.h b/app/windows/wallet-version.h index 4b46930cf..7320c3f05 100644 --- a/app/windows/wallet-version.h +++ b/app/windows/wallet-version.h @@ -2,6 +2,6 @@ // Keep it equal to app/igneum-wallet/Cargo.toml and the AppVersion default in packaging/windows/Igneum-Wallet.iss. #ifndef IGNEUM_HOST_VERSION_H #define IGNEUM_HOST_VERSION_H -#define IGNEUM_HOST_VERSION_STR "0.1.2" -#define IGNEUM_HOST_VERSION_RC 0,1,2,0 +#define IGNEUM_HOST_VERSION_STR "0.1.4" +#define IGNEUM_HOST_VERSION_RC 0,1,4,0 #endif diff --git a/packaging/windows/Igneum-Wallet.iss b/packaging/windows/Igneum-Wallet.iss index d14e3a44b..2e3e49db4 100644 --- a/packaging/windows/Igneum-Wallet.iss +++ b/packaging/windows/Igneum-Wallet.iss @@ -9,7 +9,7 @@ #define ArtDir "..\..\brand\icons" #endif #ifndef AppVersion - #define AppVersion "0.1.2" + #define AppVersion "0.1.4" #endif #define AppName "Igneum Wallet" #define Publisher "Igneum"