diff --git a/src/sessiond/device_state.rs b/src/sessiond/device_state.rs index d075307..48bcaca 100644 --- a/src/sessiond/device_state.rs +++ b/src/sessiond/device_state.rs @@ -22,8 +22,7 @@ use tracing::{info, warn}; use crate::sessiond::bearer::{Bearer, BearerEvidence}; use crate::sessiond::protocol::{ - Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, -}; + Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture,}; /// How long a locked, lit panel waits for input before it blanks. /// @@ -409,6 +408,20 @@ pub enum Action { /// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the /// panel, only sessiond moves the lock". Unblank, + /// Raise the window overview. + /// + /// A touch gesture bound the way every other control is: viewtop reports + /// what the fingers did, the machine decides what it means, and the + /// behaviour leaves here through the executor table. §12 is explicit that + /// the alternative — the compositor recognising three fingers and calling + /// `qs ipc` itself — is the eighth blind actor, and `wiuf-vpn-gate` is what + /// that costs: 652 tunnel recycles in ninety minutes with no way to turn it + /// off. + /// + /// The compositor has to do the *recognition* because contacts only exist + /// there, which is why this arrives already named rather than as raw + /// touches. What it must not do is decide what a name means. + Overview, /// Request the session lock, because something wants the panel dark and /// the session is not locked yet. /// @@ -1680,6 +1693,38 @@ impl DeviceStateMachine { ) } + /// Bind a recognised touch gesture to behaviour. + /// + /// The same shape as [`Self::apply_gesture`] for buttons, and deliberately + /// as small: three fingers raise the overview, everything else is + /// recognised, recorded and inert. A gesture that fires something nobody + /// chose is worse than one that fires nothing, and the binding table this + /// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and + /// agent-writable, so a triple-tap can be re-pointed without a rebuild. + pub fn touch_gesture(&mut self, fingers: u8, gesture: TouchGesture) -> Vec { + let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap); + self.record_decision( + "touch-gesture", + serde_json::json!({ + "fingers": fingers, + "gesture": gesture.as_str(), + "bound": bound, + }), + "touch gesture recognised", + ); + if !bound { + return Vec::new(); + } + // Not while the screen is dark: the overview is content, and putting + // content on an unauthenticated glass is what §4's disclosure rules + // exist to prevent. A tap on a dark panel is a wake, and that is the + // power button's business, not this one's. + if !self.panel_on { + return Vec::new(); + } + vec![Action::Overview] + } + pub fn tick_at(&mut self, now: Instant) -> Vec { // Buttons first: a hold that crossed its threshold during this tick // should be announced before the idle rules below decide anything, or @@ -3146,6 +3191,45 @@ mod tests { } } + #[test] + fn three_fingers_raise_the_overview() { + let (mut sm, _t0) = unlocked_and_lit(); + let actions = sm.touch_gesture(3, TouchGesture::Tap); + assert_eq!(actions, vec![Action::Overview]); + } + + #[test] + fn every_other_touch_gesture_is_recognised_and_inert() { + // Deliberate. A gesture that fires something nobody chose is worse + // than one that fires nothing, and the binding table this is a + // placeholder for is DeviceStatePolicy's to hold. + let (mut sm, _t0) = unlocked_and_lit(); + for g in [ + TouchGesture::SwipeUp, + TouchGesture::SwipeDown, + TouchGesture::SwipeLeft, + TouchGesture::SwipeRight, + ] { + assert!(sm.touch_gesture(3, g).is_empty(), "{g:?}"); + } + for fingers in [1u8, 2, 4, 5] { + assert!( + sm.touch_gesture(fingers, TouchGesture::Tap).is_empty(), + "{fingers} fingers" + ); + } + } + + #[test] + fn the_overview_does_not_open_on_a_dark_panel() { + // The overview is content, and content on an unauthenticated glass is + // what the disclosure rules exist to prevent. A tap on a dark panel is + // a wake, and that is the power button's business. + let (mut sm, _t0) = unlocked_and_lit(); + sm.set_panel(false); + assert!(sm.touch_gesture(3, TouchGesture::Tap).is_empty()); + } + #[test] fn a_press_that_woke_the_panel_does_not_then_blank_it() { // The regression Casey hit: press to wake, the lock screen appears, and diff --git a/src/sessiond/protocol.rs b/src/sessiond/protocol.rs index f516d4f..6592072 100644 --- a/src/sessiond/protocol.rs +++ b/src/sessiond/protocol.rs @@ -181,6 +181,13 @@ pub const VERBS: &[VerbDoc] = &[ refuses: &[RefusalCode::InvalidArgument], example: r#"{"op":"button","button":"power","edge":"down"}"#, }, + VerbDoc { + op: "gesture", + mutates: true, + summary: "a recognised touch gesture; the machine decides what it means", + refuses: &[RefusalCode::InvalidArgument], + example: r#"{"op":"gesture","fingers":3,"gesture":"tap"}"#, + }, VerbDoc { op: "panel", mutates: true, @@ -300,6 +307,15 @@ pub enum Request { /// hold — happens in the machine, because a gesture is an accumulation over /// time and time is the one thing a fire-and-forget script does not have. Button { button: Button, edge: ButtonEdge }, + /// A recognised touch gesture, already named by the compositor. + /// + /// Unlike [`Self::Button`], which carries raw edges because recognition is + /// an accumulation over time and the machine owns time, this arrives named: + /// touch contacts exist only inside the compositor, so nothing else *can* + /// recognise them. The line §12 draws still holds — the compositor names + /// what the fingers did and the machine decides what it means. A compositor + /// that both recognised and acted would be the eighth blind actor. + Gesture { fingers: u8, gesture: TouchGesture }, /// 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 @@ -429,6 +445,32 @@ pub enum ButtonEdge { Up, } +/// What a set of fingers did. Named by the compositor, meant by the machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TouchGesture { + /// Down and up without travelling. + Tap, + /// Travelled and let go. The direction is what a binding usually cares + /// about; the distance is the compositor's business. + SwipeUp, + SwipeDown, + SwipeLeft, + SwipeRight, +} + +impl TouchGesture { + pub fn as_str(self) -> &'static str { + match self { + TouchGesture::Tap => "tap", + TouchGesture::SwipeUp => "swipe_up", + TouchGesture::SwipeDown => "swipe_down", + TouchGesture::SwipeLeft => "swipe_left", + TouchGesture::SwipeRight => "swipe_right", + } + } +} + /// What the machine made of a run of edges. /// /// `Hold` fires while the button is still down — a hold you only learn about on @@ -436,7 +478,7 @@ pub enum ButtonEdge { /// waiting with no feedback is how a user decides the device is broken and lets /// go. Taps resolve on the multi-tap window expiring, which is the opposite /// trade and the right one: a double-tap must not first fire a single. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ButtonGesture { Tap, @@ -556,7 +598,10 @@ mod tests { // And the other direction: every variant must be advertised. Bump this // deliberately when a verb is added, having added its VerbDoc. - assert_eq!(VERBS.len(), 16, "a Request variant was added without a VerbDoc"); + // 17 since `gesture` (2026-08-03): the compositor names what the + // fingers did and the machine decides what it means, so the naming had + // to become a verb rather than the compositor calling a tool itself. + assert_eq!(VERBS.len(), 17, "a Request variant was added without a VerbDoc"); } #[test] diff --git a/src/sessiond/server.rs b/src/sessiond/server.rs index aa83619..379618d 100644 --- a/src/sessiond/server.rs +++ b/src/sessiond/server.rs @@ -372,6 +372,14 @@ fn execute(shared: &Arc, action: Action) { vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"], "volume-down", ), + // The shell owns the overview; the machine owns what opens it. Same + // shape as every other row here — a named tool with named arguments, + // one place, in the trail. + Action::Overview => ( + "qs", + vec!["-c", "souveraine", "ipc", "call", "overview", "toggle"], + "overview-toggle", + ), Action::Blank => { // The invariant, enforced where it cannot be reasoned around: the // panel does not go dark unless logind says this session is @@ -1248,6 +1256,21 @@ fn handle_request( } serde_json::json!({ "ok": true }) } + Request::Gesture { fingers, gesture } => { + let actions = { + let mut d = shared.lock(); + d.device_state.touch_gesture(fingers, gesture) + }; + let bound = !actions.is_empty(); + for action in actions { + execute(shared, action); + } + // Whether it meant anything, reported. A gesture the machine + // recognises and has no binding for is a legitimate answer and a + // different one from a gesture it did not understand — the caller + // can tell them apart instead of guessing. + serde_json::json!({ "ok": true, "bound": bound }) + } Request::Panel { on } => { let actions = { let mut d = shared.lock();