SessionEvents asked loginctl for -p ObjectPath, which is not a property — it returned empty on every boot, so external lock signals were never monitored and the shell had no unlock ingress. Resolve the graphical session the way lockhint.rs does; not GetSession(auto) (this shell is a session-less user unit) and not the /session/auto alias (PropertiesChanged only fires on the concrete path). SessiondBridge read root.registered to decide whether to re-register on reconnect, but the disconnect branch had already cleared it, so the shell never re-registered after a sessiond restart. Latch it.
338 lines
16 KiB
QML
338 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 -------------------------------------------
|
|
// Resolve the graphical session's REAL object path via the user's `Display`
|
|
// session. sessiond resolves exactly this way; see the two measured traps
|
|
// documented on `resolve_session_path` in src/sessiond/lockhint.rs:
|
|
//
|
|
// Not `auto`. `GetSession("auto")` resolves to the CALLER's session, and
|
|
// this shell is a systemd user unit under user@1000.service, outside any
|
|
// session scope — it has no session of its own to name.
|
|
//
|
|
// Not the `auto` path either. /org/freedesktop/login1/session/auto is an
|
|
// alias, not an object: PropertiesChanged only fires on the concrete
|
|
// path, so monitoring the alias subscribes to something never delivered.
|
|
//
|
|
// The user's `Display` session is the graphical one by definition, whoever
|
|
// asks. The previous probe here asked for a `-p ObjectPath` property that
|
|
// loginctl does not have, so it returned empty on every boot and external
|
|
// lock signals were never monitored at all.
|
|
Process {
|
|
id: sessionResolver
|
|
running: true
|
|
command: ["sh", "-c",
|
|
"busctl --system get-property org.freedesktop.login1 "
|
|
+ "/org/freedesktop/login1/user/_$(id -u) "
|
|
+ "org.freedesktop.login1.User Display 2>/dev/null"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const raw = text.trim();
|
|
// `(so) "1" "/org/freedesktop/login1/session/_31"` — the object
|
|
// path is the second quoted field.
|
|
const path = raw.split('"')[3];
|
|
if (path && path.startsWith("/org/freedesktop/login1/session/")) {
|
|
root.sessionPath = path;
|
|
console.log("[session-events] session path:", root.sessionPath);
|
|
sessionLockMonitor.running = true;
|
|
} else if (raw.length > 0) {
|
|
console.log("[session-events] could not parse Display session from:", raw);
|
|
} else {
|
|
console.log("[session-events] no Display session for this user; "
|
|
+ "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)
|
|
}
|
|
}
|