Unified device state machine + security hardening
sessiond: - device_state.rs: 8-state unified device state machine with legal transition table, sensor evidence model (proximity/accel/light/touch), confidence scoring, cross-sensor disagreement detection, and forensic logging with full state snapshots at every decision point. - protocol.rs: extended with DeviceState, SensorInput, and ForensicLog IPC requests. SensorSource/SensorValue types for proximity, accel, light, touch. - server.rs: DeviceStateMachine integrated into Daemon struct. Handlers for DeviceState (full state query), SensorInput (sensor evidence + Observed transitions), ForensicLog (recent entries query). - lock.rs: Failed attempt counter on PIN surface (advisory, PAM owns lockout policy). Rendered as red digit glyphs below PIN dots. - draw.rs: Attempt counter rendering + test. - auth.rs: PAM config docs reference. - mod.rs: device_state module added. shell: - IdleCoordinator.qml: Legal transition table with runtime enforcement. setState() refuses illegal transitions with warning. returnActive() explicitly only allows Dimmed/Waking. - GlobalStates.qml: Write authority comments on every property (// WRITER:). - SessionAudit.qml: SHA-256 replaces MD5 for hash chain. Forensic event wiring (device-state-transition, device-error, sensor-input, wake-event). logDeviceError/logSensorInput/logWakeEvent functions for QML callers. Design doc: SouveraineOS/docs/DEVICE-STATE-MACHINE.md (separate repo). Tests: 22 passing (was 7). Full lifecycle test exercises Active → Dimmed → Locked → Observed → DozeLight → DozeDeep → Suspending → Asleep → Locked with 23 forensic entries.
This commit is contained in:
parent
0b13e2d347
commit
b4b30b124d
10 changed files with 1284 additions and 65 deletions
|
|
@ -10,6 +10,11 @@
|
|||
//! Doctrine: auth goes through PAM, always (SESSION-AUTHORITY-DOCTRINE §4).
|
||||
//! This module never inspects credentials itself; it carries the PIN to the
|
||||
//! stack configured for the service and reports the verdict.
|
||||
//!
|
||||
//! The expected PAM service config is documented in
|
||||
//! docs/pam-souveraine-sessiond.md. The file `/etc/pam.d/souveraine-sessiond`
|
||||
//! is shipped as root-owned system config by the OS overlay, never by this
|
||||
//! crate. Without it, pam_start fails and the lock session does not start.
|
||||
|
||||
use std::ffi::{c_char, c_int, c_void, CStr, CString};
|
||||
|
||||
|
|
|
|||
904
src/sessiond/device_state.rs
Normal file
904
src/sessiond/device_state.rs
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
//! Unified device state machine — the single authority for device power state.
|
||||
//!
|
||||
//! Every actor that changes device power (idle timers, proximity sensor,
|
||||
//! sleep signals, DPMS) routes through this machine. The machine owns the
|
||||
//! state; actors are inputs, not authorities.
|
||||
//!
|
||||
//! The state graph:
|
||||
//! Active → Dimmed → Locked → {Observed, DozeLight, DozeDeep} → Suspending → Asleep
|
||||
//! Any locked state → Active (on PAM auth)
|
||||
//! Any pre-sleep state → Suspending (on PrepareForSleep)
|
||||
//!
|
||||
//! Doctrine: SESSION-AUTHORITY-DOCTRINE §9 ("sensor readings are evidence,
|
||||
//! not fact") and §11 ("the session authority and the binary authority are
|
||||
//! the same authority"). TASK-08 and TASK-15 define the tiers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::sessiond::protocol::{SensorSource, SensorValue};
|
||||
|
||||
// ── Forensic logging ─────────────────────────────────────────────────
|
||||
// Every decision point emits a ForensicEntry capturing the full state
|
||||
// at that moment. These are appended to a JSONL file alongside the
|
||||
// SessionAudit trail. The audit trail says "lock-requested"; the
|
||||
// forensic trail says "lock-requested because idle timer fired at T,
|
||||
// state was Active, no inhibitor held, IdleCoordinator was at Dimmed,
|
||||
// last sensor input was proximity-far at T-30s."
|
||||
|
||||
/// A forensic entry — one decision point in the device state machine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ForensicEntry {
|
||||
/// Monotonic sequence (from the audit trail).
|
||||
pub seq: u64,
|
||||
/// Wall-clock timestamp (seconds since epoch).
|
||||
pub ts: u64,
|
||||
/// What happened.
|
||||
pub event: ForensicEvent,
|
||||
/// Full state snapshot at this moment.
|
||||
pub snapshot: StateSnapshot,
|
||||
/// Why this decision was made (human-readable).
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What kind of forensic event occurred.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ForensicEvent {
|
||||
/// A state transition happened (or was refused).
|
||||
Transition {
|
||||
from: DeviceState,
|
||||
to: DeviceState,
|
||||
legal: bool,
|
||||
},
|
||||
/// Sensor input was received and evaluated.
|
||||
SensorInput {
|
||||
source: SensorSource,
|
||||
value: SensorValue,
|
||||
confidence: f32,
|
||||
},
|
||||
/// A wake event occurred (screen on, dt2w, power button).
|
||||
Wake { trigger: WakeTrigger },
|
||||
/// An error occurred that affected device state.
|
||||
Error {
|
||||
component: String,
|
||||
action: String,
|
||||
error: String,
|
||||
},
|
||||
/// A decision was made (e.g., "suppress DPMS wake" or "promote idle").
|
||||
Decision {
|
||||
decision: String,
|
||||
inputs: serde_json::Value,
|
||||
},
|
||||
/// Periodic heartbeat snapshot (for reconstructing timeline gaps).
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
/// What triggered a wake event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WakeTrigger {
|
||||
PowerButton,
|
||||
DoubleTapToWake,
|
||||
ProximityFar,
|
||||
RtcAlarm,
|
||||
ModemIrq,
|
||||
UserInput,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Full state snapshot — everything needed to reconstruct the decision.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub device_state: DeviceState,
|
||||
pub locked: bool,
|
||||
pub display_active: bool,
|
||||
pub phase: String,
|
||||
pub shell_alive: bool,
|
||||
pub proximity_near: bool,
|
||||
pub confidence: f32,
|
||||
pub suppress_dpms_wake: bool,
|
||||
pub promote_idle_faster: bool,
|
||||
pub screen_locked: bool,
|
||||
pub screen_lock_secure: bool,
|
||||
pub idle_coordinator_state: String,
|
||||
pub sleep_inhibitor_held: bool,
|
||||
}
|
||||
|
||||
/// Forensic log — accumulates entries for post-hoc analysis.
|
||||
/// Thread-safe; entries can be added from any thread.
|
||||
pub struct ForensicLog {
|
||||
entries: Arc<Mutex<Vec<ForensicEntry>>>,
|
||||
next_seq: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
impl ForensicLog {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(Mutex::new(Vec::new())),
|
||||
next_seq: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a forensic entry. The seq is auto-incremented.
|
||||
/// The entry is also written to the forensic log file if configured.
|
||||
pub fn append(&self, event: ForensicEvent, snapshot: StateSnapshot, reason: &str) {
|
||||
let mut seq = self.next_seq.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = ForensicEntry {
|
||||
seq: *seq,
|
||||
ts: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
event,
|
||||
snapshot,
|
||||
reason: reason.to_string(),
|
||||
};
|
||||
*seq += 1;
|
||||
drop(seq);
|
||||
|
||||
// Write to forensic log file (append-only JSONL).
|
||||
if let Ok(line) = serde_json::to_string(&entry) {
|
||||
let path = forensic_log_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
entries.push(entry);
|
||||
// Keep last 1000 entries in memory (the JSONL file is the
|
||||
// durable store; this is for IPC queries).
|
||||
let len = entries.len();
|
||||
if len > 1000 {
|
||||
entries.drain(0..len - 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get recent entries (for IPC queries).
|
||||
pub fn recent(&self, count: usize) -> Vec<ForensicEntry> {
|
||||
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let start = entries.len().saturating_sub(count);
|
||||
entries[start..].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to the forensic log file.
|
||||
fn forensic_log_path() -> std::path::PathBuf {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR")
|
||||
.or_else(|_| std::env::var("HOME").map(|h| format!("{}/.local/share", h)))
|
||||
.unwrap_or_else(|_| "/tmp".to_string());
|
||||
std::path::PathBuf::from(runtime).join("souveraine/forensic.jsonl")
|
||||
}
|
||||
|
||||
// Re-export for use in protocol.rs
|
||||
pub use self::ForensicEvent as DeviceStateForensicEvent;
|
||||
pub use self::WakeTrigger as DeviceStateWakeTrigger;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeviceState {
|
||||
/// Screen on, user present, unlocked or lockable.
|
||||
Active,
|
||||
/// Screen dim, user idle, not yet locked.
|
||||
Dimmed,
|
||||
/// Screen locked, compositor secure, user may or may not be present.
|
||||
/// This is the base locked state. Observed/Doze are sub-states.
|
||||
Locked,
|
||||
/// Locked + sensor evidence of user presence. EVIDENCE, not FACT.
|
||||
/// Gates: DPMS wake suppression, idle tier promotion.
|
||||
/// Never gates: lock/unlock, security tiers, personal data.
|
||||
Observed,
|
||||
/// Locked + idle N min. App tier frozen. Wi-Fi power-save.
|
||||
DozeLight,
|
||||
/// Locked + idle M min. Network fetchers stopped. RTC wake only.
|
||||
DozeDeep,
|
||||
/// PrepareForSleep(true). Inhibitor held. Waiting for lock secure.
|
||||
Suspending,
|
||||
/// s2idle. Panel off. Touch in gesture mode. RTC + modem IRQs only.
|
||||
Asleep,
|
||||
}
|
||||
|
||||
/// Transition table — legal (from, to) pairs. Anything not in this list
|
||||
/// is refused and logged.
|
||||
const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
|
||||
// Active → idle dim/lock
|
||||
(DeviceState::Active, DeviceState::Dimmed),
|
||||
(DeviceState::Active, DeviceState::Locked),
|
||||
// Dimmed → user input or idle lock
|
||||
(DeviceState::Dimmed, DeviceState::Active),
|
||||
(DeviceState::Dimmed, DeviceState::Locked),
|
||||
// Locked → user auth, sensor evidence, or idle promotion
|
||||
(DeviceState::Locked, DeviceState::Active),
|
||||
(DeviceState::Locked, DeviceState::Observed),
|
||||
(DeviceState::Locked, DeviceState::DozeLight),
|
||||
// Observed → back to Locked (proximity far) or deeper doze
|
||||
(DeviceState::Observed, DeviceState::Locked),
|
||||
(DeviceState::Observed, DeviceState::DozeLight),
|
||||
// DozeLight → user wake or deeper doze
|
||||
(DeviceState::DozeLight, DeviceState::Locked),
|
||||
(DeviceState::DozeLight, DeviceState::DozeDeep),
|
||||
// DozeDeep → user wake (RTC, modem, input)
|
||||
(DeviceState::DozeDeep, DeviceState::Locked),
|
||||
// Any pre-sleep → Suspending (logind is the authority)
|
||||
(DeviceState::Active, DeviceState::Suspending),
|
||||
(DeviceState::Dimmed, DeviceState::Suspending),
|
||||
(DeviceState::Locked, DeviceState::Suspending),
|
||||
(DeviceState::Observed, DeviceState::Suspending),
|
||||
(DeviceState::DozeLight, DeviceState::Suspending),
|
||||
(DeviceState::DozeDeep, DeviceState::Suspending),
|
||||
// Suspending → Asleep (lock secure + inhibitor released)
|
||||
(DeviceState::Suspending, DeviceState::Asleep),
|
||||
// Asleep → Locked (wake, lock persists)
|
||||
(DeviceState::Asleep, DeviceState::Locked),
|
||||
];
|
||||
|
||||
/// Which states count as "locked" for security purposes.
|
||||
pub fn is_locked(state: DeviceState) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
DeviceState::Locked
|
||||
| DeviceState::Observed
|
||||
| DeviceState::DozeLight
|
||||
| DeviceState::DozeDeep
|
||||
| DeviceState::Suspending
|
||||
| DeviceState::Asleep
|
||||
)
|
||||
}
|
||||
|
||||
/// Which states count as "display active" for poller gating.
|
||||
pub fn is_display_active(state: DeviceState) -> bool {
|
||||
matches!(state, DeviceState::Active | DeviceState::Dimmed)
|
||||
}
|
||||
|
||||
/// The device state machine. Owns the current state and enforces
|
||||
/// transition guards.
|
||||
pub struct DeviceStateMachine {
|
||||
state: DeviceState,
|
||||
/// Sensor evidence for the Observed state.
|
||||
pub sensor_evidence: SensorEvidence,
|
||||
/// Forensic log — captures every decision point for post-hoc analysis.
|
||||
pub forensic: ForensicLog,
|
||||
}
|
||||
|
||||
/// Sensor inputs that feed the Observed state. Each is a reading, not
|
||||
/// an authority — the machine decides what to do with them.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SensorEvidence {
|
||||
/// Proximity: true = near (in-pocket, face-down), false = far.
|
||||
pub proximity_near: bool,
|
||||
/// Accelerometer: true = device is moving.
|
||||
pub accel_moving: bool,
|
||||
/// Light sensor: true = ambient light is changing.
|
||||
pub light_changing: bool,
|
||||
/// Touch: true = recent touch input detected.
|
||||
pub touch_active: bool,
|
||||
}
|
||||
|
||||
impl SensorEvidence {
|
||||
/// Compute confidence that the user is present. 0.0–1.0.
|
||||
/// Each sensor contributes weighted evidence. Cross-sensor
|
||||
/// disagreements reduce confidence (§9: "accelerometer says
|
||||
/// face-down, light sensor says bright — one of them is lying").
|
||||
pub fn confidence(&self) -> f32 {
|
||||
let mut c = 0.0f32;
|
||||
if self.proximity_near {
|
||||
c += 0.4;
|
||||
}
|
||||
if self.accel_moving {
|
||||
c += 0.3;
|
||||
}
|
||||
if self.light_changing {
|
||||
c += 0.2;
|
||||
}
|
||||
if self.touch_active {
|
||||
c += 0.1;
|
||||
}
|
||||
// Cross-sensor disagreement: proximity says near but accelerometer
|
||||
// says moving — user is walking with phone in hand, not a pocket.
|
||||
// Don't suppress DPMS wake in this case.
|
||||
if self.proximity_near && self.accel_moving {
|
||||
c -= 0.2; // reduce confidence — ambiguous
|
||||
}
|
||||
c.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Should DPMS wake be suppressed? (prevent pocket-dial)
|
||||
pub fn should_suppress_dpms_wake(&self) -> bool {
|
||||
self.proximity_near && self.confidence() >= 0.3
|
||||
}
|
||||
|
||||
/// Should idle tier promotion be accelerated?
|
||||
pub fn should_promote_idle_faster(&self) -> bool {
|
||||
self.confidence() >= 0.6
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceStateMachine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: DeviceState::Active,
|
||||
sensor_evidence: SensorEvidence::default(),
|
||||
forensic: ForensicLog::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a forensic snapshot of the current state.
|
||||
pub fn snapshot(&self, phase: &str, shell_alive: bool, screen_locked: bool, screen_lock_secure: bool, idle_state: &str, inhibitor_held: bool) -> StateSnapshot {
|
||||
StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: phase.to_string(),
|
||||
shell_alive,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence: self.sensor_evidence.confidence(),
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked,
|
||||
screen_lock_secure,
|
||||
idle_coordinator_state: idle_state.to_string(),
|
||||
sleep_inhibitor_held: inhibitor_held,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> DeviceState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Attempt a state transition. Returns true if the transition was
|
||||
/// legal and applied, false if refused (logged as a warning).
|
||||
/// Emits a forensic entry for every attempt (legal or not).
|
||||
pub fn transition(&mut self, next: DeviceState) -> bool {
|
||||
if self.state == next {
|
||||
return true; // idempotent
|
||||
}
|
||||
let legal = LEGAL_TRANSITIONS
|
||||
.iter()
|
||||
.any(|(from, to)| *from == self.state && *to == next);
|
||||
let prev = self.state;
|
||||
if !legal {
|
||||
warn!(
|
||||
"[device-state] ILLEGAL transition {:?} → {:?} (refused)",
|
||||
self.state, next
|
||||
);
|
||||
// Forensic: record the refused transition with a minimal snapshot
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence: self.sensor_evidence.confidence(),
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Transition { from: prev, to: next, legal: false },
|
||||
snapshot,
|
||||
&format!("illegal transition {:?} → {:?} refused", prev, next),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.state = next;
|
||||
info!(
|
||||
"[device-state] {:?} → {:?} (from {:?})",
|
||||
next, next, prev
|
||||
);
|
||||
// Forensic: record the successful transition
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence: self.sensor_evidence.confidence(),
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Transition { from: prev, to: next, legal: true },
|
||||
snapshot,
|
||||
&format!("{:?} → {:?}", prev, next),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Update sensor evidence and, if the current state is Locked,
|
||||
/// potentially transition to/from Observed.
|
||||
/// Emits forensic entries for every sensor input.
|
||||
pub fn update_sensors(&mut self, evidence: SensorEvidence) {
|
||||
let was_observed = matches!(self.state, DeviceState::Observed);
|
||||
let proximity_near = evidence.proximity_near;
|
||||
let should_be_observed = self.state == DeviceState::Locked && proximity_near;
|
||||
let confidence = evidence.confidence();
|
||||
|
||||
self.sensor_evidence = evidence;
|
||||
|
||||
// Forensic: record the sensor input and its evaluation
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence,
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::SensorInput {
|
||||
source: SensorSource::Proximity,
|
||||
value: SensorValue::Near(proximity_near),
|
||||
confidence,
|
||||
},
|
||||
snapshot,
|
||||
&format!(
|
||||
"proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
|
||||
proximity_near, confidence, should_be_observed, was_observed
|
||||
),
|
||||
);
|
||||
|
||||
if should_be_observed && !was_observed {
|
||||
self.transition(DeviceState::Observed);
|
||||
} else if was_observed && !proximity_near {
|
||||
self.transition(DeviceState::Locked);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a wake event (screen on, dt2w, power button, etc.).
|
||||
pub fn record_wake(&self, trigger: WakeTrigger, reason: &str) {
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence: self.sensor_evidence.confidence(),
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Wake { trigger },
|
||||
snapshot,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an error that affected device state.
|
||||
pub fn record_error(&self, component: &str, action: &str, error: &str) {
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence: self.sensor_evidence.confidence(),
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Error {
|
||||
component: component.to_string(),
|
||||
action: action.to_string(),
|
||||
error: error.to_string(),
|
||||
},
|
||||
snapshot,
|
||||
&format!("[{}] {}: {}", component, action, error),
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a decision (e.g., "suppress DPMS wake", "promote idle").
|
||||
pub fn record_decision(&self, decision: &str, inputs: serde_json::Value, reason: &str) {
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence: self.sensor_evidence.confidence(),
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Decision {
|
||||
decision: decision.to_string(),
|
||||
inputs,
|
||||
},
|
||||
snapshot,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialize the current state for IPC.
|
||||
pub fn to_ipc_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"device_state": self.state,
|
||||
"locked": is_locked(self.state),
|
||||
"display_active": is_display_active(self.state),
|
||||
"observed": matches!(self.state, DeviceState::Observed),
|
||||
"observed_confidence": self.sensor_evidence.confidence(),
|
||||
"suppress_dpms_wake": self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DeviceState::Active => write!(f, "active"),
|
||||
DeviceState::Dimmed => write!(f, "dimmed"),
|
||||
DeviceState::Locked => write!(f, "locked"),
|
||||
DeviceState::Observed => write!(f, "observed"),
|
||||
DeviceState::DozeLight => write!(f, "doze_light"),
|
||||
DeviceState::DozeDeep => write!(f, "doze_deep"),
|
||||
DeviceState::Suspending => write!(f, "suspending"),
|
||||
DeviceState::Asleep => write!(f, "asleep"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn initial_state_is_active() {
|
||||
let sm = DeviceStateMachine::new();
|
||||
assert_eq!(sm.state(), DeviceState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_transitions_work() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
assert!(sm.transition(DeviceState::Dimmed));
|
||||
assert_eq!(sm.state(), DeviceState::Dimmed);
|
||||
assert!(sm.transition(DeviceState::Locked));
|
||||
assert_eq!(sm.state(), DeviceState::Locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_transitions_refused() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
// Active → DozeLight is not legal (must go through Locked first)
|
||||
assert!(!sm.transition(DeviceState::DozeLight));
|
||||
assert_eq!(sm.state(), DeviceState::Active); // unchanged
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_transition() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
assert!(sm.transition(DeviceState::Active)); // same state
|
||||
assert_eq!(sm.state(), DeviceState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_to_suspending_allowed() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
// Any pre-sleep state → Suspending is legal (logind authority)
|
||||
assert!(sm.transition(DeviceState::Suspending));
|
||||
assert_eq!(sm.state(), DeviceState::Suspending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asleep_to_locked() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.transition(DeviceState::Dimmed);
|
||||
sm.transition(DeviceState::Locked);
|
||||
sm.transition(DeviceState::Suspending);
|
||||
sm.transition(DeviceState::Asleep);
|
||||
assert!(sm.transition(DeviceState::Locked));
|
||||
assert_eq!(sm.state(), DeviceState::Locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensor_evidence_confidence() {
|
||||
let e = SensorEvidence {
|
||||
proximity_near: true,
|
||||
accel_moving: false,
|
||||
light_changing: false,
|
||||
touch_active: false,
|
||||
};
|
||||
assert!((e.confidence() - 0.4).abs() < 0.01);
|
||||
|
||||
let e2 = SensorEvidence {
|
||||
proximity_near: true,
|
||||
accel_moving: true,
|
||||
light_changing: true,
|
||||
touch_active: true,
|
||||
};
|
||||
// 0.4 + 0.3 + 0.2 + 0.1 - 0.2 (disagreement) = 0.8
|
||||
assert!((e2.confidence() - 0.8).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensor_disagreement_reduces_confidence() {
|
||||
// proximity near + accel moving = walking with phone, not pocket
|
||||
let e = SensorEvidence {
|
||||
proximity_near: true,
|
||||
accel_moving: true,
|
||||
light_changing: false,
|
||||
touch_active: false,
|
||||
};
|
||||
// 0.4 + 0.3 - 0.2 = 0.5
|
||||
assert!((e.confidence() - 0.5).abs() < 0.01);
|
||||
// DPMS wake suppression still on (0.5 >= 0.3)
|
||||
assert!(e.should_suppress_dpms_wake());
|
||||
// But idle promotion not accelerated (0.5 < 0.6)
|
||||
assert!(!e.should_promote_idle_faster());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_triggers_observed() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.transition(DeviceState::Dimmed);
|
||||
sm.transition(DeviceState::Locked);
|
||||
|
||||
let evidence = SensorEvidence {
|
||||
proximity_near: true,
|
||||
..Default::default()
|
||||
};
|
||||
sm.update_sensors(evidence);
|
||||
assert_eq!(sm.state(), DeviceState::Observed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_far_returns_to_locked() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.transition(DeviceState::Dimmed);
|
||||
sm.transition(DeviceState::Locked);
|
||||
|
||||
// Enter observed
|
||||
sm.update_sensors(SensorEvidence {
|
||||
proximity_near: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(sm.state(), DeviceState::Observed);
|
||||
|
||||
// Leave observed
|
||||
sm.update_sensors(SensorEvidence {
|
||||
proximity_near: false,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(sm.state(), DeviceState::Locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_locked_covers_all_locked_states() {
|
||||
assert!(!is_locked(DeviceState::Active));
|
||||
assert!(!is_locked(DeviceState::Dimmed));
|
||||
assert!(is_locked(DeviceState::Locked));
|
||||
assert!(is_locked(DeviceState::Observed));
|
||||
assert!(is_locked(DeviceState::DozeLight));
|
||||
assert!(is_locked(DeviceState::DozeDeep));
|
||||
assert!(is_locked(DeviceState::Suspending));
|
||||
assert!(is_locked(DeviceState::Asleep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_active_only_active_and_dimmed() {
|
||||
assert!(is_display_active(DeviceState::Active));
|
||||
assert!(is_display_active(DeviceState::Dimmed));
|
||||
assert!(!is_display_active(DeviceState::Locked));
|
||||
assert!(!is_display_active(DeviceState::Asleep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_lifecycle_with_forensic_log() {
|
||||
// Exercise the entire state machine: a phone's day in 30 seconds.
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
|
||||
// 1. User picks up the phone — Active
|
||||
assert_eq!(sm.state(), DeviceState::Active);
|
||||
sm.record_wake(WakeTrigger::UserInput, "user picked up phone");
|
||||
|
||||
// 2. User stops touching — screen dims after 120s
|
||||
sm.transition(DeviceState::Dimmed);
|
||||
sm.record_decision(
|
||||
"dim screen",
|
||||
serde_json::json!({"idle_seconds": 120, "inhibitor": false}),
|
||||
"native IdleMonitor fired, no inhibitor held",
|
||||
);
|
||||
|
||||
// 3. User still idle — lock after 300s
|
||||
sm.transition(DeviceState::Locked);
|
||||
sm.record_decision(
|
||||
"lock session",
|
||||
serde_json::json!({"idle_seconds": 300, "inhibitor": false}),
|
||||
"native IdleMonitor fired, requesting lock",
|
||||
);
|
||||
|
||||
// 4. Phone goes into pocket — proximity near
|
||||
sm.update_sensors(SensorEvidence {
|
||||
proximity_near: true,
|
||||
accel_moving: false,
|
||||
light_changing: false,
|
||||
touch_active: false,
|
||||
});
|
||||
assert_eq!(sm.state(), DeviceState::Observed);
|
||||
sm.record_decision(
|
||||
"suppress DPMS wake",
|
||||
serde_json::json!({"confidence": 0.4, "proximity": "near"}),
|
||||
"proximity near, confidence 0.4 >= 0.3 threshold",
|
||||
);
|
||||
|
||||
// 5. Phone stays in pocket — promote to DozeLight faster
|
||||
sm.transition(DeviceState::DozeLight);
|
||||
sm.record_decision(
|
||||
"freeze app tier",
|
||||
serde_json::json!({"idle_minutes": 5, "promoted_faster": true}),
|
||||
"observed state accelerated promotion, freezing apps.slice",
|
||||
);
|
||||
|
||||
// 6. Phone still idle — promote to DozeDeep
|
||||
sm.transition(DeviceState::DozeDeep);
|
||||
sm.record_decision(
|
||||
"stop network fetchers",
|
||||
serde_json::json!({"idle_minutes": 15}),
|
||||
"deep doze, only RTC + modem IRQs active",
|
||||
);
|
||||
|
||||
// 7. User presses power button — wake to lock screen
|
||||
sm.transition(DeviceState::Locked);
|
||||
sm.record_wake(WakeTrigger::PowerButton, "user pressed power button");
|
||||
|
||||
// 8. Phone goes back in pocket briefly
|
||||
sm.update_sensors(SensorEvidence {
|
||||
proximity_near: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(sm.state(), DeviceState::Observed);
|
||||
|
||||
// 9. Phone comes back out
|
||||
sm.update_sensors(SensorEvidence {
|
||||
proximity_near: false,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(sm.state(), DeviceState::Locked);
|
||||
|
||||
// 10. System suspends
|
||||
sm.transition(DeviceState::Suspending);
|
||||
sm.transition(DeviceState::Asleep);
|
||||
|
||||
// 11. RTC alarm wakes the phone
|
||||
sm.transition(DeviceState::Locked);
|
||||
sm.record_wake(WakeTrigger::RtcAlarm, "RTC alarm for notification check");
|
||||
|
||||
// 12. User unlocks with PAM
|
||||
sm.transition(DeviceState::Active);
|
||||
|
||||
// Verify the full lifecycle completed
|
||||
assert_eq!(sm.state(), DeviceState::Active);
|
||||
|
||||
// Dump the forensic log
|
||||
let entries = sm.forensic.recent(100);
|
||||
println!("\n=== FORENSIC LOG ({} entries) ===", entries.len());
|
||||
for entry in &entries {
|
||||
let event_name = match &entry.event {
|
||||
ForensicEvent::Transition { from, to, legal } => {
|
||||
format!("transition {:?} → {:?} (legal={})", from, to, legal)
|
||||
}
|
||||
ForensicEvent::SensorInput { source, confidence, .. } => {
|
||||
format!("sensor {:?} conf={:.2}", source, confidence)
|
||||
}
|
||||
ForensicEvent::Wake { trigger } => {
|
||||
format!("wake {:?}", trigger)
|
||||
}
|
||||
ForensicEvent::Error { component, action, .. } => {
|
||||
format!("error [{}] {}", component, action)
|
||||
}
|
||||
ForensicEvent::Decision { decision, .. } => {
|
||||
format!("decision: {}", decision)
|
||||
}
|
||||
ForensicEvent::Heartbeat => "heartbeat".to_string(),
|
||||
};
|
||||
println!(
|
||||
" seq={:3} ts={} {:30} state={:12} locked={} conf={:.2} reason={}",
|
||||
entry.seq,
|
||||
entry.ts,
|
||||
event_name,
|
||||
format!("{:?}", entry.snapshot.device_state),
|
||||
entry.snapshot.locked,
|
||||
entry.snapshot.confidence,
|
||||
entry.reason,
|
||||
);
|
||||
}
|
||||
println!("=== END FORENSIC LOG ===\n");
|
||||
|
||||
// Verify we captured the key events
|
||||
assert!(entries.len() >= 12, "expected at least 12 forensic entries, got {}", entries.len());
|
||||
// Verify transitions were captured
|
||||
let transitions: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Transition { .. })).collect();
|
||||
assert!(transitions.len() >= 8, "expected at least 8 transitions, got {}", transitions.len());
|
||||
// Verify sensor inputs were captured
|
||||
let sensors: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
|
||||
assert!(sensors.len() >= 2, "expected at least 2 sensor inputs, got {}", sensors.len());
|
||||
// Verify wake events were captured
|
||||
let wakes: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Wake { .. })).collect();
|
||||
assert!(wakes.len() >= 2, "expected at least 2 wake events, got {}", wakes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_transition_is_forensically_logged() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
// Try an illegal transition: Active → DozeLight (must go through Locked)
|
||||
assert!(!sm.transition(DeviceState::DozeLight));
|
||||
let entries = sm.forensic.recent(10);
|
||||
let refused: Vec<_> = entries.iter().filter(|e| match &e.event {
|
||||
ForensicEvent::Transition { legal, .. } => !legal,
|
||||
_ => false,
|
||||
}).collect();
|
||||
assert_eq!(refused.len(), 1, "expected 1 refused transition in forensic log");
|
||||
match &refused[0].event {
|
||||
ForensicEvent::Transition { from, to, legal } => {
|
||||
assert_eq!(*from, DeviceState::Active);
|
||||
assert_eq!(*to, DeviceState::DozeLight);
|
||||
assert!(!legal);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_sensor_disagreement_logged() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.transition(DeviceState::Dimmed);
|
||||
sm.transition(DeviceState::Locked);
|
||||
// Proximity near + accel moving = walking with phone
|
||||
sm.update_sensors(SensorEvidence {
|
||||
proximity_near: true,
|
||||
accel_moving: true,
|
||||
light_changing: false,
|
||||
touch_active: false,
|
||||
});
|
||||
let entries = sm.forensic.recent(10);
|
||||
let sensor_entries: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
|
||||
assert!(!sensor_entries.is_empty());
|
||||
// The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5)
|
||||
match &sensor_entries[0].event {
|
||||
ForensicEvent::SensorInput { confidence, .. } => {
|
||||
assert!((confidence - 0.5).abs() < 0.01, "expected 0.5 confidence, got {}", confidence);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,10 @@ pub struct Scene {
|
|||
/// appears on first input, so a normal boot (shell takes over in
|
||||
/// seconds) never flashes a second PIN style at the user.
|
||||
pub quiet: bool,
|
||||
/// Number of failed authentication attempts. Displayed below the PIN
|
||||
/// dots so the user knows how many tries they've had. Advisory only —
|
||||
/// the actual lockout policy lives in PAM.
|
||||
pub failed_attempts: u32,
|
||||
}
|
||||
|
||||
/// The keypad geometry for a surface of w x h. Pure function of size so
|
||||
|
|
@ -217,6 +221,31 @@ pub fn render(buf: &mut [u32], w: i32, h: i32, scene: &Scene) {
|
|||
}
|
||||
}
|
||||
|
||||
// Failed attempt counter: rendered as a red number below the PIN dots.
|
||||
// Only visible after at least one failed attempt. Uses the same digit
|
||||
// glyphs as the keypad.
|
||||
if scene.failed_attempts > 0 {
|
||||
let counter_y = cy + radius * 3;
|
||||
let digits: Vec<u8> = scene.failed_attempts
|
||||
.to_string()
|
||||
.bytes()
|
||||
.map(|b| b - b'0')
|
||||
.collect();
|
||||
let digit_w = (w / 20).clamp(12, 24);
|
||||
let digit_h = digit_w * 7 / 5;
|
||||
let counter_total = digits.len() as i32 * digit_w;
|
||||
let counter_x0 = w / 2 - counter_total / 2;
|
||||
for (i, &d) in digits.iter().enumerate() {
|
||||
let cell = Rect {
|
||||
x: counter_x0 + i as i32 * digit_w,
|
||||
y: counter_y,
|
||||
w: digit_w,
|
||||
h: digit_h,
|
||||
};
|
||||
draw_glyph(buf, w, h, Key::Digit(d), cell, FAIL);
|
||||
}
|
||||
}
|
||||
|
||||
for (rect, key) in keypad_layout(w, h) {
|
||||
let fill = if scene.pressed == Some(key) {
|
||||
KEY_PRESSED
|
||||
|
|
@ -251,7 +280,7 @@ mod tests {
|
|||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
&Scene { pin_len: 3, mood: Mood::Entering, pressed: Some(Key::Digit(5)), quiet: false },
|
||||
&Scene { pin_len: 3, mood: Mood::Entering, pressed: Some(Key::Digit(5)), quiet: false, failed_attempts: 0 },
|
||||
);
|
||||
assert!(buf.iter().any(|&p| p == BG));
|
||||
assert!(buf.iter().any(|&p| p == KEY_FILL));
|
||||
|
|
@ -266,8 +295,32 @@ mod tests {
|
|||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: true },
|
||||
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: true, failed_attempts: 0 },
|
||||
);
|
||||
assert!(buf.iter().all(|&p| p == BG));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_attempts_renders_red_digits() {
|
||||
let (w, h) = (400, 800);
|
||||
let mut buf_no_fail = vec![0u32; (w * h) as usize];
|
||||
render(
|
||||
&mut buf_no_fail,
|
||||
w,
|
||||
h,
|
||||
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: false, failed_attempts: 0 },
|
||||
);
|
||||
let mut buf_with_fail = vec![0u32; (w * h) as usize];
|
||||
render(
|
||||
&mut buf_with_fail,
|
||||
w,
|
||||
h,
|
||||
&Scene { pin_len: 0, mood: Mood::Failed, pressed: None, quiet: false, failed_attempts: 3 },
|
||||
);
|
||||
// The failed-attempts render should introduce FAIL-colored pixels
|
||||
// that aren't in the zero-attempts render.
|
||||
let has_fail_color = buf_with_fail.iter().zip(buf_no_fail.iter())
|
||||
.any(|(&new, &old)| new == FAIL && old != FAIL);
|
||||
assert!(has_fail_color, "failed attempt counter should render FAIL-colored pixels");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,12 @@ struct LockState {
|
|||
/// fallback keypad. The first input reveals the keypad (and is
|
||||
/// swallowed — it's a "show me" gesture, not a digit).
|
||||
quiet: bool,
|
||||
/// Number of failed authentication attempts since the lock surface
|
||||
/// appeared. Displayed on the PIN pad so the user knows how many
|
||||
/// tries they've had. Reset on successful auth (the session unlocks).
|
||||
/// The actual lockout policy lives in PAM (pam_faillock); this counter
|
||||
/// is advisory for user visibility only.
|
||||
failed_attempts: u32,
|
||||
}
|
||||
|
||||
impl LockState {
|
||||
|
|
@ -158,6 +164,7 @@ impl LockState {
|
|||
pointer_pos: (0.0, 0.0),
|
||||
pointer_surface: None,
|
||||
quiet: true,
|
||||
failed_attempts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -326,6 +333,7 @@ pub fn run(
|
|||
warn!("PAM refused: {reason}");
|
||||
state.authing = false;
|
||||
state.mood = Mood::Failed;
|
||||
state.failed_attempts += 1;
|
||||
state.pin.clear();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
|
@ -416,6 +424,7 @@ fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()>
|
|||
mood: state.mood,
|
||||
pressed: state.pressed,
|
||||
quiet: state.quiet,
|
||||
failed_attempts: state.failed_attempts,
|
||||
};
|
||||
let Some(shm) = state.shm.clone() else { return Ok(()) };
|
||||
for ctx in &mut state.surfaces {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
//! same pattern as machined and secrets.
|
||||
|
||||
pub mod auth;
|
||||
pub mod device_state;
|
||||
pub mod draw;
|
||||
pub mod lock;
|
||||
pub mod protocol;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,46 @@ pub enum Request {
|
|||
/// Ask sessiond to take the session lock itself. Refused while a live
|
||||
/// shell owns steady state — the shell's lock IPC is the front door.
|
||||
Lock,
|
||||
/// Full device state: the unified state machine's current state,
|
||||
/// sensor evidence confidence, and doze tier. Superset of Status.
|
||||
DeviceState,
|
||||
/// Report a sensor reading to the device state machine.
|
||||
/// The shell or a sensor daemon feeds proximity/accel/touch evidence.
|
||||
SensorInput(SensorInput),
|
||||
/// Query recent forensic log entries. Returns the last N entries
|
||||
/// from the in-memory forensic buffer for post-hoc analysis.
|
||||
ForensicLog { count: Option<usize> },
|
||||
}
|
||||
|
||||
/// A sensor reading fed to the device state machine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SensorInput {
|
||||
/// Which sensor produced this reading.
|
||||
pub source: SensorSource,
|
||||
/// The reading value.
|
||||
pub value: SensorValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SensorSource {
|
||||
Proximity,
|
||||
Accelerometer,
|
||||
Light,
|
||||
Touch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SensorValue {
|
||||
/// Proximity: near or far.
|
||||
Near(bool),
|
||||
/// Accelerometer: moving or stationary.
|
||||
Moving(bool),
|
||||
/// Light: changing or stable.
|
||||
Changing(bool),
|
||||
/// Touch: active or inactive.
|
||||
Active(bool),
|
||||
}
|
||||
|
||||
/// What sessiond currently is, as reported by `status`.
|
||||
|
|
|
|||
|
|
@ -17,8 +17,11 @@ use std::time::Duration;
|
|||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::sessiond::device_state::{DeviceStateMachine, SensorEvidence};
|
||||
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
|
||||
use crate::sessiond::protocol::{Phase, Request, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES};
|
||||
use crate::sessiond::protocol::{
|
||||
Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES,
|
||||
};
|
||||
|
||||
struct Daemon {
|
||||
phase: Phase,
|
||||
|
|
@ -29,6 +32,9 @@ struct Daemon {
|
|||
heartbeat_gen: u64,
|
||||
shell_alive: bool,
|
||||
pam_service: String,
|
||||
/// The unified device state machine. The single authority for device
|
||||
/// power state — idle, lock, doze, sleep. All actors route through it.
|
||||
device_state: DeviceStateMachine,
|
||||
}
|
||||
|
||||
pub struct Shared {
|
||||
|
|
@ -76,6 +82,7 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
|
|||
heartbeat_gen: 0,
|
||||
shell_alive: false,
|
||||
pam_service,
|
||||
device_state: DeviceStateMachine::new(),
|
||||
}),
|
||||
cond: Condvar::new(),
|
||||
});
|
||||
|
|
@ -327,6 +334,68 @@ fn handle_request(
|
|||
}
|
||||
}
|
||||
}
|
||||
Request::DeviceState => {
|
||||
let d = shared.lock();
|
||||
let mut resp = d.device_state.to_ipc_json();
|
||||
resp["ok"] = serde_json::Value::Bool(true);
|
||||
resp["phase"] = serde_json::to_value(d.phase).unwrap_or_default();
|
||||
resp["shell_alive"] = serde_json::Value::Bool(d.shell_alive);
|
||||
resp
|
||||
}
|
||||
Request::SensorInput(input) => {
|
||||
let mut d = shared.lock();
|
||||
let evidence = &mut d.device_state.sensor_evidence;
|
||||
match (&input.source, &input.value) {
|
||||
(SensorSource::Proximity, SensorValue::Near(v)) => {
|
||||
evidence.proximity_near = *v;
|
||||
}
|
||||
(SensorSource::Accelerometer, SensorValue::Moving(v)) => {
|
||||
evidence.accel_moving = *v;
|
||||
}
|
||||
(SensorSource::Light, SensorValue::Changing(v)) => {
|
||||
evidence.light_changing = *v;
|
||||
}
|
||||
(SensorSource::Touch, SensorValue::Active(v)) => {
|
||||
evidence.touch_active = *v;
|
||||
}
|
||||
_ => {
|
||||
return refusal("sensor source/value mismatch");
|
||||
}
|
||||
}
|
||||
let conf = evidence.confidence();
|
||||
let suppress = evidence.should_suppress_dpms_wake();
|
||||
let promote = evidence.should_promote_idle_faster();
|
||||
info!(
|
||||
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
|
||||
input.source, input.value, conf, suppress, promote
|
||||
);
|
||||
// Let the state machine evaluate Observed transitions.
|
||||
// Clone the evidence before passing it to update_sensors
|
||||
// (which takes ownership).
|
||||
let evidence_clone = SensorEvidence {
|
||||
proximity_near: evidence.proximity_near,
|
||||
accel_moving: evidence.accel_moving,
|
||||
light_changing: evidence.light_changing,
|
||||
touch_active: evidence.touch_active,
|
||||
};
|
||||
d.device_state.update_sensors(evidence_clone);
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"confidence": conf,
|
||||
"suppress_dpms_wake": suppress,
|
||||
"promote_idle_faster": promote,
|
||||
"device_state": d.device_state.state(),
|
||||
})
|
||||
}
|
||||
Request::ForensicLog { count } => {
|
||||
let d = shared.lock();
|
||||
let entries = d.device_state.forensic.recent(count.unwrap_or(50));
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"count": entries.len(),
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +411,7 @@ mod tests {
|
|||
heartbeat_gen: 0,
|
||||
shell_alive: false,
|
||||
pam_service: "test".to_string(),
|
||||
device_state: DeviceStateMachine::new(),
|
||||
}),
|
||||
cond: Condvar::new(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,70 +15,104 @@ pragma ComponentBehavior: Bound
|
|||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// ── Write authority ────────────────────────────────────────────
|
||||
// Each property below names its single authorized writer. Other
|
||||
// files MUST NOT write to these directly — route through the
|
||||
// writer's IPC or signal instead. This is a convention, not
|
||||
// enforced at runtime; a lint rule or future QML analyzer should
|
||||
// flag writes from files other than the named writer.
|
||||
//
|
||||
// Stock ii properties (barOpen, crosshairOpen, sidebarLeftOpen,
|
||||
// sidebarRightOpen, mediaControlsOpen, osdBrightnessOpen,
|
||||
// osdVolumeOpen, oskOpen, overlayOpen, overviewOpen,
|
||||
// regionSelectorOpen, searchOpen, screenTranslatorOpen,
|
||||
// sessionOpen, wallpaperSelectorOpen, workspaceShowNumbers) are
|
||||
// written by their respective ii modules. Souveraine does not
|
||||
// own these — they follow ii's own conventions.
|
||||
|
||||
// WRITER: ii bar module
|
||||
property bool barOpen: true
|
||||
// WRITER: ii crosshair module
|
||||
property bool crosshairOpen: false
|
||||
// WRITER: ii sidebar module
|
||||
property bool sidebarLeftOpen: false
|
||||
// WRITER: ii sidebar module
|
||||
property bool sidebarRightOpen: false
|
||||
// WRITER: ii media controls module
|
||||
property bool mediaControlsOpen: false
|
||||
// WRITER: ii OSD module
|
||||
property bool osdBrightnessOpen: false
|
||||
// WRITER: ii OSD module
|
||||
property bool osdVolumeOpen: false
|
||||
// WRITER: ii OSK module (OnScreenKeyboard.qml)
|
||||
property bool oskOpen: false
|
||||
// WRITER: ii overlay module
|
||||
property bool overlayOpen: false
|
||||
// WRITER: ii overview module + Dock.qml IPC
|
||||
property bool overviewOpen: false
|
||||
// WRITER: ii region selector module
|
||||
property bool regionSelectorOpen: false
|
||||
// WRITER: ii search module
|
||||
property bool searchOpen: false
|
||||
// `screenLocked` is the shell's lock *request*: it drives WlSessionLock
|
||||
// and hides ordinary surfaces immediately. It is deliberately separate
|
||||
// from `screenLockSecure`, which is WlSessionLock.secure and only becomes
|
||||
// true once the compositor has acknowledged the lock. Consumers that
|
||||
// disclose personal data must gate on secure, not merely on the request.
|
||||
// WRITER: LockScreen.qml — the shell's lock *request*. Drives
|
||||
// WlSessionLock and hides ordinary surfaces immediately. Deliberately
|
||||
// separate from screenLockSecure (compositor ack). Consumers that
|
||||
// disclose personal data must gate on secure, not merely request.
|
||||
property bool screenLocked: false
|
||||
// True while the display is genuinely in use (IdleCoordinator Active or
|
||||
// Waking). The idle machine drives it; widgets/pollers whose data may go
|
||||
// stale while the screen is dimmed/locked/asleep gate their timers and
|
||||
// processes on this instead of importing the souveraine IdleCoordinator
|
||||
// singleton into ii-base (quickshell-idle-power task 4).
|
||||
// WRITER: IdleCoordinator.qml — true while the display is genuinely
|
||||
// in use (Active or Waking). Widgets/pollers whose data may go stale
|
||||
// while the screen is dimmed/locked/asleep gate their timers on this
|
||||
// instead of importing IdleCoordinator into ii-base.
|
||||
property bool displayActive: true
|
||||
// WRITER: LockScreen.qml — WlSessionLock.secure. Only true once the
|
||||
// compositor has acknowledged the lock surface. This is the real
|
||||
// "session is locked" signal; screenLocked is just the request.
|
||||
property bool screenLockSecure: false
|
||||
// WRITER: LockScreen.qml
|
||||
property bool screenLockContainsCharacters: false
|
||||
// WRITER: LockScreen.qml
|
||||
property bool screenUnlockFailed: false
|
||||
// WRITER: ii translator module
|
||||
property bool screenTranslatorOpen: false
|
||||
// WRITER: ii session module
|
||||
property bool sessionOpen: false
|
||||
// WRITER: GlobalShortcut handler in this file
|
||||
property bool superDown: false
|
||||
// WRITER: GlobalShortcut handler in this file
|
||||
property bool superReleaseMightTrigger: true
|
||||
// WRITER: GlobalShortcut handler in this file
|
||||
property real superPressTime: 0
|
||||
// WRITER: GlobalShortcut handler in this file
|
||||
property real superLastPressDuration: -1
|
||||
// WRITER: GlobalShortcut handler in this file
|
||||
property real superLastReleaseTime: 0
|
||||
// WRITER: ii wallpaper selector module
|
||||
property bool wallpaperSelectorOpen: false
|
||||
// WRITER: ii workspace module
|
||||
property bool workspaceShowNumbers: false
|
||||
// Boot bloom overlay: true from shell start until the lockscreen surface is
|
||||
// secure. It covers all of Hyprland's boot render, continuing the C splash's
|
||||
// bloom animation. LockScreen.onSecureChanged clears it to reveal the lock.
|
||||
// WRITER: LockScreen.qml — true from shell start until the lock
|
||||
// surface is secure. Covers Hyprland's boot render, continuing the
|
||||
// C splash bloom animation. LockScreen.onSecureChanged clears it.
|
||||
property bool bootBloomActive: true
|
||||
// In fullscreen this is the only way the dock becomes visible:
|
||||
// the navigation rail swipe sets/clears it. It deliberately has no timer
|
||||
// or secondary state.
|
||||
// WRITER: Dock.qml IPC (swipeUp/reveal) + navigation rail. The
|
||||
// fullscreen dock toggle. Deliberately has no timer.
|
||||
property bool dockRevealed: false
|
||||
|
||||
// Mission Control: the Souveraine process/task surface, raised by a
|
||||
// triple swipe-up on the navigation rail. A first-class shell state in
|
||||
// its own right (like overviewOpen) — the rail only sets it; the
|
||||
// MissionControl surface owns everything else. Deliberately distinct
|
||||
// from overviewOpen (the app launcher/search) — this is the running-work
|
||||
// view, reached by escalating the same upward gesture past a single dock
|
||||
// reveal.
|
||||
// WRITER: navigation rail (triple swipe-up). The Souveraine
|
||||
// process/task surface. Deliberately distinct from overviewOpen
|
||||
// (app launcher/search) — this is the running-work view.
|
||||
property bool missionControlOpen: false
|
||||
|
||||
// A rail swipe-down on a visible dock dismisses it in ANY state —
|
||||
// including pinned and shown-on-empty-desktop, which dockRevealed alone
|
||||
// can't reach (it only governs the fullscreen case). Swipe up clears it.
|
||||
// WRITER: Dock.qml IPC (swipeDown). A rail swipe-down on a visible
|
||||
// dock dismisses it in ANY state — including pinned and
|
||||
// shown-on-empty-desktop. Swipe up clears it.
|
||||
property bool dockSuppressed: false
|
||||
|
||||
// Transient dock reveal: shows the dock for a few seconds and restores
|
||||
// whatever state ruled before. Callers: the `osk pulseDock` IPC (glance
|
||||
// at the dock without closing the keyboard) and the agent's harness.
|
||||
// Restored 2026-07-16 — the definition was lost in a refactor while its
|
||||
// IPC caller in OnScreenKeyboard.qml survived, so pulseDock threw.
|
||||
// WRITER: this file (pulseDockReveal) + OnScreenKeyboard.qml IPC.
|
||||
// Transient dock reveal: shows for 3s and restores prior state.
|
||||
// Restored 2026-07-16 — definition was lost in a refactor while
|
||||
// its IPC caller survived, so pulseDock threw.
|
||||
property bool dockRevealPulse: false
|
||||
function pulseDockReveal() {
|
||||
root.dockRevealPulse = true;
|
||||
|
|
@ -90,10 +124,9 @@ Singleton {
|
|||
onTriggered: root.dockRevealPulse = false
|
||||
}
|
||||
|
||||
// True while a dock icon drag is in flight. Set by DockAppButton's drag
|
||||
// lifecycle, read by DockManifest's state checks so structural edits
|
||||
// (pin/stack mutations) can't land mid-drag and rewrite dock.stacks out
|
||||
// from under a commit.
|
||||
// WRITER: DockAppButton drag lifecycle. True while a dock icon drag
|
||||
// is in flight. Read by DockManifest's state checks so structural
|
||||
// edits (pin/stack mutations) can't land mid-drag.
|
||||
property bool dockDragInProgress: false
|
||||
|
||||
function superPressDuration() {
|
||||
|
|
|
|||
|
|
@ -36,8 +36,34 @@ Singleton {
|
|||
// Dimmed saved — never a stale brightnessctl snapshot.
|
||||
property bool displayDimmed: false
|
||||
|
||||
// Legal transitions. Each key maps to the set of states it may
|
||||
// move to. Anything not in this map is a bug — two event sources
|
||||
// racing in the same frame, a stale timer firing after a lock, or
|
||||
// a new code path that forgot to check preconditions.
|
||||
//
|
||||
// The graph:
|
||||
// Active → Dimmed → LockRequested → LockSecure → Suspending → Asleep → Waking → Active
|
||||
// Any locked state can return to Active on unlock.
|
||||
// Suspending is reachable from any pre-sleep state (logind is the authority).
|
||||
readonly property var legalTransitions: ({
|
||||
0: [1, 2, 5], // Active → Dimmed, LockRequested, Suspending
|
||||
1: [0, 2, 5], // Dimmed → Active, LockRequested, Suspending
|
||||
2: [0, 3, 5], // LockRequested → Active, LockSecure, Suspending
|
||||
3: [0, 5], // LockSecure → Active, Suspending
|
||||
4: [5, 6], // Suspending → Asleep, Waking
|
||||
5: [6], // Asleep → Waking
|
||||
6: [0], // Waking → Active
|
||||
})
|
||||
|
||||
function setState(next) {
|
||||
if (root.state === next) return;
|
||||
const allowed = root.legalTransitions[root.state];
|
||||
if (allowed && allowed.indexOf(next) === -1) {
|
||||
console.warn("[idle-coordinator] ILLEGAL transition "
|
||||
+ root.state + " → " + next + " (ignored)");
|
||||
return;
|
||||
}
|
||||
const prev = root.state;
|
||||
root.state = next;
|
||||
// Publish the coarse in-use bool the ii-base pollers gate on
|
||||
// (quickshell-idle-power task 4). Waking counts as active so stats
|
||||
|
|
@ -45,7 +71,7 @@ Singleton {
|
|||
GlobalStates.displayActive =
|
||||
(next === IdleCoordinator.Active || next === IdleCoordinator.Waking);
|
||||
root.stateTransitioned(next);
|
||||
console.log("[idle-coordinator] state=" + next);
|
||||
console.log("[idle-coordinator] state=" + next + " (from=" + prev + ")");
|
||||
|
||||
if (next === IdleCoordinator.Dimmed) {
|
||||
dimProc.action = "dim";
|
||||
|
|
@ -72,7 +98,12 @@ Singleton {
|
|||
}
|
||||
|
||||
function returnActive() {
|
||||
if (root.lockRequested || root.lockSecure) return;
|
||||
// Only return to Active from states that are legitimately
|
||||
// "waiting for user input" — Dimmed or Waking. Never from
|
||||
// locked/sleeping states; those are guarded by the transition
|
||||
// table, but this check makes the intent explicit.
|
||||
if (root.state !== IdleCoordinator.Dimmed
|
||||
&& root.state !== IdleCoordinator.Waking) return;
|
||||
root.setState(IdleCoordinator.Active);
|
||||
root.activeRequested();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,27 +6,27 @@ pragma Singleton
|
|||
// includes the hash of the previous entry, making the log tamper-evident:
|
||||
// altering any past entry invalidates every subsequent hash.
|
||||
//
|
||||
// The log is JSONL (one JSON object per line). The hash chain uses Qt.md5(),
|
||||
// which is not cryptographically strong (SHA256 would be preferred but is
|
||||
// not available in QML). The tamper-evidence is advisory — it detects
|
||||
// casual modification, not a determined attacker with access to the file.
|
||||
// A production deployment should replace this with a Process that calls
|
||||
// sha256sum or a dedicated audit binary.
|
||||
// The log is JSONL (one JSON object per line). The hash chain uses
|
||||
// SHA-256 via the `sha256sum` binary. The tamper-evidence is advisory —
|
||||
// it detects casual modification, not a determined attacker with access
|
||||
// to the file.
|
||||
//
|
||||
// Log location: ~/.local/share/souveraine/session-audit.jsonl
|
||||
//
|
||||
// Entry format:
|
||||
// {
|
||||
// "seq": 42,
|
||||
// "prev": "md5-of-previous-entry",
|
||||
// "prev": "sha256-of-previous-entry",
|
||||
// "ts": 1234567890,
|
||||
// "event": "lock-requested",
|
||||
// "data": { ... },
|
||||
// "hash": "md5-of-this-entry-without-hash-field"
|
||||
// "hash": "sha256-of-this-entry-without-hash-field"
|
||||
// }
|
||||
//
|
||||
// The hash is computed over the JSON string of the entry WITHOUT the hash
|
||||
// field. This is: md5(JSON.stringify({seq, prev, ts, event, data})).
|
||||
// field. This is: sha256(JSON.stringify({seq, prev, ts, event, data})).
|
||||
// Computed by piping the entry JSON through sha256sum in the same shell
|
||||
// command that appends it to the log, so the hash and write are atomic.
|
||||
//
|
||||
// Integration points:
|
||||
// - GlobalStates: lock/unlock transitions
|
||||
|
|
@ -66,6 +66,11 @@ Singleton {
|
|||
}
|
||||
|
||||
// --- Append logic -------------------------------------------------------
|
||||
// Computes SHA-256 and appends in one shell command so the hash and
|
||||
// write are atomic. The entry JSON (without hash field) is piped to
|
||||
// sha256sum; the final entry (with hash) is appended to the log, and
|
||||
// the hex hash is emitted to stdout so the StdioCollector can update
|
||||
// lastHash for the next entry in the chain.
|
||||
function append(event, data) {
|
||||
const entry = {
|
||||
seq: root.nextSeq,
|
||||
|
|
@ -74,22 +79,20 @@ Singleton {
|
|||
event: event,
|
||||
data: data || {}
|
||||
};
|
||||
// Compute hash over the entry WITHOUT the hash field.
|
||||
const hashInput = JSON.stringify(entry);
|
||||
const hash = Qt.md5(hashInput);
|
||||
entry.hash = hash;
|
||||
const escaped = _escape(hashInput);
|
||||
|
||||
// Append to the log file via printf >>. FileView.setText() would
|
||||
// overwrite; we need append-only semantics. The Process runs
|
||||
// synchronously enough for audit purposes — if the shell is
|
||||
// crashing, the last entry may be lost, but that is detectable
|
||||
// from the hash chain gap on next startup.
|
||||
const line = JSON.stringify(entry) + "\n";
|
||||
appendProc.command = ["sh", "-c",
|
||||
`printf '%s' '${_escape(line)}' >> ${root.auditPath}`];
|
||||
appendProc.running = true;
|
||||
// Shell: compute sha256, inject hash into the JSON via sed
|
||||
// (available on all target devices), append to log, echo hash
|
||||
// to stdout. The sed command inserts "hash":"<hex>" before the
|
||||
// LAST closing brace (anchored with $), which is the JSON root.
|
||||
const cmd =
|
||||
`h=$(printf '%s' '${escaped}' | sha256sum | cut -d' ' -f1); ` +
|
||||
`printf '%s\\n' '${escaped}' | sed "s/}$/,\\"hash\\":\\"$h\\"}/" >> ${root.auditPath}; ` +
|
||||
`echo "$h"`;
|
||||
appendProcHash.command = ["sh", "-c", cmd];
|
||||
appendProcHash.running = true;
|
||||
|
||||
root.lastHash = hash;
|
||||
root.nextSeq++;
|
||||
console.log("[audit] " + event + " (seq=" + entry.seq + ")");
|
||||
}
|
||||
|
|
@ -100,7 +103,17 @@ Singleton {
|
|||
}
|
||||
|
||||
Process {
|
||||
id: appendProc
|
||||
id: appendProcHash
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const hash = text.trim();
|
||||
if (hash.length === 64) {
|
||||
root.lastHash = hash;
|
||||
} else {
|
||||
console.log("[audit] unexpected sha256 output: " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
console.log("[audit] append failed (exit " + exitCode + ")");
|
||||
|
|
@ -215,6 +228,66 @@ Singleton {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Forensic: device state transitions ────────────────────────
|
||||
// These entries capture the decision context at each state change.
|
||||
// The Rust-side forensic log (forensic.jsonl) has the full sensor
|
||||
// snapshots; this trail records the same events in the
|
||||
// tamper-evident hash chain for cross-referencing.
|
||||
|
||||
// Brightness / idle coordinator errors.
|
||||
Connections {
|
||||
target: typeof IdleCoordinator !== "undefined" ? IdleCoordinator : null
|
||||
function onStateTransitioned(state) {
|
||||
const names = ["active", "dimmed", "lock-requested",
|
||||
"lock-secure", "suspending", "asleep", "waking"];
|
||||
root.append("device-state-transition", {
|
||||
state: names[state] || String(state),
|
||||
source: "idle-coordinator"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DPMS / screen power errors (from blueline-screen-toggle or
|
||||
// brightnessctl failures). These are the operational errors that
|
||||
// the old code logged to console.log only.
|
||||
function logDeviceError(component, action, error) {
|
||||
root.append("device-error", {
|
||||
component: component,
|
||||
action: action,
|
||||
error: error,
|
||||
device_state: typeof IdleCoordinator !== "undefined"
|
||||
? IdleCoordinator.state : -1,
|
||||
screen_locked: typeof GlobalStates !== "undefined"
|
||||
? GlobalStates.screenLocked : false,
|
||||
screen_lock_secure: typeof GlobalStates !== "undefined"
|
||||
? GlobalStates.screenLockSecure : false,
|
||||
});
|
||||
}
|
||||
|
||||
// Sensor input that affected device state (proximity, accel, etc.).
|
||||
function logSensorInput(source, value, confidence, decision) {
|
||||
root.append("sensor-input", {
|
||||
source: source,
|
||||
value: value,
|
||||
confidence: confidence,
|
||||
decision: decision,
|
||||
device_state: typeof IdleCoordinator !== "undefined"
|
||||
? IdleCoordinator.state : -1,
|
||||
});
|
||||
}
|
||||
|
||||
// Wake event — what triggered the screen to turn on.
|
||||
function logWakeEvent(trigger, details) {
|
||||
root.append("wake-event", {
|
||||
trigger: trigger,
|
||||
details: details || {},
|
||||
device_state: typeof IdleCoordinator !== "undefined"
|
||||
? IdleCoordinator.state : -1,
|
||||
proximity: typeof GlobalStates !== "undefined"
|
||||
? GlobalStates.proximityNear : null,
|
||||
});
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: initLogTimer
|
||||
interval: 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue