quickshell: session arbiter + fix IPC returns silently dropping payloads
Fork ii's Session singleton and add a sessionctl.* surface.
The IPC bug is the important half. Quickshell marshals exactly five types
(string/int/bool/double/color) and maps a `var` return to VOID, discarding
the payload with no error -- src/io/ipc.cpp, "void and var get mixed by qml
engine". dock.*, shell.* and apps.* were all declared `: var`, so they
registered as `(): void` and returned nothing at all. The {ok, reason}
contract has never once reached a caller. All of them now return JSON as a
string, which is what actually crosses the socket.
Session: upstream fires `systemctl X || loginctl X` detached and throws the
exit code away. Fine on a desktop with someone at the keyboard, not fine on
a phone where the shell is the session manager and a verb that silently does
nothing leaves you believing the machine is suspending when it isn't. So:
probe loginctl/systemctl/hibernate once instead of assuming, run verbs
through a Process that logs the exit code, and refuse honestly when the
machine can't do the thing (the phone has no swap -- hibernate now says so
instead of no-opping). Every upstream verb keeps its name and call sites.
Inhibits carry a mandatory reason and get a cookie; state() lists who is
holding the machine awake and why. "Why didn't it sleep" is now answerable.
unlock() is refused by design -- the lock is the credential gate, so no IPC
caller routes around the PIN pad.
Named sessionctl, not session: ii's SessionScreen already owns "session",
and quickshell drops duplicate targets silently rather than erroring.
Idle: drop the 2>/dev/null and run hypridle through a Process, so a unit
that fails to come back is a log line instead of a flat battery.
Verified on the laptop: inhibit stops hypridle, uninhibit brings it back.
This commit is contained in:
parent
c78c5510af
commit
cecde3bac2
6 changed files with 466 additions and 29 deletions
|
|
@ -38,6 +38,7 @@ modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiCh
|
|||
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
|
||||
modules/common/Config.qml souveraine/modules/common/Config.qml
|
||||
modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml
|
||||
modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml
|
||||
modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml
|
||||
modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
|
||||
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
|
||||
|
|
|
|||
322
surfaces/quickshell/modules/common/functions/Session.qml
Normal file
322
surfaces/quickshell/modules/common/functions/Session.qml
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// 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 probe loginctl/systemctl/hibernate ONCE at
|
||||
// startup instead of assuming `systemctl X || loginctl X` will work.
|
||||
// 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. 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.
|
||||
//
|
||||
// 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 bool canHibernate: false
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
|
||||
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. 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.
|
||||
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; " +
|
||||
"true"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.split("\n").map(l => l.trim());
|
||||
root.hasLoginctl = lines.includes("loginctl");
|
||||
root.hasSystemctl = lines.includes("systemctl");
|
||||
root.canHibernate = lines.includes("hibernate");
|
||||
root.probed = true;
|
||||
console.log("[session] capabilities:",
|
||||
"loginctl=" + root.hasLoginctl,
|
||||
"systemctl=" + root.hasSystemctl,
|
||||
"hibernate=" + root.canHibernate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) => {
|
||||
if (exitCode !== 0) {
|
||||
console.log(`[session] ${verbProc.verb} failed (exit ${exitCode})`);
|
||||
root.actionFailed(verbProc.verb, exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runVerb(verb, argv) {
|
||||
verbProc.verb = verb;
|
||||
verbProc.command = argv;
|
||||
verbProc.running = true;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (!reason) return root.refuse("inhibit", "an inhibit must carry a reason");
|
||||
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 };
|
||||
root.inhibitors = next;
|
||||
console.log(`[session] inhibit ${cookie}: ${what || "idle"} — ${reason}`);
|
||||
root.applyIdleInhibit();
|
||||
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.applyIdleInhibit();
|
||||
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.
|
||||
function applyIdleInhibit() {
|
||||
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,
|
||||
idleInhibited: Idle.inhibit,
|
||||
inhibitors: holders,
|
||||
capabilities: root.caps()
|
||||
};
|
||||
}
|
||||
|
||||
// `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,
|
||||
hibernate: root.canHibernate,
|
||||
poweroff: root.canPoweroff,
|
||||
reboot: root.canReboot
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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() {
|
||||
// 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.
|
||||
GlobalStates.screenLocked = true;
|
||||
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();
|
||||
root.runVerb("suspend", root.powerCommand("suspend"));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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.
|
||||
if (!root.canHibernate)
|
||||
return root.refuse("hibernate", "no hibernate support (no disk in /sys/power/state)");
|
||||
pauseAllPlayers();
|
||||
root.runVerb("hibernate", root.powerCommand("hibernate"));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
root.runVerb("logout", ["loginctl", "terminate-session", ""]);
|
||||
return { ok: true };
|
||||
}
|
||||
root.runVerb("logout", ["pkill", "-i", "Hyprland"]);
|
||||
return { ok: true, 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}`]);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,34 +29,39 @@ Scope {
|
|||
// rejected ("cannot be used across IPC"). So every parameter has an
|
||||
// explicit type. The inventory's list(filter) takes an object in-process;
|
||||
// over IPC we expose list(category: string) and build the filter here.
|
||||
// The same five-type limit applies to RETURNS, not just arguments: a `var`
|
||||
// return is mapped to VOID and the payload is dropped without an error
|
||||
// (src/io/ipc.cpp ipcType(); "void and var get mixed by qml engine"). These
|
||||
// were declared `: var` and so registered as `(): void` — every call
|
||||
// returned nothing. Returning JSON as a string is what actually crosses.
|
||||
IpcHandler {
|
||||
target: "apps"
|
||||
|
||||
// apps.list() -> all (noDisplay excluded)
|
||||
// apps.list("Network") -> only entries in the "Network" category
|
||||
function list(category: string): var {
|
||||
return appInventory.listFromCategory(category);
|
||||
function list(category: string): string {
|
||||
return JSON.stringify(appInventory.listFromCategory(category));
|
||||
}
|
||||
|
||||
// apps.get("firefox") -> {ok, entry?} or {ok:false, reason:"not-found"}
|
||||
function get(appId: string): var {
|
||||
return appInventory.get(appId);
|
||||
function get(appId: string): string {
|
||||
return JSON.stringify(appInventory.get(appId));
|
||||
}
|
||||
|
||||
// apps.find("fire") -> fuzzy-ranked entries (default limit 50)
|
||||
// apps.find("fire", 10) -> capped at 10
|
||||
function find(query: string, limit: int): var {
|
||||
return appInventory.find(query, limit);
|
||||
function find(query: string, limit: int): string {
|
||||
return JSON.stringify(appInventory.find(query, limit));
|
||||
}
|
||||
|
||||
// apps.categories() -> {ok, categories:[{category,count}]}
|
||||
function categories(): var {
|
||||
return appInventory.categories();
|
||||
function categories(): string {
|
||||
return JSON.stringify(appInventory.categories());
|
||||
}
|
||||
|
||||
// apps.refresh() -> trigger a rescan (async; re-query after the log line)
|
||||
function refresh(): var {
|
||||
return appInventory.refresh();
|
||||
function refresh(): string {
|
||||
return JSON.stringify(appInventory.refresh());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,29 +132,36 @@ Scope { // Scope
|
|||
// and the state checks. The agent calls `dock.manifest`, `dock.pin`,
|
||||
// etc. — never parsing QML. Refusals return {ok:false, reason}, not
|
||||
// errors, so a refused mutation is information the agent learns from.
|
||||
//
|
||||
// Returns are `string` (JSON), not `var`: quickshell marshals only
|
||||
// string/int/bool/double/color across IPC and silently maps a `var`
|
||||
// return to VOID (src/io/ipc.cpp ipcType()). Declared `: var`, these
|
||||
// registered as `(): void` and returned nothing at all — the {ok,
|
||||
// reason} contract never reached the caller. JSON-over-string is what
|
||||
// actually crosses the socket.
|
||||
|
||||
function manifest(): var {
|
||||
return dockManifest.manifest();
|
||||
function manifest(): string {
|
||||
return JSON.stringify(dockManifest.manifest());
|
||||
}
|
||||
|
||||
function pin(appId: string): var {
|
||||
return dockManifest.pin(appId);
|
||||
function pin(appId: string): string {
|
||||
return JSON.stringify(dockManifest.pin(appId));
|
||||
}
|
||||
|
||||
function unpin(appId: string): var {
|
||||
return dockManifest.unpin(appId);
|
||||
function unpin(appId: string): string {
|
||||
return JSON.stringify(dockManifest.unpin(appId));
|
||||
}
|
||||
|
||||
function addToStack(stackId: string, appId: string): var {
|
||||
return dockManifest.addToStack(stackId, appId);
|
||||
function addToStack(stackId: string, appId: string): string {
|
||||
return JSON.stringify(dockManifest.addToStack(stackId, appId));
|
||||
}
|
||||
|
||||
function removeFromStack(stackId: string, appId: string): var {
|
||||
return dockManifest.removeFromStack(stackId, appId);
|
||||
function removeFromStack(stackId: string, appId: string): string {
|
||||
return JSON.stringify(dockManifest.removeFromStack(stackId, appId));
|
||||
}
|
||||
|
||||
function renameStack(stackId: string, newName: string): var {
|
||||
return dockManifest.renameStack(stackId, newName);
|
||||
function renameStack(stackId: string, newName: string): string {
|
||||
return JSON.stringify(dockManifest.renameStack(stackId, newName));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,16 +194,19 @@ Scope { // Scope
|
|||
IpcHandler {
|
||||
target: "shell"
|
||||
|
||||
// JSON-over-string, not `var` — see the note on dock.manifest above.
|
||||
// A `var` return marshals as VOID and silently drops the payload.
|
||||
|
||||
// surfaces() — registry list, one entry per meaningful surface with
|
||||
// layer, gating state, config gate, and a live `active` flag.
|
||||
function surfaces(): var {
|
||||
return shellModel.surfaces();
|
||||
function surfaces(): string {
|
||||
return JSON.stringify(shellModel.surfaces());
|
||||
}
|
||||
|
||||
// state() — the GlobalStates bits that matter for layer gating, plus
|
||||
// the shell mode. Read-only snapshot.
|
||||
function state(): var {
|
||||
return shellModel.state();
|
||||
function state(): string {
|
||||
return JSON.stringify(shellModel.state());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import qs.modules.common.functions
|
|||
import qs.modules.common.panels.lock
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
LockScreen {
|
||||
|
|
@ -19,6 +20,84 @@ LockScreen {
|
|||
// Monitor name -> workspace id to restore on unlock (set when locking)
|
||||
property var savedWorkspaces: ({})
|
||||
|
||||
// Session arbiter surface. Lives here beside the lock because the lock IS
|
||||
// the session gate on this device — LockScreen already owns WlSessionLock,
|
||||
// and the session verbs (suspend/poweroff/inhibit) are the other half of
|
||||
// the same lifecycle. The logic lives in the Session singleton
|
||||
// (modules/common/functions/Session.qml); this is only the IPC face.
|
||||
//
|
||||
// Every mutating method returns {ok, reason?} rather than throwing, so a
|
||||
// refusal is information the agent learns from — same contract as dock.*,
|
||||
// shell.* and apps.*. `lock` (target: "lock") stays as it was: it is the
|
||||
// surface's own activate/focus pair, not the session lifecycle.
|
||||
//
|
||||
// Named "sessionctl", not "session": ii's SessionScreen already owns the
|
||||
// "session" target (its toggle/open/close for the power menu). Quickshell
|
||||
// does not merge duplicate IPC targets — the second one is silently
|
||||
// dropped, with no error — so a collision here would have quietly produced
|
||||
// a surface that simply is not there. The menu is "session"; the session
|
||||
// lifecycle is "sessionctl".
|
||||
// Every method returns `string`, not `var`, and the payload is JSON.
|
||||
// This is not a style choice — quickshell marshals exactly five types over
|
||||
// IPC (string, int, bool, double, color; see src/io/ipc.cpp ipcType()) and
|
||||
// maps a `var` return to VOID, discarding the value with no error. A
|
||||
// method declared `: var` therefore looks correct in QML, registers as
|
||||
// `(): void`, and silently returns nothing to the caller. JSON-over-string
|
||||
// is the only way a structured {ok, reason} result actually crosses.
|
||||
IpcHandler {
|
||||
target: "sessionctl"
|
||||
|
||||
// Read-only projection: lock state (read through from the compositor,
|
||||
// never a cached bool), idle inhibitors with their reasons, and what
|
||||
// this machine can actually do.
|
||||
function state(): string {
|
||||
return JSON.stringify(Session.state());
|
||||
}
|
||||
|
||||
function capabilities(): string {
|
||||
return JSON.stringify(Session.caps());
|
||||
}
|
||||
|
||||
function lock(): string {
|
||||
return JSON.stringify(Session.lock());
|
||||
}
|
||||
|
||||
// Refuses by design — unlocking is the credential gate.
|
||||
function unlock(): string {
|
||||
return JSON.stringify(Session.unlock());
|
||||
}
|
||||
|
||||
function suspend(): string {
|
||||
return JSON.stringify(Session.suspend());
|
||||
}
|
||||
|
||||
function hibernate(): string {
|
||||
return JSON.stringify(Session.hibernate());
|
||||
}
|
||||
|
||||
function poweroff(): string {
|
||||
return JSON.stringify(Session.poweroff());
|
||||
}
|
||||
|
||||
function reboot(): string {
|
||||
return JSON.stringify(Session.reboot());
|
||||
}
|
||||
|
||||
function logout(): string {
|
||||
return JSON.stringify(Session.logout());
|
||||
}
|
||||
|
||||
// Reason is mandatory: an inhibitor nobody can explain is exactly the
|
||||
// thing that leaves the phone awake in a pocket at 3am.
|
||||
function inhibit(what: string, reason: string): string {
|
||||
return JSON.stringify(Session.inhibit(what, reason));
|
||||
}
|
||||
|
||||
function uninhibit(cookie: string): string {
|
||||
return JSON.stringify(Session.uninhibit(cookie));
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: restoreTimer
|
||||
interval: 150
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pragma Singleton
|
|||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
|
||||
/**
|
||||
|
|
@ -20,9 +21,28 @@ Singleton {
|
|||
// Keep System Awake toggles the systemd-managed hypridle unit.
|
||||
// (Was pkill/setsid, which orphaned hypridle across Hyprland restarts
|
||||
// and wedged wake — see hyprland.lua hyprland.start.)
|
||||
Quickshell.execDetached(["sh", "-c", root.inhibit
|
||||
//
|
||||
// Runs through a Process, not execDetached, because this is the
|
||||
// mechanism that decides whether the phone sleeps: if the unit fails
|
||||
// to come back we need to know, not discover it via a flat battery.
|
||||
// reset-failed is expected to be a no-op when the unit is healthy, so
|
||||
// its failure is not interesting — but the restart's is, so the two
|
||||
// are separated rather than hidden behind one silencing redirect.
|
||||
hypridleProc.action = root.inhibit ? "stop" : "restart";
|
||||
hypridleProc.command = ["sh", "-c", root.inhibit
|
||||
? "systemctl --user stop hypridle.service"
|
||||
: "systemctl --user reset-failed hypridle.service 2>/dev/null; systemctl --user restart hypridle.service"])
|
||||
: "systemctl --user reset-failed hypridle.service || true; systemctl --user restart hypridle.service"];
|
||||
hypridleProc.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: hypridleProc
|
||||
property string action: ""
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
console.log(`[idle] hypridle ${hypridleProc.action} failed (exit ${exitCode}); `
|
||||
+ `idle handling may be wedged — inhibit=${root.inhibit}`);
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
|
|
|
|||
Loading…
Reference in a new issue