Watch
1
0
Fork
You've already forked souveraine
0

lock primitives: power-from-lock, login1 polkit rule, gesture rail progress

seat0-active polkit rule so power off/reboot/suspend stop returning
challenge into a session with no polkit agent on the lock surface.
Lock-surface buttons stay gated behind allowPowerFromLock (opt-in) and
requirePasswordToPower. SystemGestureRail progressive gesture + mission
control nav flag.
This commit is contained in:
Fimeg 2026-07-19 22:12:06 -04:00
commit 5cb1be06cd
10 changed files with 250 additions and 47 deletions

View file

@ -8,7 +8,7 @@ pkgdesc="Sovereign agent substrate — server, TUI, surfaces"
arch=('aarch64' 'x86_64')
url="https://github.com/Fimeg/souveraine"
license=('MIT')
depends=('gcc-libs')
depends=('gcc-libs' 'polkit')
makedepends=('cargo' 'git')
options=('!lto')
@ -44,5 +44,12 @@ package() {
install -Dm644 packaging/arch/souveraine.sysusers \
"$pkgdir/usr/lib/sysusers.d/souveraine.conf"
# Let the active local session power off / reboot / suspend / hibernate
# without an interactive polkit challenge — otherwise the lock surface
# (no agent above session lock) silently no-ops these and every shutdown
# is a fastboot hard-reboot.
install -Dm644 packaging/arch/souveraine-login1.rules \
"$pkgdir/etc/polkit-1/rules.d/49-souveraine-login1.rules"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}

View file

@ -0,0 +1,31 @@
// Souveraine — let the active local session power off, reboot, suspend, and
// hibernate without an interactive polkit challenge.
//
// Without this, login1 returns 'challenge' for CanPowerOff/CanReboot on
// every call, and the session has no usable polkit agent on the lock
// surface, so power off / reboot silently no-op'd and every shutdown was a
// fastboot hard-reboot.
//
// Doctrine boundary: this grants every process in the authenticated active
// local seat0 session unchallenged power state. That authority remains while
// the session is screen-locked; a Wayland lock is not a new logind session.
// Config.options.lock.security.allowPowerFromLock (default false, opt-in)
// gates only Souveraine's lock-surface buttons, and requirePasswordToPower can
// arm those buttons for PIN confirmation. They do not constrain arbitrary
// local D-Bus clients. Remote/SSH sessions are NOT granted.
//
// Mirrors the default rule every desktop (GNOME/KDE/Phosh) ships.
polkit.addRule(function(action, subject) {
if ((action.id === "org.freedesktop.login1.power-off" ||
action.id === "org.freedesktop.login1.power-off-multiple-sessions" ||
action.id === "org.freedesktop.login1.reboot" ||
action.id === "org.freedesktop.login1.reboot-multiple-sessions" ||
action.id === "org.freedesktop.login1.suspend" ||
action.id === "org.freedesktop.login1.suspend-multiple-sessions" ||
action.id === "org.freedesktop.login1.hibernate" ||
action.id === "org.freedesktop.login1.hibernate-multiple-sessions") &&
subject.active && subject.local && subject.seat === "seat0") {
return polkit.Result.YES;
}
});

View file

@ -8,9 +8,7 @@
//! path only exists through a successful PAM conversation here.
use std::collections::HashMap;
use std::io::Write;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::net::UnixStream;
use std::sync::mpsc::{Receiver, Sender};
use anyhow::{bail, Context, Result};
@ -41,10 +39,6 @@ use wayland_protocols::ext::session_lock::v1::client::{
use crate::sessiond::auth;
use crate::sessiond::draw::{self, hit_test, Key, Mood, Scene};
/// Where the boot bloom listens; poked once our surface has real content so
/// the splash dissolves onto the lock, not onto the desktop.
const SPLASH_SOCK: &str = "/run/souveraine-splash.sock";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionOutcome {
/// The user authenticated at the fallback surface; session is unlocked.
@ -136,7 +130,6 @@ struct LockState {
submit: Option<String>,
pointer_pos: (f64, f64),
pointer_surface: Option<u32>, // protocol id of the entered wl_surface
splash_poked: bool,
/// True until the user touches the surface. While quiet we render only
/// the dark field, so the boot handoff to the shell never flashes the
/// fallback keypad. The first input reveals the keypad (and is
@ -164,7 +157,6 @@ impl LockState {
submit: None,
pointer_pos: (0.0, 0.0),
pointer_surface: None,
splash_poked: false,
quiet: true,
}
}
@ -228,14 +220,6 @@ impl LockState {
}
}
fn poke_splash() {
std::thread::spawn(|| {
if let Ok(mut s) = UnixStream::connect(SPLASH_SOCK) {
let _ = s.write_all(b"1");
}
});
}
/// Run one full lock session on the current thread. Blocks until unlock,
/// release, or denial.
pub fn run(
@ -434,17 +418,11 @@ fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()>
quiet: state.quiet,
};
let Some(shm) = state.shm.clone() else { return Ok(()) };
let mut drew = false;
for ctx in &mut state.surfaces {
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
continue;
}
draw_frame(&shm, ctx, &scene, qh)?;
drew = true;
}
if drew && !state.splash_poked {
state.splash_poked = true;
poke_splash();
}
Ok(())
}

