Watch
1
0
Fork
You've already forked souveraine
0

quickshell: first-party lock/nav/session layer, retire the pill

Grows Souveraine's own surfaces on top of the borrowed ii shell and drops
the separate pill shell in favor of one integrated navigation rail.

Session arbiter (functions/Session.qml): probe logind's Can* methods over
busctl instead of guessing from installed binaries -- the answer carries the
polkit tier (yes/challenge/na), so a swapless phone reports hibernate as na
and refuses honestly rather than firing a verb that no-ops. Verbs run through
a Process that logs exit codes and tracks lastAction; refusals log too. The
busctl output is parsed with awk, not a sed regex buried under four escaping
layers -- the sed version returned nothing on the phone and left every
capability stuck at "unknown" (invisible on the laptop, where timing masked
it). Every structured result is JSON-over-string; quickshell maps a var
return to void.

Lock trust: screenLocked (the shell's lock request) is now distinct from
screenLockSecure (WlSessionLock.secure, the compositor's acknowledgement,
mirrored from LockScreen). Cards that disclose personal data gate on secure,
not on a button press. LockContentPolicy centralizes the ambient/personal/
step-up tiers so no card grows its own private rule.

New first-party namespace modules/souveraine/: LockMediaCard, LockSurfaceHost,
SystemGestureRail -- owned surfaces, not ii patches. IdleCoordinator gives one
staged idle vocabulary (dim/lock) gated behind nativeCoordinatorEnabled, off
until the native Wayland idle-notify is verified on the Pixel compositor;
hypridle stays the adapter. WallpaperAssets selects aspect-aware variants for
phone-vs-laptop display shapes.

Pill retired: pill/shell.qml and PillConfig gone, replaced by NavigationConfig
and the gesture rail. Hyprland starts qs -c souveraine directly; no secondary
shell, no qsConfig flip.

Verified on the phone: session.* reports challenge/na correctly, hibernate
and unlock refuse, inhibit round-trips with its reason.
This commit is contained in:
Fimeg 2026-07-14 20:00:57 -04:00
commit e31c3aaf62
26 changed files with 1374 additions and 251 deletions

View file

@ -12,8 +12,9 @@
// the session menus are unchanged) and adds the parts a real session arbiter
// needs:
//
// 1. Capability detection. We probe loginctl/systemctl/hibernate ONCE at
// startup instead of assuming `systemctl X || loginctl X` will work.
// 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.
//
@ -27,9 +28,9 @@
// 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. locked reads GlobalStates (which
// is bound to WlSessionLock) at call time; we never keep our own "I
// locked it" bool that could drift from what the compositor actually did.
// 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
@ -55,19 +56,26 @@ Singleton {
property bool probed: false
property bool hasLoginctl: false
property bool hasSystemctl: false
property bool canHibernate: 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.
readonly property bool canSuspend: root.hasLoginctl || root.hasSystemctl
readonly property bool canPoweroff: root.hasLoginctl || root.hasSystemctl
readonly property bool canReboot: root.hasLoginctl || root.hasSystemctl
// "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 lock state. Read through, never stored: GlobalStates.screenLocked is
// what WlSessionLock.locked is bound to, so this reports what the
// compositor is actually doing rather than what we last asked it to do.
readonly property bool locked: GlobalStates.screenLocked
// 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)
@ -76,14 +84,27 @@ Singleton {
// 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. Prints three lines: loginctl, systemctl,
// hibernate. /sys/power/state carries "disk" only when hibernation is
// actually available on this kernel, which is the honest test the
// phone has no swap and cannot hibernate, and we must not offer it.
// 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; " +
"grep -qw disk /sys/power/state 2>/dev/null && echo hibernate; " +
"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 {
@ -91,12 +112,24 @@ Singleton {
const lines = text.split("\n").map(l => l.trim());
root.hasLoginctl = lines.includes("loginctl");
root.hasSystemctl = lines.includes("systemctl");
root.canHibernate = lines.includes("hibernate");
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,
"hibernate=" + root.canHibernate);
"suspend=" + root.suspendCapability,
"hibernate=" + root.hibernateCapability,
"poweroff=" + root.poweroffCapability,
"reboot=" + root.rebootCapability);
}
}
}
@ -108,6 +141,11 @@ Singleton {
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);
@ -116,11 +154,22 @@ Singleton {
}
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.
@ -140,14 +189,22 @@ Singleton {
readonly property bool inhibited: Object.keys(root.inhibitors).length > 0
function inhibit(what, reason) {
if (!reason) return root.refuse("inhibit", "an inhibit must carry a 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");
// Only idle is wired today. Recording a sleep/logout/user-switch
// inhibitor without applying its mechanism would create a dangerous
// success-shaped no-op, so reject those until their real backends
// (systemd-inhibit or session policy) land.
if (kind !== "idle")
return root.refuse("inhibit", `unsupported inhibit kind ${kind}; only idle is 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: what || "idle", reason: reason };
next[cookie] = { what: kind, reason: why };
root.inhibitors = next;
console.log(`[session] inhibit ${cookie}: ${what || "idle"} ${reason}`);
console.log(`[session] inhibit ${cookie}: ${kind} ${why}`);
root.applyIdleInhibit();
return { ok: true, cookie: cookie };
}
@ -182,8 +239,14 @@ Singleton {
}));
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()
};
}
@ -197,9 +260,14 @@ Singleton {
return {
probed: root.probed,
suspend: root.canSuspend,
suspendStatus: root.suspendCapability,
hibernate: root.canHibernate,
hibernateStatus: root.hibernateCapability,
poweroff: root.canPoweroff,
reboot: root.canReboot
poweroffStatus: root.poweroffCapability,
reboot: root.canReboot,
rebootStatus: root.rebootCapability,
inhibitors: ["idle"]
};
}
@ -234,17 +302,18 @@ Singleton {
}
function lock() {
// loginctl lock-session is the right door: it tells logind, which
// signals the session, which raises our WlSessionLock. Setting
// GlobalStates directly would lock the surface without logind ever
// knowing the session was locked.
if (root.hasLoginctl) {
root.runVerb("lock", ["loginctl", "lock-session"]);
return { ok: true };
}
// No logind: fall back to raising the lock ourselves. Still a real
// WlSessionLock, just without logind's knowledge of it.
// 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" };
}
@ -260,43 +329,48 @@ Singleton {
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();
root.runVerb("suspend", root.powerCommand("suspend"));
return { ok: true };
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");
// The phone has no swap: /sys/power/state carries no "disk", so this
// refuses instead of firing a hibernate that would quietly do nothing.
// 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", "no hibernate support (no disk in /sys/power/state)");
return root.refuse("hibernate", "hibernate unavailable (logind: " + root.hibernateCapability + ")");
pauseAllPlayers();
root.runVerb("hibernate", root.powerCommand("hibernate"));
return { ok: true };
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();
root.runVerb("poweroff", root.powerCommand("poweroff"));
return { ok: true };
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();
root.runVerb("reboot", root.powerCommand("reboot"));
return { ok: true };
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();
root.runVerb("rebootToFirmware", ["systemctl", "reboot", "--firmware-setup"]);
return { ok: true };
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() {
@ -305,11 +379,13 @@ Singleton {
// down the scope and the seat); pkill Hyprland just kills the
// compositor and leaves logind believing the session is alive.
if (root.hasLoginctl) {
root.runVerb("logout", ["loginctl", "terminate-session", ""]);
return { ok: true };
if (!root.runVerb("logout", ["loginctl", "terminate-session", ""]))
return root.refuse("logout", "another session action is still running");
return { ok: true, status: "started" };
}
root.runVerb("logout", ["pkill", "-i", "Hyprland"]);
return { ok: true, degraded: "no loginctl; killed the compositor" };
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() {