sessiond: give the device state machine a clock and real actuators
The state machine landed in b4b30b1 and never ran. Verified on the phone
after 2.5h of use: device_state=active, observed_confidence=0.0,
forensic_log count=0, forensic.jsonl never created. Not one transition.
Root cause was not a missing caller. The daemon had no timebase at all —
every Duration in it was a one-shot handshake deadline — so the machine
could answer requests but nothing ever told it time had passed. It could
not be the proactive half of anything.
- tick loop (1s) drives every timed rule; tick_at() takes the clock so the
rules are testable without sleeping
- panel is a field, not a ninth state: the enum had no cell for "locked,
screen dark", and DozeLight (frozen apps, Wi-Fi save) is not a dark glance
- lock-blank rule, two stages: dim as a visible pre-warning, blank one grace
window later. Nothing owned this before — the only backstop was hypridle's
600s screen-off timer shared with the desktop case, so glancing at the
clock lit the panel for ten minutes
- dim/restore via brightnessctl + blueline-undim. The dim listener was
dropped from hypridle.conf by accident and never restored; it now lives
here, so brightness has one writer and the save happens exactly once —
which is the actual fix for the 10/255 wedge the old -s/-r pair hit
- input truth from ext-idle-notify (new idle.rs), not inferred. Tracking the
compositor's active/quiet boolean instead of stamping last-input is what
keeps a long swipe from being blanked mid-gesture: continuous input emits
no events at all
- evidence staleness, 30s TTL per source, clearing toward safety: a stale
proximity stops suppressing wake, a stale accelerometer stops buying the
longer budget. Measured need — hexagonrpcd-sdsp exited 0, systemd left it
dead, and iio-sensor-proxy went on reporting HasProximity=true for hours
- proximity-near while locked blanks now, taking that decision back from
blueline-proximity-lock
- get_policy/set_policy IPC, Auto-Lock shaped with 0 = never, so Settings can
be a view over the owning daemon instead of a switch that looks like it worked
- fix: PAM unlock was refused as an illegal transition from Observed,
DozeLight and DozeDeep. Unlocking a phone whose proximity had fired hit it
33 tests. aarch64 cross-build verified on archdev.
This commit is contained in:
parent
91c0cf800b
commit
aa3027845d
5 changed files with 1022 additions and 12 deletions
|
|
@ -16,9 +16,100 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::sessiond::protocol::{SensorSource, SensorValue};
|
||||
use crate::sessiond::protocol::{InputTrigger, SensorSource, SensorValue};
|
||||
|
||||
/// How long a locked, lit panel waits for input before it blanks.
|
||||
///
|
||||
/// Android blanks the lock screen in about this long; Sailfish's mce keeps a
|
||||
/// separate blank-from-lockscreen timeout for exactly this case. We had
|
||||
/// neither: the only backstop was hypridle's 600 s screen-off timer, shared
|
||||
/// with the desktop case, so glancing at the clock lit the panel for ten
|
||||
/// minutes.
|
||||
pub const LOCK_BLANK_AFTER: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Same, when evidence says the device is in a hand rather than on a table.
|
||||
pub const LOCK_BLANK_AFTER_HELD: Duration = Duration::from_secs(25);
|
||||
|
||||
/// How long the dimmed warning lasts before the panel goes dark.
|
||||
///
|
||||
/// Dimming first is the pre-warning: the screen visibly fades and a tap
|
||||
/// inside this window cancels the blank. The mechanism already existed and
|
||||
/// was orphaned — hypridle once carried a dim listener
|
||||
/// (`brightnessctl -s set 10`, `on-resume = blueline-undim`) which is no
|
||||
/// longer in the live config, while `blueline-undim` is still installed.
|
||||
pub const LOCK_DIM_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Evidence older than this is stale, and stale evidence is *unknown* —
|
||||
/// neither "user present" nor "user absent".
|
||||
///
|
||||
/// This exists because of a measured failure: on 2026-07-25 the SLPI took a
|
||||
/// CHRE fatal, `blueline-hexagonrpcd-sdsp` exited with status 0 so systemd's
|
||||
/// `Restart=on-failure` never fired, and every sensor was dead for hours
|
||||
/// while iio-sensor-proxy still answered `HasProximity: true`. A consumer of
|
||||
/// a dead sensor looked exactly like a consumer of a quiet one.
|
||||
pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Tunable policy for the machine's timed behaviour.
|
||||
///
|
||||
/// These are settings, not constants: the shape is iOS's Auto-Lock — a
|
||||
/// user-chosen timeout with a visible dim shortly before it, and a "never"
|
||||
/// option for the desk-clock case. The Settings control center (TASK-19) is
|
||||
/// meant to be a view over this struct, so the values live here rather than
|
||||
/// being baked into the rule.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceStatePolicy {
|
||||
/// Locked, panel lit, no input for this long → blank. `None` = never.
|
||||
pub lock_blank_after: Option<Duration>,
|
||||
/// Same, when evidence says the device is being held.
|
||||
pub lock_blank_after_held: Option<Duration>,
|
||||
/// How long the dimmed warning shows before the blank.
|
||||
pub dim_grace: Duration,
|
||||
/// Evidence older than this is unknown.
|
||||
pub evidence_ttl: Duration,
|
||||
/// Whether the dim pre-warning is used at all.
|
||||
pub dim_warning: bool,
|
||||
}
|
||||
|
||||
impl Default for DeviceStatePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lock_blank_after: Some(LOCK_BLANK_AFTER),
|
||||
lock_blank_after_held: Some(LOCK_BLANK_AFTER_HELD),
|
||||
dim_grace: LOCK_DIM_GRACE,
|
||||
evidence_ttl: EVIDENCE_TTL,
|
||||
dim_warning: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the machine wants done. The machine decides; it never runs a command
|
||||
/// itself. `tick` returns intent and the daemon executes it, so the decision
|
||||
/// stays testable without a compositor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Dim the panel as the pre-warning. Saves the current brightness first.
|
||||
Dim,
|
||||
/// Restore brightness after a dim. Runs `blueline-undim`, never a bare
|
||||
/// `brightnessctl -r`: the restore needs the floor, or a save that landed
|
||||
/// while already dim leaves the panel at 10/255 and every later wake
|
||||
/// looks like dead glass (Pixel3Arch b9f83f0).
|
||||
Restore,
|
||||
/// Blank the panel through the DPMS executor.
|
||||
Blank,
|
||||
}
|
||||
|
||||
/// When each evidence source last reported. Kept beside `SensorEvidence`
|
||||
/// rather than inside it so the weights stay a pure function of the readings.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EvidenceSeen {
|
||||
pub proximity: Option<Instant>,
|
||||
pub accel: Option<Instant>,
|
||||
pub light: Option<Instant>,
|
||||
pub touch: Option<Instant>,
|
||||
}
|
||||
|
||||
// ── Forensic logging ─────────────────────────────────────────────────
|
||||
// Every decision point emits a ForensicEntry capturing the full state
|
||||
|
|
@ -221,6 +312,12 @@ const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
|
|||
(DeviceState::Locked, DeviceState::Active),
|
||||
(DeviceState::Locked, DeviceState::Observed),
|
||||
(DeviceState::Locked, DeviceState::DozeLight),
|
||||
// PAM auth unlocks from ANY locked sub-state, not just the base one.
|
||||
// Without these, unlocking a phone that was in Observed (proximity had
|
||||
// fired) or dozing was refused as an illegal transition.
|
||||
(DeviceState::Observed, DeviceState::Active),
|
||||
(DeviceState::DozeLight, DeviceState::Active),
|
||||
(DeviceState::DozeDeep, DeviceState::Active),
|
||||
// Observed → back to Locked (proximity far) or deeper doze
|
||||
(DeviceState::Observed, DeviceState::Locked),
|
||||
(DeviceState::Observed, DeviceState::DozeLight),
|
||||
|
|
@ -268,6 +365,25 @@ pub struct DeviceStateMachine {
|
|||
pub sensor_evidence: SensorEvidence,
|
||||
/// Forensic log — captures every decision point for post-hoc analysis.
|
||||
pub forensic: ForensicLog,
|
||||
/// Panel power. A field, not a ninth state: panel-off is orthogonal to
|
||||
/// the doze tier, and the enum had no cell for "locked, screen dark".
|
||||
panel_on: bool,
|
||||
/// When the compositor's quiet period began, or `None` while the user is
|
||||
/// interacting. This is reported by `ext-idle-notify`, never inferred: a
|
||||
/// continuous gesture produces no events at all, so a machine that stamped
|
||||
/// "last input" itself would blank in the middle of a swipe.
|
||||
idle_since: Option<Instant>,
|
||||
/// Per-source freshness, for the staleness rule.
|
||||
evidence_seen: EvidenceSeen,
|
||||
/// True once a blank has been asked for, so we ask exactly once per wake
|
||||
/// instead of every tick.
|
||||
blank_requested: bool,
|
||||
/// True while the pre-warning dim is showing. The machine tracks this so
|
||||
/// brightness is saved exactly once per dim; the old hypridle `-s/-r`
|
||||
/// pair had no such memory, which is how it wedged at 10/255.
|
||||
dimmed: bool,
|
||||
/// Timed policy. Settable, so Settings can own it.
|
||||
pub policy: DeviceStatePolicy,
|
||||
}
|
||||
|
||||
/// Sensor inputs that feed the Observed state. Each is a reading, not
|
||||
|
|
@ -329,9 +445,272 @@ impl DeviceStateMachine {
|
|||
state: DeviceState::Active,
|
||||
sensor_evidence: SensorEvidence::default(),
|
||||
forensic: ForensicLog::new(),
|
||||
panel_on: true,
|
||||
idle_since: None,
|
||||
evidence_seen: EvidenceSeen::default(),
|
||||
blank_requested: false,
|
||||
dimmed: false,
|
||||
policy: DeviceStatePolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The clock. Called on a fixed interval by the daemon.
|
||||
///
|
||||
/// This is the piece the machine did not have. Everything else was
|
||||
/// event-driven, so the machine could only answer other actors and could
|
||||
/// never notice that time had passed — which is why it sat in `Active`
|
||||
/// with an empty forensic log while the phone locked and unlocked around
|
||||
/// it. Every timed tier (blank, dim, doze promotion, wake windows) hangs
|
||||
/// off this one function.
|
||||
pub fn tick(&mut self) -> Vec<Action> {
|
||||
self.tick_at(Instant::now())
|
||||
}
|
||||
|
||||
/// `tick` with the clock passed in, so the rules can be tested at any
|
||||
/// point on the timeline instead of by sleeping.
|
||||
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
|
||||
self.expire_stale_evidence(now);
|
||||
|
||||
let mut actions = Vec::new();
|
||||
|
||||
// The lock-screen burn: locked, panel lit, nobody touching it.
|
||||
if !is_locked(self.state) || !self.panel_on || self.blank_requested {
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Pocket or face-down: something is over the sensor while the session
|
||||
// is locked. Blank now rather than waiting out the idle budget — this
|
||||
// is the job `blueline-proximity-lock` used to do by calling the DPMS
|
||||
// executor itself, and it belongs here where it can be weighed
|
||||
// against the rest of the evidence instead of racing the other rules.
|
||||
//
|
||||
// Note this deliberately fires even when the accelerometer says the
|
||||
// device is moving (walking with it, or holding it to an ear): a lit
|
||||
// panel against a face or a pocket lining is not wanted either way.
|
||||
// The hand-held carve-out in §4 is about not suppressing a *wake*,
|
||||
// which is a different question from blanking one that is already on.
|
||||
if self.sensor_evidence.proximity_near {
|
||||
self.blank_requested = true;
|
||||
info!("[device-state] proximity near while locked — blanking");
|
||||
self.record_decision(
|
||||
"panel-off",
|
||||
serde_json::json!({
|
||||
"reason": "proximity-near",
|
||||
"confidence": self.sensor_evidence.confidence(),
|
||||
"accel_moving": self.sensor_evidence.accel_moving,
|
||||
}),
|
||||
"locked and something is over the proximity sensor",
|
||||
);
|
||||
actions.push(Action::Blank);
|
||||
return actions;
|
||||
}
|
||||
|
||||
// The compositor says the user is interacting. Not our business yet —
|
||||
// and crucially this is what keeps a long swipe from being blanked
|
||||
// out from under the user's thumb.
|
||||
let Some(idle_since) = self.idle_since else {
|
||||
return actions;
|
||||
};
|
||||
|
||||
let held = self.sensor_evidence.accel_moving;
|
||||
let budget = if held {
|
||||
self.policy.lock_blank_after_held
|
||||
} else {
|
||||
self.policy.lock_blank_after
|
||||
};
|
||||
// "Never" is a legitimate setting (plugged in at the desk).
|
||||
let Some(budget) = budget else {
|
||||
return actions;
|
||||
};
|
||||
|
||||
let idle_for = now.saturating_duration_since(idle_since);
|
||||
|
||||
// Stage two: the grace ran out, go dark.
|
||||
if idle_for >= budget {
|
||||
self.blank_requested = true;
|
||||
info!(
|
||||
"[device-state] locked, idle {:.1}s of {}s — blanking",
|
||||
idle_for.as_secs_f32(),
|
||||
budget.as_secs()
|
||||
);
|
||||
self.record_decision(
|
||||
"panel-off",
|
||||
serde_json::json!({
|
||||
"idle_secs": idle_for.as_secs(),
|
||||
"budget_secs": budget.as_secs(),
|
||||
"held": held,
|
||||
"was_dimmed": self.dimmed,
|
||||
"confidence": self.sensor_evidence.confidence(),
|
||||
}),
|
||||
"locked, panel lit, no input within the blank budget",
|
||||
);
|
||||
actions.push(Action::Blank);
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Stage one: the pre-warning. The panel visibly fades and a tap
|
||||
// inside the grace window cancels the blank — the user gets told
|
||||
// what is about to happen instead of the screen simply dying.
|
||||
if self.policy.dim_warning && !self.dimmed {
|
||||
let dim_at = budget.saturating_sub(self.policy.dim_grace);
|
||||
if idle_for >= dim_at {
|
||||
self.dimmed = true;
|
||||
info!(
|
||||
"[device-state] locked, idle {:.1}s — dimming, {}s to tap",
|
||||
idle_for.as_secs_f32(),
|
||||
self.policy.dim_grace.as_secs()
|
||||
);
|
||||
self.record_decision(
|
||||
"panel-dim",
|
||||
serde_json::json!({
|
||||
"idle_secs": idle_for.as_secs(),
|
||||
"blank_at_secs": budget.as_secs(),
|
||||
"grace_secs": self.policy.dim_grace.as_secs(),
|
||||
"held": held,
|
||||
}),
|
||||
"pre-warning before blanking; input inside the grace cancels it",
|
||||
);
|
||||
actions.push(Action::Dim);
|
||||
}
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
/// Drop evidence nobody has refreshed. Stale readings are cleared rather
|
||||
/// than trusted, and clearing errs the safe way: a stale proximity no
|
||||
/// longer suppresses a wake, and a stale accelerometer no longer buys the
|
||||
/// longer blank budget. Never the reverse.
|
||||
fn expire_stale_evidence(&mut self, now: Instant) {
|
||||
let ttl = self.policy.evidence_ttl;
|
||||
let expired = |seen: &mut Option<Instant>, flag: &mut bool| -> bool {
|
||||
let stale = match *seen {
|
||||
Some(t) => now.saturating_duration_since(t) > ttl,
|
||||
None => true,
|
||||
};
|
||||
if stale && *flag {
|
||||
*flag = false;
|
||||
*seen = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
let mut dropped: Vec<&str> = Vec::new();
|
||||
if expired(
|
||||
&mut self.evidence_seen.proximity,
|
||||
&mut self.sensor_evidence.proximity_near,
|
||||
) {
|
||||
dropped.push("proximity");
|
||||
}
|
||||
if expired(
|
||||
&mut self.evidence_seen.accel,
|
||||
&mut self.sensor_evidence.accel_moving,
|
||||
) {
|
||||
dropped.push("accel");
|
||||
}
|
||||
if expired(
|
||||
&mut self.evidence_seen.light,
|
||||
&mut self.sensor_evidence.light_changing,
|
||||
) {
|
||||
dropped.push("light");
|
||||
}
|
||||
if expired(
|
||||
&mut self.evidence_seen.touch,
|
||||
&mut self.sensor_evidence.touch_active,
|
||||
) {
|
||||
dropped.push("touch");
|
||||
}
|
||||
|
||||
if !dropped.is_empty() {
|
||||
warn!(
|
||||
"[device-state] evidence went stale, treating as unknown: {}",
|
||||
dropped.join(", ")
|
||||
);
|
||||
self.record_decision(
|
||||
"evidence-stale",
|
||||
serde_json::json!({ "sources": dropped, "ttl_secs": ttl.as_secs() }),
|
||||
"no reading within the evidence TTL — a dead sensor is not a quiet one",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp a source as freshly reported. Called alongside the reading.
|
||||
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
|
||||
let now = Instant::now();
|
||||
match source {
|
||||
SensorSource::Proximity => self.evidence_seen.proximity = Some(now),
|
||||
SensorSource::Accelerometer => self.evidence_seen.accel = Some(now),
|
||||
SensorSource::Light => self.evidence_seen.light = Some(now),
|
||||
SensorSource::Touch => self.evidence_seen.touch = Some(now),
|
||||
}
|
||||
}
|
||||
|
||||
/// Real user input. Resets the blank budget, re-arms the rule, and undoes
|
||||
/// the pre-warning dim if one is showing — that cancel is the whole point
|
||||
/// of the grace window, so it returns the action rather than waiting for
|
||||
/// the next tick to notice.
|
||||
pub fn note_input(&mut self, trigger: InputTrigger) -> Vec<Action> {
|
||||
self.idle_since = None;
|
||||
self.blank_requested = false;
|
||||
let mut actions = Vec::new();
|
||||
if self.dimmed {
|
||||
self.dimmed = false;
|
||||
info!("[device-state] input during the dim warning — restoring");
|
||||
actions.push(Action::Restore);
|
||||
}
|
||||
let wake = match trigger {
|
||||
InputTrigger::PowerButton => WakeTrigger::PowerButton,
|
||||
InputTrigger::DoubleTapToWake => WakeTrigger::DoubleTapToWake,
|
||||
InputTrigger::Touch | InputTrigger::Key => WakeTrigger::UserInput,
|
||||
InputTrigger::Unknown => WakeTrigger::Unknown,
|
||||
};
|
||||
self.record_wake(wake, &format!("input: {:?}", trigger));
|
||||
actions
|
||||
}
|
||||
|
||||
/// The executor reports the panel's real state. Turning on counts as
|
||||
/// input, so a dt2w wake starts the blank budget from the wake itself.
|
||||
pub fn set_panel(&mut self, on: bool) -> Vec<Action> {
|
||||
if self.panel_on == on {
|
||||
return Vec::new();
|
||||
}
|
||||
self.panel_on = on;
|
||||
info!("[device-state] panel {}", if on { "on" } else { "off" });
|
||||
self.blank_requested = false;
|
||||
if on {
|
||||
self.idle_since = None;
|
||||
// A wake must never come up dim. If the blank landed while the
|
||||
// warning was showing, the saved brightness is still low, so the
|
||||
// restore has to run before the user sees the panel.
|
||||
if self.dimmed {
|
||||
self.dimmed = false;
|
||||
return vec![Action::Restore];
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The compositor reported a quiet period beginning at `since`.
|
||||
pub fn note_idle_start(&mut self, since: Instant) {
|
||||
if self.idle_since.is_none() {
|
||||
self.idle_since = Some(since);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds of compositor-reported quiet, or 0 while the user is active.
|
||||
pub fn idle_secs(&self) -> u64 {
|
||||
match self.idle_since {
|
||||
Some(t) => Instant::now().saturating_duration_since(t).as_secs(),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the compositor currently reports the user as interacting.
|
||||
pub fn user_active(&self) -> bool {
|
||||
self.idle_since.is_none()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -558,6 +937,17 @@ impl DeviceStateMachine {
|
|||
"observed": matches!(self.state, DeviceState::Observed),
|
||||
"observed_confidence": self.sensor_evidence.confidence(),
|
||||
"suppress_dpms_wake": self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
"panel_on": self.panel_on,
|
||||
"user_active": self.user_active(),
|
||||
"idle_secs": self.idle_secs(),
|
||||
"dimmed": self.dimmed,
|
||||
"blank_requested": self.blank_requested,
|
||||
"evidence_fresh": {
|
||||
"proximity": self.evidence_seen.proximity.is_some(),
|
||||
"accel": self.evidence_seen.accel.is_some(),
|
||||
"light": self.evidence_seen.light.is_some(),
|
||||
"touch": self.evidence_seen.touch.is_some(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -587,6 +977,203 @@ mod tests {
|
|||
assert_eq!(sm.state(), DeviceState::Active);
|
||||
}
|
||||
|
||||
/// A locked machine with a lit panel, where the compositor has just
|
||||
/// reported that input stopped at `t0`.
|
||||
fn locked_and_lit() -> (DeviceStateMachine, Instant) {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.transition(DeviceState::Locked);
|
||||
assert!(sm.panel_on);
|
||||
let t0 = Instant::now();
|
||||
sm.note_idle_start(t0);
|
||||
(sm, t0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lockscreen_dims_as_a_warning_then_blanks() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
|
||||
// Nothing at all early on.
|
||||
assert!(sm.tick_at(t0 + Duration::from_secs(1)).is_empty());
|
||||
|
||||
// Dim lands one grace-window before the blank, not at it.
|
||||
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
|
||||
vec![Action::Dim]
|
||||
);
|
||||
// The warning is asked for once, not on every tick.
|
||||
assert!(sm.tick_at(t0 + dim_at + Duration::from_secs(1)).is_empty());
|
||||
|
||||
// Then the panel goes dark when the budget runs out.
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10)),
|
||||
vec![Action::Blank]
|
||||
);
|
||||
// And that is asked for once too.
|
||||
assert!(sm
|
||||
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(5))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_inside_the_grace_window_cancels_the_blank() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
|
||||
vec![Action::Dim]
|
||||
);
|
||||
|
||||
// The user taps: brightness comes back and the budget restarts.
|
||||
assert_eq!(sm.note_input(InputTrigger::Touch), vec![Action::Restore]);
|
||||
assert!(sm.user_active(), "a tap means the user is here");
|
||||
|
||||
// While the compositor reports the user as active, nothing happens at
|
||||
// all — this is the case a self-stamped clock gets wrong.
|
||||
assert!(sm.tick_at(t0 + LOCK_BLANK_AFTER * 10).is_empty());
|
||||
|
||||
// Quiet resumes later; the budget runs from there, not from before.
|
||||
let tap = t0 + LOCK_BLANK_AFTER * 10;
|
||||
sm.note_idle_start(tap);
|
||||
let at_old_deadline = tap + LOCK_BLANK_AFTER - Duration::from_secs(1);
|
||||
assert!(!sm
|
||||
.tick_at(at_old_deadline)
|
||||
.contains(&Action::Blank));
|
||||
|
||||
// And it does eventually blank, one full budget after the tap.
|
||||
assert_eq!(
|
||||
sm.tick_at(tap + LOCK_BLANK_AFTER + Duration::from_millis(10)),
|
||||
vec![Action::Blank]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_near_while_locked_blanks_at_once() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
sm.sensor_evidence.proximity_near = true;
|
||||
sm.mark_evidence_seen(SensorSource::Proximity);
|
||||
// No waiting for the idle budget: it is in a pocket now.
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + Duration::from_millis(10)),
|
||||
vec![Action::Blank]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_near_while_unlocked_is_left_alone() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let t0 = Instant::now();
|
||||
sm.sensor_evidence.proximity_near = true;
|
||||
sm.mark_evidence_seen(SensorSource::Proximity);
|
||||
// A hand over the sensor on an unlocked, in-use phone is not a
|
||||
// reason to blank it.
|
||||
assert!(sm.tick_at(t0 + Duration::from_secs(1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlocked_screen_is_never_blanked_by_this_rule() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let t0 = Instant::now();
|
||||
sm.note_idle_start(t0);
|
||||
assert_eq!(sm.state(), DeviceState::Active);
|
||||
// Hours idle and unlocked: not this rule's business.
|
||||
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_is_a_real_setting() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
sm.policy.lock_blank_after = None;
|
||||
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_device_gets_the_longer_budget() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
sm.sensor_evidence.accel_moving = true;
|
||||
sm.mark_evidence_seen(SensorSource::Accelerometer);
|
||||
|
||||
// Past the table budget, but a hand is on it: the panel has not gone
|
||||
// dark. (A dim warning is due here — the held budget's grace window
|
||||
// starts exactly where the table budget would have blanked.)
|
||||
assert!(!sm
|
||||
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(1))
|
||||
.contains(&Action::Blank));
|
||||
// The longer budget still ends.
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + LOCK_BLANK_AFTER_HELD + Duration::from_millis(10)),
|
||||
vec![Action::Blank]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_evidence_stops_buying_the_longer_budget() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
sm.sensor_evidence.accel_moving = true;
|
||||
sm.mark_evidence_seen(SensorSource::Accelerometer);
|
||||
|
||||
// The sensor dies: no further readings. Once the TTL passes the
|
||||
// reading is unknown, and unknown must not extend the budget —
|
||||
// erring toward saving the panel, never toward burning it.
|
||||
let late = t0 + EVIDENCE_TTL + LOCK_BLANK_AFTER;
|
||||
let actions = sm.tick_at(late);
|
||||
assert!(!sm.sensor_evidence.accel_moving, "stale reading must clear");
|
||||
assert_eq!(actions, vec![Action::Blank]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_proximity_stops_suppressing_wake() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
sm.sensor_evidence.proximity_near = true;
|
||||
sm.mark_evidence_seen(SensorSource::Proximity);
|
||||
assert!(sm.sensor_evidence.should_suppress_dpms_wake());
|
||||
|
||||
// A dead proximity sensor must not keep vetoing wakes forever.
|
||||
sm.tick_at(t0 + EVIDENCE_TTL + Duration::from_secs(1));
|
||||
assert!(!sm.sensor_evidence.should_suppress_dpms_wake());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waking_from_a_dimmed_blank_restores_brightness_first() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
|
||||
sm.tick_at(t0 + dim_at + Duration::from_millis(10));
|
||||
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10));
|
||||
sm.set_panel(false);
|
||||
|
||||
// dt2w wake: the saved brightness is still the dim value, so the
|
||||
// restore has to run or the panel comes up looking dead.
|
||||
assert_eq!(sm.set_panel(true), vec![Action::Restore]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_unlock_is_legal_from_every_locked_substate() {
|
||||
for from in [
|
||||
DeviceState::Locked,
|
||||
DeviceState::Observed,
|
||||
DeviceState::DozeLight,
|
||||
DeviceState::DozeDeep,
|
||||
] {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.transition(DeviceState::Locked);
|
||||
// Walk the real graph to get there; doze tiers promote in order.
|
||||
match from {
|
||||
DeviceState::Locked => {}
|
||||
DeviceState::Observed => assert!(sm.transition(DeviceState::Observed)),
|
||||
DeviceState::DozeLight => assert!(sm.transition(DeviceState::DozeLight)),
|
||||
DeviceState::DozeDeep => {
|
||||
assert!(sm.transition(DeviceState::DozeLight));
|
||||
assert!(sm.transition(DeviceState::DozeDeep));
|
||||
}
|
||||
other => panic!("unexpected setup state {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
sm.transition(DeviceState::Active),
|
||||
"unlock from {from:?} must be legal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_transitions_work() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
|
|
|
|||
175
src/sessiond/idle.rs
Normal file
175
src/sessiond/idle.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! ext-idle-notify-v1 client: the machine's input truth.
|
||||
//!
|
||||
//! The state machine needs one fact it cannot work out for itself — is the
|
||||
//! user touching the device right now. Nothing in sessiond could answer that
|
||||
//! before: the daemon saw handshakes and PAM verdicts, never input. So the
|
||||
//! lock-blank rule had no honest clock to run against and the only actor that
|
||||
//! knew about input was hypridle, off in its own config with a single
|
||||
//! 600-second timer shared with the desktop case.
|
||||
//!
|
||||
//! The compositor is the only thing that actually sees input, so we ask it
|
||||
//! rather than guess. `ext_idle_notifier_v1` gives exactly two events:
|
||||
//! `idled` after the requested quiet period, and `resumed` on the next input.
|
||||
//! Tracking the *boolean* — active or quiet — instead of stamping "last input"
|
||||
//! ourselves is what makes continuous input safe: a long swipe on the lock
|
||||
//! surface never idles, so the machine never blanks mid-gesture.
|
||||
//!
|
||||
//! This connection is deliberately separate from `lock.rs`. A lock session is
|
||||
//! transient — it exists only while sessiond holds the lock — but input truth
|
||||
//! must be continuous, including the whole time the shell owns the lock.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
use wayland_client::{
|
||||
delegate_noop,
|
||||
protocol::wl_registry::{self, WlRegistry},
|
||||
Connection, Dispatch, QueueHandle,
|
||||
};
|
||||
use wayland_protocols::ext::idle_notify::v1::client::{
|
||||
ext_idle_notification_v1::{self, ExtIdleNotificationV1},
|
||||
ext_idle_notifier_v1::ExtIdleNotifierV1,
|
||||
};
|
||||
|
||||
use wayland_client::protocol::wl_seat::WlSeat;
|
||||
|
||||
/// How long a quiet period must last before the compositor tells us the user
|
||||
/// went away. Short, because it is only the *edge* we care about — the budgets
|
||||
/// themselves live in the machine's policy. One second keeps the reported idle
|
||||
/// start accurate to within a second without waking us needlessly.
|
||||
const QUIET_PERIOD: Duration = Duration::from_secs(1);
|
||||
|
||||
/// What the idle watcher reports back into the daemon.
|
||||
pub enum IdleEvent {
|
||||
/// The compositor has seen no input for `QUIET_PERIOD`. The user stopped
|
||||
/// interacting approximately that long ago.
|
||||
Idled { since: Instant },
|
||||
/// Input happened. The user is here now.
|
||||
Resumed,
|
||||
}
|
||||
|
||||
struct IdleState {
|
||||
notifier: Option<ExtIdleNotifierV1>,
|
||||
seat: Option<WlSeat>,
|
||||
sink: Arc<dyn Fn(IdleEvent) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Run the idle watcher until the connection drops. Blocking; call on its own
|
||||
/// thread.
|
||||
pub fn run(sink: Arc<dyn Fn(IdleEvent) + Send + Sync>) -> Result<()> {
|
||||
let conn = Connection::connect_to_env().context("connecting to the compositor")?;
|
||||
let display = conn.display();
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
display.get_registry(&qh, ());
|
||||
|
||||
let mut state = IdleState {
|
||||
notifier: None,
|
||||
seat: None,
|
||||
sink,
|
||||
};
|
||||
|
||||
// Two round trips: the first surfaces the globals, the second lets the
|
||||
// seat and notifier binds settle before we ask for a notification.
|
||||
queue.roundtrip(&mut state)?;
|
||||
queue.roundtrip(&mut state)?;
|
||||
|
||||
let (Some(notifier), Some(seat)) = (state.notifier.clone(), state.seat.clone()) else {
|
||||
anyhow::bail!(
|
||||
"compositor does not offer ext_idle_notifier_v1 — the machine has no input truth"
|
||||
);
|
||||
};
|
||||
|
||||
// `get_idle_notification` (not the input-ignoring variant): we want the
|
||||
// idle timer to be reset by real input only.
|
||||
let _notification = notifier.get_idle_notification(
|
||||
QUIET_PERIOD.as_millis() as u32,
|
||||
&seat,
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
info!(
|
||||
"[idle] watching input via ext_idle_notifier_v1 (quiet period {}ms)",
|
||||
QUIET_PERIOD.as_millis()
|
||||
);
|
||||
|
||||
loop {
|
||||
queue.blocking_dispatch(&mut state)?;
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for IdleState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"ext_idle_notifier_v1" => {
|
||||
state.notifier =
|
||||
Some(registry.bind::<ExtIdleNotifierV1, _, _>(name, 1, qh, ()));
|
||||
}
|
||||
"wl_seat" => {
|
||||
if state.seat.is_none() {
|
||||
state.seat =
|
||||
Some(registry.bind::<WlSeat, _, _>(name, version.min(5), qh, ()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtIdleNotificationV1, ()> for IdleState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ExtIdleNotificationV1,
|
||||
event: ext_idle_notification_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
ext_idle_notification_v1::Event::Idled => {
|
||||
// Input stopped one quiet period ago, not now.
|
||||
let since = Instant::now()
|
||||
.checked_sub(QUIET_PERIOD)
|
||||
.unwrap_or_else(Instant::now);
|
||||
(state.sink)(IdleEvent::Idled { since });
|
||||
}
|
||||
ext_idle_notification_v1::Event::Resumed => {
|
||||
(state.sink)(IdleEvent::Resumed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The seat is bound only to scope the notification; its events are not ours.
|
||||
delegate_noop!(IdleState: ignore WlSeat);
|
||||
delegate_noop!(IdleState: ignore ExtIdleNotifierV1);
|
||||
|
||||
/// Spawn the watcher and keep it alive. A compositor restart drops the
|
||||
/// connection; input truth is load-bearing, so we reconnect rather than
|
||||
/// silently degrade into a machine that cannot see the user.
|
||||
pub fn spawn(sink: Arc<dyn Fn(IdleEvent) + Send + Sync>) {
|
||||
std::thread::spawn(move || loop {
|
||||
match run(Arc::clone(&sink)) {
|
||||
Ok(()) => warn!("[idle] watcher exited cleanly — retrying"),
|
||||
Err(e) => warn!("[idle] watcher failed: {e:#} — retrying"),
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
});
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
pub mod auth;
|
||||
pub mod device_state;
|
||||
pub mod draw;
|
||||
pub mod idle;
|
||||
pub mod lock;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
|
|
|
|||
|
|
@ -60,9 +60,45 @@ pub enum Request {
|
|||
/// Report a sensor reading to the device state machine.
|
||||
/// The shell or a sensor daemon feeds proximity/accel/touch evidence.
|
||||
SensorInput(SensorInput),
|
||||
/// Real user input happened — touch, key, power button, dt2w wake.
|
||||
/// Resets the idle budget that the lock-blank rule counts against.
|
||||
/// This is the wire that lets the machine tell "the user is looking at
|
||||
/// the lock screen" from "the lock screen has been lit for ten minutes."
|
||||
Input { trigger: Option<InputTrigger> },
|
||||
/// The DPMS executor reports the panel's real power state. The machine
|
||||
/// keeps the panel as a field, not a state: "locked with the screen off"
|
||||
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
|
||||
/// glance. Reported, never assumed — the executor owns the panel.
|
||||
Panel { on: bool },
|
||||
/// Query recent forensic log entries. Returns the last N entries
|
||||
/// from the in-memory forensic buffer for post-hoc analysis.
|
||||
ForensicLog { count: Option<usize> },
|
||||
/// Read the timed policy — the Auto-Lock shaped settings.
|
||||
GetPolicy,
|
||||
/// Change the timed policy. Every field is optional; omitted fields keep
|
||||
/// their current value. This is the seam the Settings control center reads
|
||||
/// and writes, so a control there is a view over the owning daemon rather
|
||||
/// than a switch that only looks like it did something (TASK-19's rule).
|
||||
SetPolicy {
|
||||
/// Seconds; `Some(0)` means never blank.
|
||||
lock_blank_after_secs: Option<u64>,
|
||||
lock_blank_after_held_secs: Option<u64>,
|
||||
dim_grace_secs: Option<u64>,
|
||||
evidence_ttl_secs: Option<u64>,
|
||||
dim_warning: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What produced a user input. Recorded so the forensic trail can tell a
|
||||
/// deliberate power-button press from an accidental pocket touch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InputTrigger {
|
||||
Touch,
|
||||
Key,
|
||||
PowerButton,
|
||||
DoubleTapToWake,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// A sensor reading fed to the device state machine.
|
||||
|
|
|
|||
|
|
@ -17,12 +17,27 @@ use std::time::Duration;
|
|||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::sessiond::device_state::{DeviceStateMachine, SensorEvidence};
|
||||
use crate::sessiond::device_state::{Action, DeviceState, DeviceStateMachine, SensorEvidence};
|
||||
use crate::sessiond::idle;
|
||||
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
|
||||
use crate::sessiond::protocol::{
|
||||
Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES,
|
||||
InputTrigger, Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS,
|
||||
MAX_REQUEST_BYTES,
|
||||
};
|
||||
|
||||
/// How often the machine's clock runs. One second is fine: the shortest
|
||||
/// budget it enforces is measured in seconds, and a locked idle phone should
|
||||
/// not be woken more often than it must be.
|
||||
const TICK_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The one userspace authority for panel DPMS on blueline. sessiond decides;
|
||||
/// this serializes the transition and owns the FTS pacing lesson.
|
||||
const DPMS_EXECUTOR: &str = "blueline-screen-toggle";
|
||||
|
||||
/// Restores brightness after a dim, with the 10% floor. Never a bare
|
||||
/// `brightnessctl -r` — see Pixel3Arch b9f83f0.
|
||||
const UNDIM_EXECUTOR: &str = "blueline-undim";
|
||||
|
||||
struct Daemon {
|
||||
phase: Phase,
|
||||
/// Present exactly while a lock-session thread is running.
|
||||
|
|
@ -90,6 +105,8 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
|
|||
if initial_lock {
|
||||
spawn_lock_session(&shared);
|
||||
}
|
||||
spawn_clock(&shared);
|
||||
spawn_idle_watcher(&shared);
|
||||
info!("sessiond serving on {}", socket_path.display());
|
||||
|
||||
for stream in listener.incoming() {
|
||||
|
|
@ -104,6 +121,105 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The machine's clock. Ticks the state machine and carries out whatever it
|
||||
/// decided.
|
||||
///
|
||||
/// The daemon had no periodic anything before this: every `Duration` in it was
|
||||
/// a one-shot handshake deadline. That is the whole reason the state machine
|
||||
/// never moved — it could answer requests, but nothing ever told it that time
|
||||
/// had passed, so it could not be the proactive half of the nervous system.
|
||||
fn spawn_clock(shared: &Arc<Shared>) {
|
||||
let shared = Arc::clone(shared);
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(TICK_INTERVAL);
|
||||
// Hold the lock only to decide, never while running a command.
|
||||
let actions = {
|
||||
let mut d = shared.lock();
|
||||
d.device_state.tick()
|
||||
};
|
||||
for action in actions {
|
||||
execute(&shared, action);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Feed compositor input truth into the machine.
|
||||
///
|
||||
/// Without this the machine has a clock but no idea whether anyone is
|
||||
/// touching the device, and the only other actor holding that knowledge is
|
||||
/// hypridle — which is why the lock screen's timeout lived in hypridle's
|
||||
/// config with a single 600 s timer instead of in the authority that owns the
|
||||
/// lock.
|
||||
fn spawn_idle_watcher(shared: &Arc<Shared>) {
|
||||
let shared = Arc::clone(shared);
|
||||
idle::spawn(Arc::new(move |event| match event {
|
||||
idle::IdleEvent::Idled { since } => {
|
||||
shared.lock().device_state.note_idle_start(since);
|
||||
}
|
||||
idle::IdleEvent::Resumed => {
|
||||
let actions = {
|
||||
let mut d = shared.lock();
|
||||
d.device_state.note_input(InputTrigger::Unknown)
|
||||
};
|
||||
for action in actions {
|
||||
execute(&shared, action);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Carry out one decision. Failures are recorded in the forensic trail — a
|
||||
/// blank that did not happen is a state divergence, not a log line to lose.
|
||||
fn execute(shared: &Arc<Shared>, action: Action) {
|
||||
let (program, args, label): (&str, Vec<&str>, &str) = match action {
|
||||
// Save-then-dim. sessiond guarantees this runs once per dim, which is
|
||||
// what the old hypridle `-s/-r` listener could not: a second save
|
||||
// while already dim is what pinned brightness at 10/255.
|
||||
Action::Dim => ("brightnessctl", vec!["-s", "set", "10"], "panel-dim"),
|
||||
Action::Restore => (UNDIM_EXECUTOR, vec![], "panel-restore"),
|
||||
Action::Blank => (DPMS_EXECUTOR, vec!["off"], "panel-off"),
|
||||
};
|
||||
|
||||
match std::process::Command::new(program).args(&args).status() {
|
||||
Ok(status) if status.success() => {
|
||||
if action == Action::Blank {
|
||||
// Record the panel we actually turned off, so the machine's
|
||||
// field tracks the hardware rather than its own intent.
|
||||
let follow_up = shared.lock().device_state.set_panel(false);
|
||||
for a in follow_up {
|
||||
execute(shared, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(status) => {
|
||||
let code = status.code().unwrap_or(-1).to_string();
|
||||
warn!("{program} {args:?} exited {code}");
|
||||
shared
|
||||
.lock()
|
||||
.device_state
|
||||
.record_error("device-state", label, &code);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("could not run {program}: {e}");
|
||||
shared
|
||||
.lock()
|
||||
.device_state
|
||||
.record_error("device-state", label, &e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the machine to a tier. Kept separate from the lock plumbing so the
|
||||
/// call sites read as one line each.
|
||||
fn set_tier(d: &mut Daemon, next: DeviceState, why: &str) {
|
||||
if d.device_state.state() == next {
|
||||
return;
|
||||
}
|
||||
if !d.device_state.transition(next) {
|
||||
warn!("[device-state] {why}: refused {:?}", next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a lock session unless one is already running. Returns whether a
|
||||
/// session is running when we leave (either ours or a pre-existing one).
|
||||
fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
|
||||
|
|
@ -128,6 +244,9 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
|
|||
};
|
||||
d.controller = Some(controller);
|
||||
d.phase = Phase::Holding;
|
||||
// sessiond itself now holds the lock — boot lock, retake after a shell
|
||||
// death, or an explicit `lock` request. All three are the locked tier.
|
||||
set_tier(&mut d, DeviceState::Locked, "sessiond holds the lock");
|
||||
drop(d);
|
||||
|
||||
let shared = Arc::clone(shared);
|
||||
|
|
@ -140,6 +259,8 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
|
|||
Ok(SessionOutcome::Unlocked) => {
|
||||
info!("session unlocked at fallback surface");
|
||||
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
|
||||
// PAM succeeded. Only this path may leave the locked tier.
|
||||
set_tier(&mut d, DeviceState::Active, "unlocked at fallback surface");
|
||||
}
|
||||
Ok(SessionOutcome::Released) => {
|
||||
d.phase = Phase::AwaitingShellLock;
|
||||
|
|
@ -311,6 +432,11 @@ fn handle_request(
|
|||
}
|
||||
d.phase = Phase::Released;
|
||||
info!("shell lock confirmed");
|
||||
// The compositor has acknowledged a secure lock surface. THIS is
|
||||
// the transition nothing ever made: without it the machine stayed
|
||||
// in Active for the life of the daemon, so every locked-state rule
|
||||
// below it was unreachable.
|
||||
set_tier(&mut d, DeviceState::Locked, "shell lock confirmed");
|
||||
// The handoff is a deliberate connection drop, but Hyprland
|
||||
// announces any dying lock client as a crashed lockscreen.
|
||||
// The shell's lock is confirmed, so clear that banner.
|
||||
|
|
@ -365,20 +491,24 @@ fn handle_request(
|
|||
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 {
|
||||
// Copy the readings out, ending the borrow before the machine is
|
||||
// touched again.
|
||||
let evidence_copy = 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);
|
||||
// Stamp the source as fresh. Without this every reading expires
|
||||
// on the next tick, because "never reported" and "reported long
|
||||
// ago" are the same thing to the staleness rule.
|
||||
d.device_state.mark_evidence_seen(input.source);
|
||||
info!(
|
||||
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
|
||||
input.source, input.value, conf, suppress, promote
|
||||
);
|
||||
// Let the state machine evaluate Observed transitions.
|
||||
d.device_state.update_sensors(evidence_copy);
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"confidence": conf,
|
||||
|
|
@ -387,6 +517,87 @@ fn handle_request(
|
|||
"device_state": d.device_state.state(),
|
||||
})
|
||||
}
|
||||
Request::Input { trigger } => {
|
||||
let trigger = trigger.unwrap_or(InputTrigger::Unknown);
|
||||
let actions = {
|
||||
let mut d = shared.lock();
|
||||
d.device_state.note_input(trigger)
|
||||
};
|
||||
let cancelled = !actions.is_empty();
|
||||
for action in actions {
|
||||
execute(shared, action);
|
||||
}
|
||||
serde_json::json!({ "ok": true, "dim_cancelled": cancelled })
|
||||
}
|
||||
Request::Panel { on } => {
|
||||
let actions = {
|
||||
let mut d = shared.lock();
|
||||
d.device_state.set_panel(on)
|
||||
};
|
||||
for action in actions {
|
||||
execute(shared, action);
|
||||
}
|
||||
serde_json::json!({ "ok": true, "panel_on": on })
|
||||
}
|
||||
Request::GetPolicy => {
|
||||
let d = shared.lock();
|
||||
let p = &d.device_state.policy;
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"lock_blank_after_secs": p.lock_blank_after.map(|v| v.as_secs()).unwrap_or(0),
|
||||
"lock_blank_after_held_secs":
|
||||
p.lock_blank_after_held.map(|v| v.as_secs()).unwrap_or(0),
|
||||
"dim_grace_secs": p.dim_grace.as_secs(),
|
||||
"evidence_ttl_secs": p.evidence_ttl.as_secs(),
|
||||
"dim_warning": p.dim_warning,
|
||||
})
|
||||
}
|
||||
Request::SetPolicy {
|
||||
lock_blank_after_secs,
|
||||
lock_blank_after_held_secs,
|
||||
dim_grace_secs,
|
||||
evidence_ttl_secs,
|
||||
dim_warning,
|
||||
} => {
|
||||
// 0 means "never" — the desk-clock case, and iOS's Auto-Lock has
|
||||
// the same option for the same reason.
|
||||
let as_budget = |secs: u64| {
|
||||
if secs == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_secs(secs))
|
||||
}
|
||||
};
|
||||
let mut d = shared.lock();
|
||||
let p = &mut d.device_state.policy;
|
||||
if let Some(v) = lock_blank_after_secs {
|
||||
p.lock_blank_after = as_budget(v);
|
||||
}
|
||||
if let Some(v) = lock_blank_after_held_secs {
|
||||
p.lock_blank_after_held = as_budget(v);
|
||||
}
|
||||
if let Some(v) = dim_grace_secs {
|
||||
p.dim_grace = Duration::from_secs(v);
|
||||
}
|
||||
if let Some(v) = evidence_ttl_secs {
|
||||
p.evidence_ttl = Duration::from_secs(v);
|
||||
}
|
||||
if let Some(v) = dim_warning {
|
||||
p.dim_warning = v;
|
||||
}
|
||||
let applied = serde_json::json!({
|
||||
"lock_blank_after_secs": p.lock_blank_after.map(|v| v.as_secs()).unwrap_or(0),
|
||||
"lock_blank_after_held_secs":
|
||||
p.lock_blank_after_held.map(|v| v.as_secs()).unwrap_or(0),
|
||||
"dim_grace_secs": p.dim_grace.as_secs(),
|
||||
"evidence_ttl_secs": p.evidence_ttl.as_secs(),
|
||||
"dim_warning": p.dim_warning,
|
||||
});
|
||||
info!("[device-state] policy set: {applied}");
|
||||
d.device_state
|
||||
.record_decision("policy-set", applied.clone(), "settings changed the policy");
|
||||
serde_json::json!({ "ok": true, "policy": applied })
|
||||
}
|
||||
Request::ForensicLog { count } => {
|
||||
let d = shared.lock();
|
||||
let entries = d.device_state.forensic.recent(count.unwrap_or(50));
|
||||
|
|
|
|||
Loading…
Reference in a new issue