View file

@ -50,6 +50,15 @@ Singleton {
// or secondary state.
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.
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.

View file

@ -478,6 +478,15 @@ Singleton {
property JsonObject security: JsonObject {
property bool unlockKeyring: true
property bool requirePasswordToPower: false
// Whether the lock surface may trigger power-off / reboot
// at all. Off by default (fail-closed): a destructive
// action from the locked screen is opt-in. The system
// polkit rule (souveraine-login1.rules) lets the active
// session power off without challenge once unlocked; this
// gate keeps the *locked* surface from reaching it until
// the user enables it. requirePasswordToPower then arms
// the action to be confirmed by the PIN unlock.
property bool allowPowerFromLock: false
}
// Step-up authentication grants for sensitive operations.
// The PAM service (souveraine-stepup) is root-owned system

View file

@ -94,6 +94,16 @@ Singleton {
property bool locked: false
}
// Navigation-rail onboarding. `missionControlDiscovered` flips
// true the first time the triple-swipe raises Mission Control;
// until then the rail may nudge the gesture after repeated
// incomplete swipes (the Souveraine analogue of Launcher3's
// AllAppsEduView, driven from SystemGestureRail.qml). Persisted so
// the nudge doesn't return after the user has found the gesture.
property JsonObject navigation: JsonObject {
property bool missionControlDiscovered: false
}
property JsonObject overlay: JsonObject {
property list<string> open: ["crosshair", "recorder", "volumeMixer", "resources"]
property JsonObject crosshair: JsonObject {

View file

@ -72,10 +72,6 @@ Scope {
if (GlobalStates.screenLocked) {
lockContext.reset();
lockContext.tryFingerUnlock();
if (!root.splashPoked) {
root.splashPoked = true;
splashSignalProc.exec({ command: ["splash-signal"] });
}
}
}
}
@ -113,13 +109,24 @@ Scope {
locked: GlobalStates.screenLocked
surface: root.sessionLockSurface
// This is the compositor acknowledgement, not our requested bool.
// Keep it separate so an IPC caller cannot mistake a queued lock for
// a secure surface when it is deciding whether personal content may
// be exposed.
// `secure` is the compositor's acknowledgement that a real
// session-lock surface is up not our requested bool. Keep it
// separate so an IPC caller cannot mistake a queued lock for a secure
// surface when it is deciding whether personal content may be exposed.
// It is also the true "lockscreen is ready" event: the one moment we
// end the boot splash. The bloom has covered all of Hyprland's startup
// and now our own lock surface is mapped and on screen, so fade it
// here. Firing on the request edge (screenLocked) was too early
// Hyprland's boot render leaked through the gap. splashPoked keeps it
// once-only so re-locks after unlock never re-fade a splash that
// isn't there.
onSecureChanged: {
GlobalStates.screenLockSecure = secure;
console.log("[lock] session lock secure=" + secure);
if (secure && !root.splashPoked) {
root.splashPoked = true;
splashSignalProc.exec({ command: ["splash-signal"] });
}
}
// The compositor can end our lock without us asking: ext-session-lock

View file

@ -25,6 +25,7 @@ MouseArea {
id: root
required property LockContext context
readonly property bool requirePasswordToPower: Config.options.lock.security.requirePasswordToPower
readonly property bool allowPowerFromLock: Config.options.lock.security.allowPowerFromLock
readonly property int keySize: 96
readonly property int keySpacing: 18
@ -278,6 +279,7 @@ MouseArea {
spacing: 36
UtilityButton {
visible: root.allowPowerFromLock
iconName: "power_settings_new"
targetAction: LockContext.ActionEnum.Poweroff
}
@ -299,6 +301,7 @@ MouseArea {
}
UtilityButton {
visible: root.allowPowerFromLock
iconName: "restart_alt"
targetAction: LockContext.ActionEnum.Reboot
}

View file

@ -51,6 +51,18 @@ ContentPage {
}
}
ConfigSwitch {
buttonIcon: "power_settings_new"
text: Translation.tr("Allow power off / reboot from lock screen")
checked: Config.options.lock.security.allowPowerFromLock
onCheckedChanged: {
Config.options.lock.security.allowPowerFromLock = checked;
}
StyledToolTip {
text: Translation.tr("Show the power and reboot buttons on the lock screen. Off by default — a destructive action from the locked surface is opt-in. The buttons still respect “require password to power off” above.")
}
}
ConfigSwitch {
buttonIcon: "vpn_key"
text: Translation.tr("Unlock keyring on PIN unlock")

View file

@ -2,8 +2,25 @@
//
// This is deliberately part of the primary Souveraine shell, rather than a
// second `qs -c ` configuration. It owns the small bottom input region that
// stays available over fullscreen applications: swipe up/down controls the
// dock and a double tap toggles fullscreen for the real active toplevel.
// stays available over fullscreen applications: an escalating upward gesture
// drives the dock and, pushed further, Mission Control; swipe down peels the
// nearest surface back; a double tap toggles fullscreen for the real active
// toplevel.
//
// The upward gesture is progressive, not a single threshold. As the drag
// climbs it crosses two stages first REVEAL (the dock shows), then MISSION
// (the process/task surface, GlobalStates.missionControlOpen). The handle
// tracks the drag live: it grows, brightens and lifts toward whichever stage
// the current travel has reached, so the pill reads as "on top" of the
// motion rather than a passive strip. Release commits the stage the drag
// last held; a flick past the mission line commits Mission Control even from
// a short-but-fast throw.
//
// Discovery: releasing short of REVEAL three times in a row (without ever
// finding Mission Control) nudges the handle with a brief pulse the first
// session only Persistent.states.navigation.missionControlDiscovered gates
// it off for good once the gesture is found. This is the Souveraine analogue
// of Launcher3's AllAppsEduView triple-swipe hint, minus the full overlay.
//
// When the on-screen keyboard is summoned the rail rides ATOP it (bottom
// margin = the keyboard's measured layer height, probed from
@ -85,26 +102,84 @@ PanelWindow {
}
}
// --- Gesture stages -----------------------------------------------------
// Upward travel, in px, at which each stage arms. The drag must climb
// past REVEAL_AT to show the dock and past MISSION_AT to reach Mission
// Control; releasing between the two commits the lower stage.
readonly property int revealAt: 48
readonly property int missionAt: 190
// Live upward travel of the in-flight drag (0 while idle, grows as the
// finger climbs). Drives the handle's appearance so the pill tracks the
// motion. Only ever set by the MouseArea below.
property real dragTravel: 0
property bool dragging: false
// Stage the current travel has reached: 0 none, 1 reveal, 2 mission.
readonly property int dragStage: dragTravel >= missionAt ? 2
: dragTravel >= revealAt ? 1 : 0
Rectangle {
id: handle
anchors.centerIn: parent
width: 150
height: 7
anchors.horizontalCenter: parent.horizontalCenter
// Ride upward with the drag so the pill leads the motion, clamped so
// it stays inside the rail's own height.
y: (parent.height - height) / 2
- Math.min(rail.dragTravel * 0.12, (parent.height - height) / 2)
// Grow with travel; jump wider once a stage arms so the escalation is
// felt, not just seen.
width: (rail.dragStage >= 2 ? 190
: rail.dragStage >= 1 ? 170 : 150)
+ Math.min(rail.dragTravel * 0.08, 24)
height: 7 + (rail.dragStage >= 2 ? 3 : rail.dragStage >= 1 ? 1 : 0)
radius: height / 2
color: "#e6ffffff"
// Brighten and tint toward the accent as the drag escalates. Stage 2
// pulls the handle to the theme accent the "you've reached Mission
// Control" tell.
color: rail.dragStage >= 2
? (Appearance?.colors?.colPrimary ?? "#a0c8ff")
: "#e6ffffff"
opacity: rail.dragging ? 1 : 0.9
// Idle nudge for the discovery hint: a brief scale pulse.
scale: 1
SequentialAnimation {
id: discoveryPulse
running: false
loops: 2
NumberAnimation { target: handle; property: "scale"; to: 1.18; duration: 160; easing.type: Easing.OutQuad }
NumberAnimation { target: handle; property: "scale"; to: 1.0; duration: 220; easing.type: Easing.InOutQuad }
}
Behavior on width {
enabled: !rail.dragging
NumberAnimation { duration: 120 }
}
Behavior on color {
ColorAnimation { duration: 140 }
}
Behavior on y {
enabled: !rail.dragging
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
}
}
MouseArea {
id: gestureArea
anchors.fill: parent
property real startY: 0
property real startAt: -1
property real lastTapAt: -1
// Consecutive upward attempts that fell short of REVEAL without ever
// reaching Mission Control feeds the discovery nudge.
property int shortSwipes: 0
readonly property int tapSlop: 24
readonly property int swipeDistance: 48
readonly property int doubleTapInterval: 350
// Fast throws commit the stage above their travel: a short-but-quick
// flick past this speed (px/ms) escalates one stage.
readonly property real flickSpeed: 1.1
function toggleFullscreen(): void {
GlobalStates.dockRevealed = false
@ -116,32 +191,81 @@ PanelWindow {
`hl.dsp.window.fullscreen({ window = "address:${address}", mode = "fullscreen", action = "toggle" })`])
}
// Commit an upward gesture. `stage` is the stage the drag settled on
// (0/1/2). Higher stages imply the lower one, so Mission Control also
// reveals the dock beneath it.
function commitUp(stage: int): void {
lastTapAt = -1
if (stage >= 1) {
GlobalStates.dockSuppressed = false
GlobalStates.dockRevealed = true
}
if (stage >= 2) {
GlobalStates.missionControlOpen = true
if (!Persistent.states.navigation.missionControlDiscovered)
Persistent.states.navigation.missionControlDiscovered = true
shortSwipes = 0
}
}
onPressed: mouse => {
startY = mouse.y
handle.width = 170
startAt = Date.now()
rail.dragging = true
rail.dragTravel = 0
}
onPositionChanged: mouse => {
if (!rail.dragging)
return
// Upward travel is positive; downward drags leave travel at 0 so
// the handle doesn't chase a dismiss gesture.
rail.dragTravel = Math.max(0, startY - mouse.y)
}
onReleased: mouse => {
handle.width = 150
const wasDragging = rail.dragging
rail.dragging = false
const travel = Math.max(0, startY - mouse.y)
const deltaY = mouse.y - startY
const elapsed = Math.max(1, Date.now() - startAt)
const speed = travel / elapsed // px/ms, upward only
rail.dragTravel = 0
if (deltaY <= -swipeDistance) {
lastTapAt = -1
GlobalStates.dockSuppressed = false
GlobalStates.dockRevealed = true
// Upward: pick the committed stage from travel, then let a fast
// flick promote it one step (a quick short throw still reaches
// Mission Control).
if (travel >= rail.revealAt || (speed >= flickSpeed && travel >= rail.tapSlop)) {
let stage = travel >= rail.missionAt ? 2 : travel >= rail.revealAt ? 1 : 0
if (speed >= flickSpeed && stage < 2)
stage += 1
commitUp(stage)
return
}
if (deltaY >= swipeDistance) {
// Downward: peel the nearest surface (keyboard, then dock).
if (deltaY >= rail.revealAt) {
lastTapAt = -1
// Nearest surface first: keyboard, then a visible dock.
if (GlobalStates.oskOpen) {
GlobalStates.oskOpen = false
return
}
if (GlobalStates.missionControlOpen) {
GlobalStates.missionControlOpen = false
return
}
GlobalStates.dockRevealed = false
GlobalStates.dockSuppressed = true
return
}
// Neither committed: an upward attempt that fell short of REVEAL
// counts toward the discovery nudge; anything else resets it.
if (travel > tapSlop) {
lastTapAt = -1
maybeNudgeDiscovery()
return
}
if (Math.abs(deltaY) > tapSlop) {
lastTapAt = -1
return
@ -156,8 +280,21 @@ PanelWindow {
}
}
// Count a short upward attempt; after three in a row, and only until
// the gesture has been discovered, pulse the handle once.
function maybeNudgeDiscovery(): void {
if (Persistent.states.navigation.missionControlDiscovered)
return
shortSwipes += 1
if (shortSwipes >= 3) {
shortSwipes = 0
discoveryPulse.restart()
}
}
onCanceled: {
handle.width = 150
rail.dragging = false
rail.dragTravel = 0
lastTapAt = -1
}
}