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:
parent
8d661d2352
commit
5cb1be06cd
10 changed files with 250 additions and 47 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
31
packaging/arch/souveraine-login1.rules
Normal file
31
packaging/arch/souveraine-login1.rules
Normal 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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue