Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/common/functions/Session.qml

440 lines
21 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 QtQuick
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
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 })
// Prefer logind (owns the session, works under elogind) and fall back to
// systemctl. Returns [] when neither exists, which callers treat as a
// refusal rather than firing a command that cannot work.
function powerCommand(action) {
if (root.hasLoginctl) return ["loginctl", action];
if (root.hasSystemctl) return ["systemctl", 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.
GlobalStates.screenLocked = true;
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}`]);
}
// --- 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 — SessionScreen.qml (session verbs)
// session — Session.qml (this file — state, lock, unlock, suspend, ...)
// 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,
// so the audit log fires via a zero-delay Timer instead.
Timer {
id: bootAuditTimer
interval: 0
repeat: false
running: true
onTriggered: {
console.log("[session] boot IPC audit — shell exposes: "
+ "lock, lock2, dock, sidebar, session, overview, keyboard, appInventory");
}
}
}