sessiond: the machine can wake the panel, and so can she
The state machine could turn the screen off and had no way to turn it back on. `apply_gesture` answers a power tap on a dark panel with `Restore` — which is brightness — and there was no unblank in the action table at all. That was invisible because the wake never came through the daemon: hyprland.lua bound the physical key straight to blueline-screen-toggle, so the compositor woke the panel and sessiond only heard about it afterwards. viewtop consumes that key on purpose — the control that locks the phone must not be interceptable by whatever is fullscreen — so the gap became a phone that could sleep and never wake. Measured on hardware: panel off, presses arriving and logged, no way back short of ssh. Action::Unblank closes it, and a power tap now returns [Unblank, Restore] in that order, because the brightness a panel comes back at means nothing until the panel is back. Also a `screen` verb, because a wake bound to a keybinding in a compositor config is not something the agent can reach and §13 says that is a defect. It is not a bypass: `on` is immediate, `off` routes through request_blank() like every other path, so she can turn the screen off and still cannot darken an unlocked session. Operation is hers; the ordering is a property of the machine, not a permission she lacks. 100 tests, four new ones pinning exactly that.
This commit is contained in:
parent
106c3bb680
commit
b45fb705db
3 changed files with 189 additions and 2 deletions
|
|
@ -336,6 +336,31 @@ pub enum Action {
|
||||||
Restore,
|
Restore,
|
||||||
/// Blank the panel through the DPMS executor.
|
/// Blank the panel through the DPMS executor.
|
||||||
Blank,
|
Blank,
|
||||||
|
/// Light the panel through the same executor.
|
||||||
|
///
|
||||||
|
/// The machine could turn the screen off and had no way to turn it back
|
||||||
|
/// on: `apply_gesture` answers a power tap on a dark panel with `Restore`,
|
||||||
|
/// which is *brightness*, and there was no unblank in this table at all.
|
||||||
|
/// That worked only because the wake never came through the daemon —
|
||||||
|
/// `hyprland.lua` bound the physical key straight to
|
||||||
|
/// `blueline-screen-toggle`, so the compositor woke the panel and sessiond
|
||||||
|
/// merely heard about it afterwards. Under viewtop the compositor consumes
|
||||||
|
/// that key (a device button must not be interceptable by whatever is
|
||||||
|
/// fullscreen), and the gap became a phone that could sleep and never wake.
|
||||||
|
///
|
||||||
|
/// So the machine owns both directions now, which is what §1 asks for in
|
||||||
|
/// the first place: seven blind actors became one authority, and an
|
||||||
|
/// authority that can only act in one direction is half an authority. It
|
||||||
|
/// also makes waking something the agent can *reach* — doctrine §13, a
|
||||||
|
/// verb she cannot reach is a defect — rather than a side effect of a
|
||||||
|
/// keybinding in a compositor config she has no say over.
|
||||||
|
///
|
||||||
|
/// Unlike [`Action::Blank`] this carries no ordering obligation: §1 is
|
||||||
|
/// about paths to a *dark* panel. Lighting one discloses nothing that the
|
||||||
|
/// lock surface is not already responsible for covering, and
|
||||||
|
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
|
||||||
|
/// panel, only sessiond moves the lock".
|
||||||
|
Unblank,
|
||||||
/// Request the session lock, because something wants the panel dark and
|
/// Request the session lock, because something wants the panel dark and
|
||||||
/// the session is not locked yet.
|
/// the session is not locked yet.
|
||||||
///
|
///
|
||||||
|
|
@ -1529,10 +1554,19 @@ impl DeviceStateMachine {
|
||||||
}
|
}
|
||||||
if !self.panel_on {
|
if !self.panel_on {
|
||||||
// Dark: the tap is a wake, and a power-button wake is never vetoed.
|
// Dark: the tap is a wake, and a power-button wake is never vetoed.
|
||||||
|
//
|
||||||
|
// `Unblank` *then* `Restore`, and the order is the whole of it: the
|
||||||
|
// panel has to come back before the brightness it comes back at
|
||||||
|
// means anything. `Restore` alone was what this returned, which was
|
||||||
|
// only ever enough because the compositor woke the panel through a
|
||||||
|
// keybinding before the daemon was ever consulted — see
|
||||||
|
// `Action::Unblank`. Under viewtop nothing else does it, so a tap
|
||||||
|
// set the brightness of a screen that stayed off.
|
||||||
|
//
|
||||||
// `Restore` puts back the brightness the dim captured rather than a
|
// `Restore` puts back the brightness the dim captured rather than a
|
||||||
// guessed floor — see the comment on that capture.
|
// guessed floor — see the comment on that capture.
|
||||||
self.blank_requested = false;
|
self.blank_requested = false;
|
||||||
return vec![Action::Restore];
|
return vec![Action::Unblank, Action::Restore];
|
||||||
}
|
}
|
||||||
self.request_blank(
|
self.request_blank(
|
||||||
now,
|
now,
|
||||||
|
|
@ -2091,6 +2125,53 @@ impl DeviceStateMachine {
|
||||||
|
|
||||||
/// The executor reports the panel's real state. Turning on counts as
|
/// 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.
|
/// input, so a dt2w wake starts the blank budget from the wake itself.
|
||||||
|
/// Ask for the panel, from outside the machine's own rules.
|
||||||
|
///
|
||||||
|
/// The agent's verb lands here (`{"op":"screen"}`), and so does anything
|
||||||
|
/// else that wants the glass lit or dark. It is deliberately not a
|
||||||
|
/// shortcut past anything: **off routes through `request_blank()`** like
|
||||||
|
/// every other path, so it locks first and waits out `LOCK_ACK_BUDGET`,
|
||||||
|
/// and **on** is the wake that direction never had.
|
||||||
|
///
|
||||||
|
/// Doctrine §13 in one function. Operation is hers — she may turn the
|
||||||
|
/// screen off — and the ordering invariant is not a permission she is
|
||||||
|
/// missing, it is a property of the machine that applies to every caller
|
||||||
|
/// including itself.
|
||||||
|
/// What the machine currently believes the panel is doing.
|
||||||
|
///
|
||||||
|
/// A belief, and named as one: the executor reports it (`set_panel`) and
|
||||||
|
/// nothing here infers it, so this is the last thing the hardware said
|
||||||
|
/// rather than an assumption about what it must be doing now.
|
||||||
|
pub fn panel_on(&self) -> bool {
|
||||||
|
self.panel_on
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_screen(&mut self, on: bool, why: &str) -> Vec<Action> {
|
||||||
|
if on {
|
||||||
|
if self.panel_on {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.blank_requested = false;
|
||||||
|
let mut actions = vec![Action::Unblank];
|
||||||
|
// A wake must never come up dim, for the same reason `set_panel`
|
||||||
|
// restores: a blank that landed while the dim warning was showing
|
||||||
|
// left the saved brightness low.
|
||||||
|
if self.dimmed {
|
||||||
|
self.dimmed = false;
|
||||||
|
actions.push(Action::Restore);
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
if !self.panel_on {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.request_blank(
|
||||||
|
Instant::now(),
|
||||||
|
serde_json::json!({ "verb": "screen", "on": false }),
|
||||||
|
why,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_panel(&mut self, on: bool) -> Vec<Action> {
|
pub fn set_panel(&mut self, on: bool) -> Vec<Action> {
|
||||||
if self.panel_on == on {
|
if self.panel_on == on {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|
@ -2771,6 +2852,62 @@ mod tests {
|
||||||
assert!(!sm.blank_requested, "the panel must not be dark yet");
|
assert!(!sm.blank_requested, "the panel must not be dark yet");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_agents_screen_verb_cannot_blank_an_unlocked_session() {
|
||||||
|
// Doctrine §13 in one assertion. Operation is hers, so she may ask for
|
||||||
|
// the screen off — and the ordering invariant is not a permission she
|
||||||
|
// lacks, it is a property of the machine that applies to every caller.
|
||||||
|
// She gets `Lock` first, exactly as the idle timer does.
|
||||||
|
let (mut sm, _t0) = unlocked_and_lit();
|
||||||
|
assert_eq!(sm.request_screen(false, "agent"), vec![Action::Lock]);
|
||||||
|
assert!(sm.pending_blank.is_some());
|
||||||
|
assert!(!sm.blank_requested, "the panel must not be dark yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_screen_verb_wakes_a_dark_panel() {
|
||||||
|
// The direction the machine did not have. Before `Action::Unblank`
|
||||||
|
// there was no way out of a blank at all from inside the daemon: a
|
||||||
|
// power tap on a dark panel answered `Restore`, which is brightness,
|
||||||
|
// and the panel stayed off. It only ever worked because Hyprland's
|
||||||
|
// keybinding woke the panel before sessiond was consulted.
|
||||||
|
let (mut sm, _t0) = unlocked_and_lit();
|
||||||
|
sm.set_panel(false);
|
||||||
|
assert!(!sm.panel_on());
|
||||||
|
assert_eq!(sm.request_screen(true, "agent"), vec![Action::Unblank]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_power_tap_on_a_dark_panel_unblanks_before_it_restores() {
|
||||||
|
// Order is the whole of it: the brightness a panel comes back at means
|
||||||
|
// nothing until the panel is back. `Restore` alone was what this
|
||||||
|
// returned, and under viewtop that set the brightness of a screen that
|
||||||
|
// stayed off.
|
||||||
|
let (mut sm, t0) = unlocked_and_lit();
|
||||||
|
sm.set_panel(false);
|
||||||
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
||||||
|
let actions = sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
|
||||||
|
let actions = if actions.is_empty() {
|
||||||
|
// Taps resolve on the multi-tap window expiring, not on release.
|
||||||
|
sm.tick_at(t0 + Duration::from_secs(2))
|
||||||
|
} else {
|
||||||
|
actions
|
||||||
|
};
|
||||||
|
let unblank = actions.iter().position(|a| *a == Action::Unblank);
|
||||||
|
let restore = actions.iter().position(|a| *a == Action::Restore);
|
||||||
|
assert!(unblank.is_some(), "a tap on a dark panel must wake it: {actions:?}");
|
||||||
|
if let (Some(u), Some(r)) = (unblank, restore) {
|
||||||
|
assert!(u < r, "the panel must come back before its brightness does");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asking_for_the_state_the_panel_is_already_in_does_nothing() {
|
||||||
|
let (mut sm, _t0) = unlocked_and_lit();
|
||||||
|
assert!(sm.panel_on());
|
||||||
|
assert!(sm.request_screen(true, "agent").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pending_blank_fires_once_the_lock_is_acked() {
|
fn a_pending_blank_fires_once_the_lock_is_acked() {
|
||||||
let (mut sm, t0) = unlocked_and_lit();
|
let (mut sm, t0) = unlocked_and_lit();
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,17 @@ pub const VERBS: &[VerbDoc] = &[
|
||||||
refuses: &[],
|
refuses: &[],
|
||||||
example: r#"{"op":"panel","on":false}"#,
|
example: r#"{"op":"panel","on":false}"#,
|
||||||
},
|
},
|
||||||
|
VerbDoc {
|
||||||
|
op: "screen",
|
||||||
|
mutates: true,
|
||||||
|
summary: "turn the panel on or off; a blank still locks the session first",
|
||||||
|
// Refusable because turning it OFF routes through the same
|
||||||
|
// lock-before-blank path everything else does — the agent owns
|
||||||
|
// operation (§13) and still cannot blank an unlocked session, because
|
||||||
|
// that is an ordering invariant rather than a permission.
|
||||||
|
refuses: &[RefusalCode::RefusedByState],
|
||||||
|
example: r#"{"op":"screen","on":true}"#,
|
||||||
|
},
|
||||||
VerbDoc {
|
VerbDoc {
|
||||||
op: "forensic_log",
|
op: "forensic_log",
|
||||||
mutates: false,
|
mutates: false,
|
||||||
|
|
@ -294,6 +305,19 @@ pub enum Request {
|
||||||
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
|
/// 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.
|
/// glance. Reported, never assumed — the executor owns the panel.
|
||||||
Panel { on: bool },
|
Panel { on: bool },
|
||||||
|
/// Ask for the panel. The agent's verb, and the user's, and the shell's.
|
||||||
|
///
|
||||||
|
/// Distinct from [`Request::Panel`], which is the executor *reporting*
|
||||||
|
/// what the hardware did. This one *asks*, and the distinction is the
|
||||||
|
/// whole reason both exist: a report must never be able to actuate, or a
|
||||||
|
/// stale report would drive the panel; and an ask must never be able to
|
||||||
|
/// silently edit the machine's idea of the hardware.
|
||||||
|
///
|
||||||
|
/// Waking is immediate. Blanking goes through the same `request_blank()`
|
||||||
|
/// every other path takes, so it locks first and waits for the ack —
|
||||||
|
/// doctrine §13's shape exactly: operation is hers, and the one thing she
|
||||||
|
/// cannot do is make the glass dark on a session that is not locked.
|
||||||
|
Screen { on: bool },
|
||||||
/// Query recent forensic log entries. Returns the last N entries
|
/// Query recent forensic log entries. Returns the last N entries
|
||||||
/// from the in-memory forensic buffer for post-hoc analysis.
|
/// from the in-memory forensic buffer for post-hoc analysis.
|
||||||
ForensicLog { count: Option<usize> },
|
ForensicLog { count: Option<usize> },
|
||||||
|
|
@ -532,7 +556,7 @@ mod tests {
|
||||||
|
|
||||||
// And the other direction: every variant must be advertised. Bump this
|
// And the other direction: every variant must be advertised. Bump this
|
||||||
// deliberately when a verb is added, having added its VerbDoc.
|
// deliberately when a verb is added, having added its VerbDoc.
|
||||||
assert_eq!(VERBS.len(), 15, "a Request variant was added without a VerbDoc");
|
assert_eq!(VERBS.len(), 16, "a Request variant was added without a VerbDoc");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,16 @@ fn execute(shared: &Arc<Shared>, action: Action) {
|
||||||
// dim is what pinned brightness at 10/255.
|
// dim is what pinned brightness at 10/255.
|
||||||
Action::Dim => ("brightnessctl", vec!["set", "10"], "panel-dim"),
|
Action::Dim => ("brightnessctl", vec!["set", "10"], "panel-dim"),
|
||||||
Action::Restore => unreachable!("handled above; the restore carries a value"),
|
Action::Restore => unreachable!("handled above; the restore carries a value"),
|
||||||
|
// The other direction, through the same executor.
|
||||||
|
//
|
||||||
|
// No lock check and no proximity gate. `Blank`'s guard below exists
|
||||||
|
// because a dark panel on an unlocked session is a disclosure hazard
|
||||||
|
// that outlives the moment; lighting one is not, and the lock surface
|
||||||
|
// is what gets lit when the session is locked. §4's table settles the
|
||||||
|
// veto question separately: a power button "can it lie? no — hardware
|
||||||
|
// signal", which is why the one wake a pocket can produce by itself
|
||||||
|
// (tap-to-wake) is gated at the toggle and this is not.
|
||||||
|
Action::Unblank => (DPMS_EXECUTOR, vec!["on"], "panel-on"),
|
||||||
Action::Blank => {
|
Action::Blank => {
|
||||||
// The invariant, enforced where it cannot be reasoned around: the
|
// The invariant, enforced where it cannot be reasoned around: the
|
||||||
// panel does not go dark unless logind says this session is
|
// panel does not go dark unless logind says this session is
|
||||||
|
|
@ -1233,6 +1243,22 @@ fn handle_request(
|
||||||
}
|
}
|
||||||
serde_json::json!({ "ok": true, "panel_on": on })
|
serde_json::json!({ "ok": true, "panel_on": on })
|
||||||
}
|
}
|
||||||
|
Request::Screen { on } => {
|
||||||
|
let actions = {
|
||||||
|
let mut d = shared.lock();
|
||||||
|
d.device_state.request_screen(on, "screen verb")
|
||||||
|
};
|
||||||
|
let acted = !actions.is_empty();
|
||||||
|
for action in actions {
|
||||||
|
execute(shared, action);
|
||||||
|
}
|
||||||
|
// `panel_on` is what the machine currently believes, not what was
|
||||||
|
// asked: a blank that is waiting on a lock ack has not happened
|
||||||
|
// yet, and saying otherwise would be the report lying about the
|
||||||
|
// hardware.
|
||||||
|
let panel_on = shared.lock().device_state.panel_on();
|
||||||
|
serde_json::json!({ "ok": true, "requested": on, "panel_on": panel_on, "acted": acted })
|
||||||
|
}
|
||||||
Request::Bearer => {
|
Request::Bearer => {
|
||||||
let d = shared.lock();
|
let d = shared.lock();
|
||||||
let b = &d.device_state.bearer;
|
let b = &d.device_state.bearer;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue