Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/common/functions/Session.qml
Fimeg 94988138b4 lock hint: resolve the seat's session, and replay the report
LockedHint was never set on this device, so sessiond's `locked` (which
comes from logind per doctrine §4) was permanently false, request_blank()
timed out LOCK_ACK_BUDGET on every blank, and the panel went dark on a
session nobody could confirm was locked. LOCK-DPMS-LESSONS §1 held in the
code and not on the glass — every blank recorded blank-without-lock.

Three faults, measured 2026-08-02:

- Session.qml wrote the hint through /session/auto, which is the caller's
  own session. The shell is not in the session that owns the seat:
  viewtop in logind 66 (seat0/tty1), qs in 70. Written to one session,
  read from another.
- lockhint.rs resolved via User.Display, which an ssh login is enough to
  point at a seatless remote session — and which then went stale when
  that session ended. Both sides now ask seat0 for its active session.
- The report was dropped outright. hasLoginctl starts false and only
  flips when a Process probe returns; secure=true landed 30s earlier, so
  the one edge that mattered hit the guard and was never retried, because
  the shell locks once at boot and never changes again. Fourth
  edge-vs-level bug after locked_ack, ChargeRate and bootBloomActive.

Verified on hardware: LockedHint now goes yes ~2s after a power tap.
2026-08-02 15:26:37 -04:00

578 lines
29 KiB
QML

