Igneum Wallet 0.1.4: the lock screen; the Touch ID sheet on a tap, once by itself when the wallet opens; the idle lock's minutes
The lock screen (ui/lock-screen.js, pure, with lock-screen.test.mjs): the coin large on the ember glow, the name in
Unbounded, the short address in mono, one control. Touch ID enrolled in the app window: the fingerprint button, its
line, a quiet "Use password" that reveals the field in place (the control area keeps one height). Otherwise the
password field is the control. Locking is a 250 ms transition from the home screen, not a cut. The glow breathes;
reduced motion stops it.
No automatic sheet: it appears on a tap, Return or Space on the button (focused on arrival and when the window
comes back). One exception: the first arrival after the person opened the app, 600 ms after the lock screen is
drawn, when "Ask for Touch ID when the wallet opens" is on (new setting, default on). Never on an idle lock, a hand
lock, the window coming back, after a cancelled sheet ("Touch ID cancelled, tap to try again", no modal), or on the
updater's relaunch (updated_from set). Escape in the password field returns to the button.
The idle lock: Settings > Lock when idle, 1, 5 (default), 15, 60 minutes or never (settings.json idle_lock_min;
idle_lock mirrors on/off for 0.1.2 and 0.1.3; /api/settings takes idle_lock_min and ask_on_open and refuses other
minutes). The engine's lock event names the minutes.
Also: the three wallet switches in Settings were invisible (the miner's .switch hid the input behind a .track the
wallet never renders); the box shows now. ?bio=touch shows the Touch ID layout without a host, for screenshots.
Tests: node --test ui/lock-screen.test.mjs (5) + update-card.test.mjs (4); cargo test in app/igneum-wallet, 18
passed (the settings migration test is new). Verified in the browser pane at 900x700 and 1280x800 against a scratch
engine. Shipped: Igneum-Wallet-0.1.4.dmg published to the new download folder and bridged into the old one (the
installed 0.1.3 polls the old folder; rotation phase 2 had removed the wallet manifest there); the project lead's wallet went
0.1.3 -> 0.1.4 in 28 s (check 15:48:50Z, 0.1.4 up 15:49:19Z), no sheet on the relaunch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c42bff7cac
commit
6c2eea7dfb
13 changed files with 481 additions and 62 deletions
2
app/igneum-wallet/Cargo.lock
generated
2
app/igneum-wallet/Cargo.lock
generated
|
|
@ -1940,7 +1940,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "igneum-wallet"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"bip32",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u64>,
|
||||
/// 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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
{
|
||||
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<u64> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<Shared>) {
|
|||
("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<Shared>, 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) ----
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -54,17 +54,72 @@ function bioLine(r, okText) {
|
|||
return `<span class="bio-state">${FP}${esc(t)}</span>`;
|
||||
}
|
||||
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'), `<span class="bio-state wait">${FP}${esc('Waiting for ' + what())}</span>`);
|
||||
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 ? `<span class="bio-state ${l.cls}">${FP}${esc(l.text)}</span>` : '');
|
||||
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'] });
|
||||
|
||||
|
|
|
|||
|
|
@ -104,22 +104,29 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- unlock -->
|
||||
<section class="screen" id="screen-unlock">
|
||||
<div class="hero">
|
||||
<div class="coin-wrap"><img class="coin" src="coin.png" width="148" height="148" alt=""></div>
|
||||
<h2>Unlock</h2>
|
||||
<p class="lead mono" id="unlock-address"></p>
|
||||
<div class="bio-row" id="unlock-bio" hidden>
|
||||
<button class="btn primary big fp" id="unlock-touch"><svg width="22" height="22"><use href="#i-fp"></use></svg><span id="unlock-touch-text">Unlock with Touch ID</span></button>
|
||||
<div class="note" id="unlock-bio-note"></div>
|
||||
<!-- the lock screen (lock-screen.js, app.js onLockScreen): the coin on the ember glow, the name, the short
|
||||
address, one primary control. Touch ID enrolled in the app window: the fingerprint button, its line, a quiet
|
||||
"Use password" that reveals the field in place. Otherwise the password field is the control. The system sheet
|
||||
is raised by a tap, Return or Space on the button; once by itself on the first arrival after the person opened
|
||||
the app, when the setting is on. -->
|
||||
<section class="screen lock" id="screen-unlock">
|
||||
<div class="lock-body">
|
||||
<div class="lock-coin"><span class="lock-glow" aria-hidden="true"></span><img class="coin" src="coin.png" width="220" height="220" alt=""></div>
|
||||
<h1 class="lock-title">Igneum Wallet</h1>
|
||||
<p class="lock-address mono" id="unlock-address"></p>
|
||||
<div class="lock-controls">
|
||||
<div class="lock-touch" id="unlock-bio" hidden>
|
||||
<button class="btn primary big fp lock-btn" id="unlock-touch" type="button"><svg width="22" height="22" aria-hidden="true"><use href="#i-fp"></use></svg><span id="unlock-touch-text">Unlock with Touch ID</span></button>
|
||||
<div class="lock-line" id="unlock-bio-note" aria-live="polite"></div>
|
||||
<button class="lock-link" id="unlock-use-pw" type="button">Use password</button>
|
||||
</div>
|
||||
<form id="unlock-form" class="unlock lock-form" hidden>
|
||||
<input type="password" id="unlock-pw" placeholder="Password" autocomplete="current-password" aria-label="Password">
|
||||
<button class="btn primary big" type="submit" id="unlock-submit">Unlock</button>
|
||||
</form>
|
||||
<div class="err lock-err" id="unlock-err"></div>
|
||||
</div>
|
||||
<form id="unlock-form" class="unlock">
|
||||
<input type="password" id="unlock-pw" placeholder="Password" autocomplete="current-password" autofocus>
|
||||
<button class="btn primary big" type="submit" id="unlock-submit">Unlock</button>
|
||||
</form>
|
||||
<div class="err" id="unlock-err"></div>
|
||||
<div class="seedline">Forgot it? Only the 24 words or the key open this wallet again: Settings is locked too.</div>
|
||||
<div class="seedline lock-foot">Forgot it? Only the 24 words or the key open this wallet again.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -225,7 +232,14 @@
|
|||
<p class="note small" id="bio-off-note"></p>
|
||||
</div>
|
||||
<div id="bio-on" hidden>
|
||||
<label class="switch"><input type="checkbox" id="idle-lock"><span id="idle-lock-text">Lock after 5 minutes idle</span></label>
|
||||
<label class="switch"><input type="checkbox" id="ask-on-open"><span id="ask-on-open-text">Ask for Touch ID when the wallet opens</span></label>
|
||||
<div class="inline idle-row"><span class="idle-label">Lock when idle</span><select class="select" id="idle-lock" aria-label="Lock when idle">
|
||||
<option value="1">after 1 minute</option>
|
||||
<option value="5">after 5 minutes</option>
|
||||
<option value="15">after 15 minutes</option>
|
||||
<option value="60">after 1 hour</option>
|
||||
<option value="0">never</option>
|
||||
</select><span class="note small" id="idle-lock-note"></span></div>
|
||||
<div class="inline"><button class="btn small" id="bio-remove">Turn off</button><span class="note" id="bio-on-note"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -309,6 +323,7 @@
|
|||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="update-card.js"></script>
|
||||
<script src="lock-screen.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
78
app/igneum-wallet/ui/lock-screen.js
Normal file
78
app/igneum-wallet/ui/lock-screen.js
Normal file
|
|
@ -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;
|
||||
93
app/igneum-wallet/ui/lock-screen.test.mjs
Normal file
93
app/igneum-wallet/ui/lock-screen.test.mjs
Normal file
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue