Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/SessionEvents.qml

333 lines
16 KiB
QML

// Souveraine logind event ingress — the shell's ears for external lock and
// suspend requests.
//
// logind is the system session manager. Other software (a phone's power
// button daemon, a remote SSH session, `loginctl lock-session`) can ask it
// to lock or suspend at any time. Without this file the shell only knows
// about locks it initiates itself; an external lock request would arrive as
// a D-Bus signal that nothing listens to, and the phone would suspend into
// an unlocked session.
//
// Architecture: two `gdbus monitor` processes (one for the manager bus, one
// for the session bus) emit lines that SplitParser splits into individual
// D-Bus signal frames. This is the same Process + SplitParser pattern that
// Souveraine.qml uses for the SSE turn stream — a long-lived child whose
// stdout is line-delimited and parsed in-process.
//
// The sleep delay inhibitor is a `systemd-inhibit --what=sleep --mode=delay
// sleep infinity` process that holds the logind delay inhibitor slot from
// shell startup. Kill it to release (allowing suspend); restart it to
// reacquire (blocking suspend again after wake). This is the same pattern
// Idle.qml uses for hypridle: a background Process whose lifecycle IS the
// inhibitor's lifecycle.
//
// The trust boundary is the same as Session.qml: this surface is local,
// single-user, reachable only over quickshell's IPC socket. The D-Bus
// signals it listens to are system-bus signals that any session member can
// see; this file simply acts on them before logind times out its inhibitor
// delay (typically 5s).
//
// See: session-inhibitors.md, suspend-before-lock.md, capability-tiers.md
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
// --- Properties --------------------------------------------------------
// The resolved logind session path (e.g. "/org/freedesktop/login1/session/_3").
// Empty until the resolver Process completes. The session monitor does not
// start until this is set, because the D-Bus object path depends on it.
property string sessionPath: ""
// Whether the sleep delay inhibitor is currently held. True from shell
// startup; released only when PrepareForSleep(true) arrives and the
// session is locked (or about to be). Reacquired on PrepareForSleep(false).
property bool sleepInhibitorHeld: true
// --- Signals -----------------------------------------------------------
// Emitted on every PrepareForSleep signal. `suspending` is true when the
// system is about to sleep, false when it has woken. Listeners can use
// this to pause/resume network activity, dim screens, etc.
signal prepareForSleep(bool suspending)
// Emitted when an external Lock signal arrives from logind (e.g.
// `loginctl lock-session` from another process, or a power-button daemon
// that locks before suspend).
signal sessionLockRequested()
// Emitted when the delay inhibitor is released to let suspend proceed.
// This happens after WlSessionLock.secure confirms the compositor has
// locked the session, or immediately if the session was already secure.
signal sleepInhibitorReleased()
// Emitted when the delay inhibitor is reacquired after wake. The system
// is no longer suspending, and the shell is blocking suspend again until
// the next PrepareForSleep(true) cycle.
signal sleepInhibitorAcquired()
// --- Session path resolution -------------------------------------------
// The logind session D-Bus object path contains the session ID with dots
// replaced by underscores (session "3" -> /org/freedesktop/login1/session/_3,
// session "c1" -> /org/freedesktop/login1/session/_c1). We resolve it once
// at startup by asking logind for the current session's object path.
Process {
id: sessionResolver
running: true
command: ["sh", "-c",
// loginctl list-sessions -p Name gives the first session's user;
// show-session with no argument operates on the caller's session.
// -p ObjectPath gives the D-Bus path directly, no encoding needed.
"loginctl show-session -p ObjectPath 2>/dev/null | head -1"]
stdout: StdioCollector {
onStreamFinished: {
const raw = text.trim();
// loginctl outputs ObjectPath=/org/freedesktop/login1/session/_3
const match = raw.match(/^ObjectPath=(.+)$/);
if (match && match[1].length > 0) {
root.sessionPath = match[1];
console.log("[session-events] session path:", root.sessionPath);
sessionLockMonitor.running = true;
} else if (raw.length > 0) {
// Some logind versions print just the path without the key.
if (raw.startsWith("/org/freedesktop/login1/session/")) {
root.sessionPath = raw;
console.log("[session-events] session path (bare):", root.sessionPath);
sessionLockMonitor.running = true;
} else {
console.log("[session-events] could not parse session path from:", raw);
}
} else {
console.log("[session-events] no session path returned; "
+ "external lock signals will not be monitored");
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
console.log("[session-events] session path probe failed (exit " + exitCode
+ "); external lock monitoring disabled");
}
}
}
// --- Sleep delay inhibitor ---------------------------------------------
// systemd-inhibit with --mode=delay holds a delay inhibitor on the sleep
// verb. logind allows a configurable delay (typically 5s) before forcing
// suspend, giving the shell time to lock the session securely. The
// process runs `sleep infinity` so it stays alive until we kill it.
//
// Kill this process = release the inhibitor = logind may proceed with
// suspend. Restart it = reacquire = suspend is blocked again.
//
// This is deliberately NOT wired through Session.inhibit() yet. Session.qml
// now supports "sleep" kind, but the delay-mode inhibitor must be held from
// startup and released reactively on PrepareForSleep — it does not follow
// the cookie-based request/release pattern that Session.inhibit() exposes.
// Managing the process here keeps the sleep path working without coupling
// the reactive suspend flow to the IPC-facing inhibitor API.
Process {
id: sleepInhibitor
running: true
command: ["systemd-inhibit", "--what=sleep", "--mode=delay",
"--who=Souveraine Shell",
"--why=Delay suspend until WlSessionLock.secure confirms session is locked",
"sleep", "infinity"]
onExited: (exitCode, exitStatus) => {
// Unexpected exit while we think the inhibitor is held. This can
// happen if systemd-inhibit is not installed, or if logind
// restarted and invalidated the inhibitor fd. Log it; the next
// PrepareForSleep(true) will find no inhibitor and suspend will
// proceed immediately — which is the safe degradation: the phone
// sleeps instead of draining its battery blocking a suspend that
// will never complete.
if (root.sleepInhibitorHeld) {
console.log("[session-events] sleep inhibitor exited unexpectedly (exit "
+ exitCode + "); suspend will no longer be delayed");
root.sleepInhibitorHeld = false;
}
}
}
function releaseSleepInhibitor() {
if (!root.sleepInhibitorHeld) return;
// Setting running=false sends SIGTERM to the process. `sleep infinity`
// exits cleanly on SIGTERM, which releases the systemd-inhibit fd.
sleepInhibitor.running = false;
root.sleepInhibitorHeld = false;
root.sleepInhibitorReleased();
console.log("[session-events] sleep inhibitor released; suspend may proceed");
}
function reacquireSleepInhibitor() {
if (root.sleepInhibitorHeld) return;
sleepInhibitor.running = true;
root.sleepInhibitorHeld = true;
root.sleepInhibitorAcquired();
console.log("[session-events] sleep inhibitor reacquired; suspend blocked");
}
// --- D-Bus monitor: PrepareForSleep ------------------------------------
// Watches org.freedesktop.login1.Manager for the PrepareForSleep signal.
// This signal carries a boolean: true = suspending, false = waking.
//
// gdbus monitor output format:
// /org/freedesktop/login1.Manager: org.freedesktop.login1.Manager.PrepareForSleep (true,)
//
// We parse the boolean from the signal arguments. The object path line
// is also emitted by gdbus but SplitParser gives us one line at a time,
// so the signal name line is what carries the argument.
Process {
id: sleepMonitor
running: true
command: ["gdbus", "monitor", "--system",
"--dest", "org.freedesktop.login1",
"--object-path", "/org/freedesktop/login1"]
stdout: SplitParser {
onRead: data => {
if (data.length === 0) return;
if (!data.includes("PrepareForSleep")) return;
// Extract the boolean from the parenthesized argument list.
// The format is "(true,)" or "(false,)" — the trailing comma
// is gdbus's tuple representation of a single-argument signal.
const suspending = data.includes("(true,");
root.prepareForSleep(suspending);
console.log("[session-events] PrepareForSleep:", suspending ? "suspending" : "waking");
if (suspending) {
root.onSuspendRequested();
} else {
root.onWoken();
}
}
}
onExited: (exitCode) => {
console.log("[session-events] sleep monitor exited (exit " + exitCode
+ "); PrepareForSleep signals will not be received");
}
}
// --- D-Bus monitor: Session Lock ---------------------------------------
// Watches the current session's object path for the Lock signal. This
// signal has no arguments — it is a notification that something asked
// logind to lock this session.
//
// The monitor does not start until sessionPath is resolved, because the
// object path is session-specific. If resolution fails, this monitor
// stays disabled and only shell-initiated locks work.
Process {
id: sessionLockMonitor
command: root.sessionPath.length > 0
? ["gdbus", "monitor", "--system",
"--dest", "org.freedesktop.login1",
"--object-path", root.sessionPath]
: ["false"] // never runs; guarded by sessionPath check
stdout: SplitParser {
onRead: data => {
if (data.length === 0) return;
if (!data.includes("org.freedesktop.login1.Session.Lock")) return;
// Avoid reacting to our own lock-session notification.
// Session.lock() calls loginctl lock-session, which emits
// this same signal back at us. If the session is already
// locked (or lock was requested), this is an echo, not a
// new external request.
if (GlobalStates.screenLocked) return;
console.log("[session-events] external Lock signal received");
root.sessionLockRequested();
Session.lock();
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
console.log("[session-events] session lock monitor exited (exit " + exitCode
+ "); external lock signals will not be received");
}
}
}
// --- Suspend/lock coordination -----------------------------------------
// The core logic: when logind tells us the system is about to suspend,
// we must ensure the session is locked BEFORE we release the delay
// inhibitor. If the session is already secure (WlSessionLock confirmed),
// release immediately. If not, request a Wayland lock and wait for
// LockScreen.qml to confirm secure — then release.
//
// This is the suspend-before-lock protocol documented in
// suspend-before-lock.md: PrepareForSleep(true) -> request lock ->
// wait for screenLockSecure -> release inhibitor -> logind proceeds.
// Whether we requested a lock as part of the suspend path. Used to
// distinguish "we locked for suspend" from "we woke up and should not
// unlock". The lock persists across suspend/resume; only the human
// unlocks via the credential gate.
property bool _lockForSuspend: false
function onSuspendRequested() {
if (GlobalStates.screenLockSecure) {
// Session is already secure — the compositor has confirmed the
// lock surface. Release the inhibitor immediately so logind can
// proceed with suspend within its delay window.
root.releaseSleepInhibitor();
} else {
// Session is not yet secure. Request a Wayland lock; the
// Connections block below watches for screenLockSecure and will
// release the inhibitor when it arrives. We set the flag so the
// Connections handler knows this lock came from the suspend path.
root._lockForSuspend = true;
if (!GlobalStates.screenLocked) {
Session.lock();
}
// If screenLocked is true but screenLockSecure is not yet true,
// the lock has been requested but the compositor has not acked.
// The Connections handler will catch the ack.
console.log("[session-events] lock requested for suspend; "
+ "waiting for WlSessionLock.secure");
}
}
function onWoken() {
// The system has woken from suspend. Reacquire the delay inhibitor
// so the next PrepareForSleep(true) is again blocked until the
// session is secure. The lock persists — we do not unlock on wake.
// Only the human credential gate (PIN pad) unlocks.
root._lockForSuspend = false;
root.reacquireSleepInhibitor();
}
// Watch for the compositor confirming the lock surface. This is the
// signal from LockScreen.qml's WlSessionLock.onSecureChanged handler,
// mediated through GlobalStates.screenLockSecure. If we are on the
// suspend path (lock requested for suspend, inhibitor still held), the
// secure confirmation means the session is now safe to suspend.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (!GlobalStates.screenLockSecure) return;
if (!root._lockForSuspend) return;
if (!root.sleepInhibitorHeld) return;
// WlSessionLock.secure confirms the compositor has replaced the
// session surfaces with the lock surface. The session is now
// secure; release the delay inhibitor so logind can suspend.
root._lockForSuspend = false;
root.releaseSleepInhibitor();
}
}
Timer {
id: initLogTimer
interval: 0
repeat: false
running: true
onTriggered: console.log("[session-events] initialized; sleep inhibitor held="
+ root.sleepInhibitorHeld)
}
}