// Souveraine fork of ii's stock Session.qml.
//
// Upstream ii's Session is a set of fire-and-forget verbs:
// Quickshell.execDetached(["bash", "-c", "systemctl poweroff || loginctl poweroff"])
// That is fine for a desktop where a failed poweroff is visible to the person
// sitting at the keyboard. It is not fine for the phone, where the shell is
// the only session manager and an agent can drive these verbs over IPC. A
// verb that silently does nothing is the worst outcome: the caller believes
// the machine is suspending and it is not.
//
// So this fork keeps every upstream verb (call sites in LockScreen.qml and
// the session menus are unchanged) and adds the parts a real session arbiter
// needs:
//
// 1. Capability detection. We query logind's Can* methods once at startup
// instead of treating a command being installed, or /sys/power/state
// advertising "disk", as proof that an action is usable.
// caps() reports what this machine can actually do, so a caller can ask
// before it acts and the session menu can grey out what is unavailable.
//
// 2. Honest failure. Upstream execDetached throws the exit code away. Every
// verb here runs through a Process with an onExited that logs
// [session] <verb> failed (exit N) and emits actionFailed(). A wedged
// logind is now a fact in the log, not silence.
//
// 3. Reason-tracked inhibits. Idle.qml's inhibit is a bare bool: something
// is holding the machine awake and nothing records what or why. inhibit()
// takes a reason, returns a cookie, and state() lists every holder. "Why
// is the phone not sleeping" becomes a question with an answer.
//
// 4. State that is re-derived, not cached. `secure` is WlSessionLock's
// compositor acknowledgement; it is distinct from `lockRequested`, the
// shell input that asks WlSessionLock to lock.
//
// The trust boundary here is deliberately trivial and stated so it stays that
// way: this surface is local, single-user, reachable only over quickshell's
// IPC socket by the user who owns the session. It has no remote caller and no
// second operator, so it has no grants, no signing, and no nonces. If it ever
// grows a network-reachable caller, that assumption is what breaks first.
pragma Singleton
import qs
import qs.services
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
Singleton {
id: root
// --- Capabilities ------------------------------------------------------
// Probed once, at startup. Until the probe returns, every capability reads
// false: better to refuse a suspend we are unsure of than to fire a verb
// into a machine that cannot honor it.
property bool probed: false
property bool hasLoginctl: false
property bool hasSystemctl: false
property string suspendCapability: "unknown"
property string hibernateCapability: "unknown"
property string poweroffCapability: "unknown"
property string rebootCapability: "unknown"
// logind is the preferred backend when present: it is the thing that
// actually owns the session, and it works under elogind as well as
// systemd. systemctl is the fallback for the poweroff/reboot verbs.
// "challenge" means logind can do it after polkit authentication. It is
// available to a normal desktop session with a functioning polkit agent,
// but callers still learn that a prompt may be required through caps().
readonly property bool canSuspend: ["yes", "challenge"].includes(root.suspendCapability)
readonly property bool canHibernate: ["yes", "challenge"].includes(root.hibernateCapability)
readonly property bool canPoweroff: ["yes", "challenge"].includes(root.poweroffCapability)
readonly property bool canReboot: ["yes", "challenge"].includes(root.rebootCapability)
// Live compositor acknowledgement, mirrored from WlSessionLock.secure by
// LockScreen.qml. `screenLocked` remains the requested state that drives
// the lock surface; do not treat it as proof that the session is secure.
readonly property bool locked: GlobalStates.screenLockSecure
// Report the SECURE lock state to logind as the session's LockedHint —
// the freedesktop contract's state half (loginctl lock-session above is
// only the request half). This makes `LockedHint` truthful device-wide,
// so logind-aware apps (culver stops its Matrix sync, media players
// pause, recorders blank) can watch one standard property instead of
// each growing a bespoke shell IPC. Reporting the secure edge — not the
// requested edge — means the hint never claims locked before the
// compositor holds the lock. set-locked-hint is not a Lock signal, so
// the hypridle echo storm documented in lock() cannot recur through it.
onLockedChanged: {
if (root.hasLoginctl)
lockedHintProc.report(root.locked);
}
// Replay the hint once the capability probe lands.
//
// `hasLoginctl` starts false and only becomes true when `capabilityProbe`
// returns, which is a `Process` round trip. The lock goes secure long
// before that: measured 2026-08-02, `secure=true` at 15:21:07 and
// `loginctl=true` at 15:21:37 — thirty seconds later. The one edge that
// mattered was therefore dropped by the guard above and never retried,
// because the shell locks once at boot and `locked` never changes again.
//
// The cost was the whole lock-before-blank invariant. `LockedHint` stayed
// `no`, so sessiond's `locked` (which comes from logind, doctrine §4) was
// permanently false, `request_blank()` timed out its `LOCK_ACK_BUDGET`
// every time, and the panel blanked on a session nobody could confirm was
// locked — `blank-without-lock` on every single blank.
//
// This is the fourth edge-vs-level bug in this system after `locked_ack`,
// `ChargeRate` and `bootBloomActive`. A guard that drops a report must
// replay it when the guard opens, or the report is only ever delivered by
// luck of ordering.
onHasLoginctlChanged: {
if (root.hasLoginctl)
lockedHintProc.report(root.locked);
}
Process {
id: lockedHintProc
property bool pending: false
property bool pendingValue: false
function report(value) {
if (running) {
// Coalesce: remember the latest value, replay on exit.
pending = true;
pendingValue = value;
return;
}
// Resolve the session the way `lockhint.rs` does — the user's
// Display session — and never through the `/session/auto` alias.
//
// `auto` means *the caller's own* session, and the shell is not in
// the session that owns the seat. Measured 2026-08-02: viewtop in
// logind session 66 (seat0, tty1, the one `User.Display` names and
// the one sessiond watches), `qs -c souveraine` in session 70. So
// this call was setting the hint on a session nobody reads, while
// the graphical session's `LockedHint` stayed `no` forever.
//
// The consequence was not cosmetic. sessiond takes `locked` from
// `LockedHint` (doctrine §4), so `locked` was permanently false;
// `request_blank()` therefore timed out its `LOCK_ACK_BUDGET` on
// every single blank and took the fail-open branch, darkening the
// panel on a session it could not confirm was locked and writing
// `blank-without-lock` each time. LOCK-DPMS-LESSONS §1's ordering
// held in the code and not on the device.
//
// One shell round trip rather than two Processes: the path has to
// be resolved at report time, because the session id changes across
// a greetd restart and a cached one would be stale exactly when it
// matters. `$(…)` is fine here — the value is a busctl-printed
// object path, not user input.
// The seat's active session — the one on the glass — with
// `User.Display` only as a fallback. `lockhint.rs` resolves the
// same way on the read side, and the two must agree or this writes
// a hint nobody reads. An ssh login is enough to make `Display`
// name a seatless remote session, which is exactly how this was
// found.
command = ["sh", "-c",
"p=$(busctl get-property org.freedesktop.login1 " +
"/org/freedesktop/login1/seat/seat0 " +
"org.freedesktop.login1.Seat ActiveSession " +
"| grep -o '/org/freedesktop/login1/session/[^\"]*'); " +
"[ -n \"$p\" ] || p=$(busctl get-property " +
"org.freedesktop.login1 " +
"/org/freedesktop/login1/user/_$(id -u) " +
"org.freedesktop.login1.User Display " +
"| grep -o '/org/freedesktop/login1/session/[^\"]*'); " +
"[ -n \"$p\" ] || exit 1; " +
"exec busctl call org.freedesktop.login1 \"$p\" " +
"org.freedesktop.login1.Session SetLockedHint b " +
(value ? "true" : "false")];
running = true;
}
onExited: {
if (pending) {
pending = false;
report(pendingValue);
}
}
}
signal actionFailed(string action, int exitCode)
Process {
id: capabilityProbe
// Runs at construction: the probe must land before anything asks
// caps(), and every capability reads false until it does.
running: true
// One shell, one round trip. logind's Can* methods incorporate the
// policy and configuration that /sys/power/state cannot see (notably
// swap/resume setup for hibernation). Possible values include yes,
// no, challenge, and na; retain the value rather than flattening it.
// busctl prints `s "challenge"`; awk pulls the second field verbatim
// and the quotes come off in JS below. An earlier version parsed it
// with sed inside single quotes, where sh does not process the \" and
// sed ended up matching a literal backslash-quote that busctl never
// emits — so on the phone the probe returned nothing and every
// capability stuck at "unknown". Keep the shell here quote-free; do
// the string work in QML where there is no second escaping layer.
command: ["sh", "-c",
"command -v loginctl >/dev/null && echo loginctl; " +
"command -v systemctl >/dev/null && echo systemctl; " +
"if command -v busctl >/dev/null; then " +
"for cap in CanSuspend CanHibernate CanPowerOff CanReboot; do " +
"value=$(busctl --system call org.freedesktop.login1 /org/freedesktop/login1 " +
"org.freedesktop.login1.Manager $cap 2>/dev/null | awk '{print $2}'); " +
"[ -n \"$value\" ] && echo $cap=$value; " +
"done; " +
"fi; " +
"true"]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.split("\n").map(l => l.trim());
root.hasLoginctl = lines.includes("loginctl");
root.hasSystemctl = lines.includes("systemctl");
const capability = (name) => {
const prefix = name + "=";
const line = lines.find(l => l.startsWith(prefix));
// Value arrives quoted from busctl (e.g. "challenge").
return line ? line.slice(prefix.length).replace(/"/g, "") : "unknown";
};
root.suspendCapability = capability("CanSuspend");
root.hibernateCapability = capability("CanHibernate");
root.poweroffCapability = capability("CanPowerOff");
root.rebootCapability = capability("CanReboot");
root.probed = true;
console.log("[session] capabilities:",
"loginctl=" + root.hasLoginctl,
"systemctl=" + root.hasSystemctl,
"suspend=" + root.suspendCapability,
"hibernate=" + root.hibernateCapability,
"poweroff=" + root.poweroffCapability,
"reboot=" + root.rebootCapability);
}
}
}
// --- Verb runner -------------------------------------------------------
// Every power verb goes through here so that none of them can fail
// silently. Upstream used execDetached, which cannot report an exit code.
Process {
id: verbProc
property string verb: ""
onExited: (exitCode, exitStatus) => {
root.lastAction = {
action: verbProc.verb,
status: exitCode === 0 ? "succeeded" : "failed",
exitCode: exitCode
};
if (exitCode !== 0) {
console.log(`[session] ${verbProc.verb} failed (exit ${exitCode})`);
root.actionFailed(verbProc.verb, exitCode);
}
}
}
function runVerb(verb, argv) {
// Process has one command slot. Overwriting it while a prior action
// is still running makes the eventual exit code belong to the wrong
// action, which is another form of silent failure.
if (verbProc.running) return false;
verbProc.verb = verb;
verbProc.command = argv;
root.lastAction = { action: verb, status: "running", exitCode: null };
verbProc.running = true;
return true;
}
// IPC returns when an action is accepted, not when the kernel has already
// suspended or powered off. This records the later Process outcome so a
// caller can distinguish "started" from "succeeded".
property var lastAction: ({ action: "", status: "idle", exitCode: null })
// systemctl owns the power verbs, NOT loginctl. loginctl only manages
// sessions/users/seats — `loginctl poweroff` exits 1 with "Unknown
// command verb" (verified on systemd 261, and its --help lists no power
// commands at all). Preferring loginctl here silently broke poweroff,
// reboot, suspend and hibernate from every shell surface: the button
// fired, the Process exited 1, and the phone stayed on (2026-07-20).
//
// The capability probe still asks logind over D-Bus (CanPowerOff etc.) —
// that part was always right; logind owns the *policy*. It just isn't
// the CLI that carries out the action.
function powerCommand(action) {
if (root.hasSystemctl) return ["systemctl", action];
// elogind ships loginctl with power verbs and usually no systemctl;
// only reachable on such a system, where these verbs do exist.
if (root.hasLoginctl) return ["loginctl", action];
return [];
}
// --- Inhibits ----------------------------------------------------------
// A bare "something is holding the machine awake" bool cannot answer the
// only question that matters when the phone will not sleep: WHAT is
// holding it, and why. Each holder gets a cookie and carries a reason.
property var inhibitors: ({})
property int nextCookie: 1
readonly property bool inhibited: Object.keys(root.inhibitors).length > 0
function inhibit(what, reason) {
const kind = String(what || "idle").trim().toLowerCase();
const why = String(reason || "").trim();
if (!why) return root.refuse("inhibit", "an inhibit must carry a reason");
// idle and sleep are wired today. idle uses the Wayland/hypridle
// mechanism via Idle.qml; sleep uses SessionEvents' delay-mode
// systemd-inhibit. Recording logout/user-switch without applying
// their mechanism would create a dangerous success-shaped no-op.
if (kind !== "idle" && kind !== "sleep")
return root.refuse("inhibit", `unsupported inhibit kind ${kind}; only idle and sleep are implemented`);
const cookie = String(root.nextCookie++);
// Reassign rather than mutate: QML only notifies on assignment, so an
// in-place insert would leave `inhibited` and any binding on it stale.
const next = Object.assign({}, root.inhibitors);
next[cookie] = { what: kind, reason: why };
root.inhibitors = next;
console.log(`[session] inhibit ${cookie}: ${kind} ${why}`);
root.applyInhibits();
return { ok: true, cookie: cookie };
}
function uninhibit(cookie) {
if (!root.inhibitors[cookie])
return root.refuse("uninhibit", `no inhibitor with cookie ${cookie}`);
const next = Object.assign({}, root.inhibitors);
delete next[cookie];
root.inhibitors = next;
console.log(`[session] uninhibit ${cookie}`);
root.applyInhibits();
return { ok: true };
}
// Any holder inhibiting "idle" keeps the machine awake. Idle.qml owns the
// mechanism (it knows the hypridle quirk on this device); we own the
// policy of who is asking and why.
//
// "sleep" inhibitors are managed by SessionEvents (delay-mode
// systemd-inhibit). They don't need a mechanism toggle here —
// SessionEvents holds the inhibitor from startup and releases it
// only when PrepareForSleep(true) fires and the Wayland lock is secure.
function applyInhibits() {
const wantIdle = Object.values(root.inhibitors).some(i => i.what === "idle");
Idle.toggleInhibit(wantIdle);
}
// --- State -------------------------------------------------------------
// The projection an agent reads. Everything here is re-derived at call
// time; nothing is a bool we set ourselves and then trusted.
function state() {
const holders = Object.keys(root.inhibitors).map(c => ({
cookie: c,
what: root.inhibitors[c].what,
reason: root.inhibitors[c].reason
}));
return {
locked: root.locked,
lockRequested: GlobalStates.screenLocked,
idle: {
stage: IdleCoordinator.state,
nativeCoordinatorEnabled: IdleCoordinator.nativeEnabled
},
idleInhibited: Idle.inhibit,
inhibitors: holders,
lastAction: root.lastAction,
capabilities: root.caps(),
stepUp: typeof StepUpAuth !== "undefined" ? StepUpAuth.state() : null,
sleepInhibitorHeld: typeof SessionEvents !== "undefined" ? SessionEvents.sleepInhibitorHeld : null
};
}
// `probed` is not decoration: until the probe lands every capability reads
// false, and false-because-unknown is not the same claim as
// false-because-unsupported. A caller that ignores `probed` during the
// startup window would conclude this machine cannot suspend at all. Check
// `probed` before believing a false.
function caps() {
return {
probed: root.probed,
suspend: root.canSuspend,
suspendStatus: root.suspendCapability,
hibernate: root.canHibernate,
hibernateStatus: root.hibernateCapability,
poweroff: root.canPoweroff,
poweroffStatus: root.poweroffCapability,
reboot: root.canReboot,
rebootStatus: root.rebootCapability,
inhibitors: ["idle", "sleep"]
};
}
// --- Verbs -------------------------------------------------------------
// Every upstream ii verb is preserved by name and behavior, so existing
// call sites (LockScreen.qml's poweroff/reboot on the lock's power action,
// the session menus) keep working. What changed is that they now refuse
// honestly when the machine cannot do the thing, and log when it fails.
//
// Those call sites are all statements — `onClicked: Session.suspend()` —
// so they ignore the returned {ok, reason}. That is fine for the IPC
// caller, which reads the value, but it means a UI button that hits a
// refusal would otherwise do nothing at all, silently: press hibernate on
// the phone, no swap, nothing happens, no trace. Every refusal therefore
// goes through refuse(), which logs before it returns. A refused verb is
// an event, not a void.
function refuse(verb, reason) {
console.log(`[session] ${verb} refused: ${reason}`);
return { ok: false, reason: reason };
}
function closeAllWindows() {
HyprlandData.windowList.map(w => w.pid).forEach(pid => {
Quickshell.execDetached(["kill", pid]);
});
}
function pauseAllPlayers() {
for (const player of Mpris.players.values) {
if (player.canPause) player.pause();
}
}
function lock() {
// Raise our Wayland lock ourselves: logind's Lock signal is a request
// for session software to lock, not a Wayland lock implementation.
// We also notify logind when it is available so other consumers see
// the standard session event. The safe lock does not depend on that
// asynchronous notification returning successfully.
//
// Notify ONLY on the unlocked->locked edge. hypridle's lock_cmd
// fires on logind's Lock signal, so an unconditional
// `loginctl lock-session` here echoes back through logind ->
// hypridle -> this function forever. Observed on the phone: a
// sustained storm of lock requests (several per second, for hours)
// that re-locked the screen seconds after every unlock and chewed
// battery all night.
const alreadyLocked = GlobalStates.screenLocked;
GlobalStates.screenLocked = true;
if (alreadyLocked) {
return { ok: true, status: "already-locked" };
}
if (root.hasLoginctl) {
const notified = root.runVerb("lock", ["loginctl", "lock-session"]);
return notified
? { ok: true, status: "requested" }
: { ok: true, status: "requested", degraded: "logind notification skipped; another action is running" };
}
return { ok: true, degraded: "no loginctl; locked without logind" };
}
function unlock() {
// Deliberately not a verb an agent gets. Unlocking is the credential
// gate on this device — the only thing standing between a picked-up
// phone and the session. It is refused here so that no IPC caller can
// route around the PIN pad. The human unlocks; nothing else does.
return root.refuse("unlock", "unlock is the credential gate; not remotely callable");
}
function suspend() {
if (!root.probed) return root.refuse("suspend", "capabilities not probed yet");
if (!root.canSuspend) return root.refuse("suspend", "no loginctl or systemctl on this machine");
pauseAllPlayers();
if (!root.runVerb("suspend", root.powerCommand("suspend")))
return root.refuse("suspend", "another session action is still running");
return { ok: true, status: "started" };
}
function hibernate() {
if (!root.probed) return root.refuse("hibernate", "capabilities not probed yet");
// logind validates swap/resume configuration as well as kernel support,
// so a phone without hibernation refuses instead of firing a no-op.
if (!root.canHibernate)
return root.refuse("hibernate", "hibernate unavailable (logind: " + root.hibernateCapability + ")");
pauseAllPlayers();
if (!root.runVerb("hibernate", root.powerCommand("hibernate")))
return root.refuse("hibernate", "another session action is still running");
return { ok: true, status: "started" };
}
function poweroff() {
if (!root.probed) return root.refuse("poweroff", "capabilities not probed yet");
if (!root.canPoweroff) return root.refuse("poweroff", "no loginctl or systemctl on this machine");
closeAllWindows();
if (!root.runVerb("poweroff", root.powerCommand("poweroff")))
return root.refuse("poweroff", "another session action is still running");
return { ok: true, status: "started" };
}
function reboot() {
if (!root.probed) return root.refuse("reboot", "capabilities not probed yet");
if (!root.canReboot) return root.refuse("reboot", "no loginctl or systemctl on this machine");
closeAllWindows();
if (!root.runVerb("reboot", root.powerCommand("reboot")))
return root.refuse("reboot", "another session action is still running");
return { ok: true, status: "started" };
}
function rebootToFirmware() {
if (!root.hasSystemctl)
return root.refuse("rebootToFirmware", "firmware-setup reboot needs systemctl");
closeAllWindows();
if (!root.runVerb("rebootToFirmware", ["systemctl", "reboot", "--firmware-setup"]))
return root.refuse("rebootToFirmware", "another session action is still running");
return { ok: true, status: "started" };
}
function logout() {
closeAllWindows();
// loginctl terminate-session ends the session properly (logind tears
// down the scope and the seat); pkill Hyprland just kills the
// compositor and leaves logind believing the session is alive.
if (root.hasLoginctl) {
if (!root.runVerb("logout", ["loginctl", "terminate-session", ""]))
return root.refuse("logout", "another session action is still running");
return { ok: true, status: "started" };
}
if (!root.runVerb("logout", ["pkill", "-i", "Hyprland"]))
return root.refuse("logout", "another session action is still running");
return { ok: true, status: "started", degraded: "no loginctl; killed the compositor" };
}
function changePassword() {
Quickshell.execDetached(["bash", "-c", `${Config.options.apps.changePassword}`]);
}
function launchTaskManager() {
Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]);
}
// NO IpcHandler here. This file used to register `target: "session"` with
// state/inhibit/uninhibit, and Lock.qml registers the same target with a
// strict superset (those three plus capabilities, lock, unlock, suspend,
// hibernate, poweroff, reboot, logout). Quickshell keeps whichever
// registers first and drops the other with a warning:
//
// QML IpcHandler at Session.qml[470:5]: Handler was registered but will
// not be used because another handler is registered for target session
//
// Which one won was load-order, not design — so `session inhibit` and
// `session uninhibit` were reachable or dead depending on the run, and
// deploy.sh's `session state` was riding the same coin flip. Since every
// verb here exists on Lock.qml's handler, the duplicate is removed rather
// than renamed: one target, one owner. Add new session verbs there.
// --- Boot-time IPC audit ------------------------------------------------
// Logs every IPC endpoint the shell exposes at startup. This is a
// defensive visibility measure — not a gate, not a refusal. It answers
// "what is reachable over the IPC socket?" so a human or an audit tool
// can verify the surface matches intent. The list is static per shell
// config; it does not change at runtime.
//
// Known IPC targets at time of writing:
// lock — LockScreen.qml (activate, focus)
// lock2 — Lock.qml (lock, unlock, toggleLock)
// dock — Dock.qml (launch, pin, unpin, moveStack, ...)
// sidebar — SidebarLeft.qml / SidebarRight.qml
// session — Lock.qml (the sole owner: state, capabilities, lock,
// unlock, suspend, hibernate, poweroff, reboot, logout,
// inhibit, uninhibit). Session.qml's duplicate was removed
// 2026-07-26; SessionScreen.qml's moved to `sessionMenu`.
// overview — Overview.qml
// keyboard — OnScreenKeyboard.qml
// appInventory — AppInventoryScope.qml
//
// If a new IpcHandler is added, this comment must be updated. The
// console.log below is the runtime check; the comment is the human
// audit trail.
// NOTE: Component.onCompleted doesn't work on Singletons in QML,
// and Timer isn't available in this module's import scope. Use a
// Process with onExited instead.
Process {
id: bootAuditProc
running: true
command: ["true"]
onExited: {
console.log("[session] boot IPC audit — shell exposes: "
+ "lock, lock2, dock, sidebar, session, overview, keyboard, appInventory");
}
}
}