Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-07-14 18:31:59 -04:00
commit cecde3bac2
6 changed files with 466 additions and 29 deletions

View file

@ -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/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
modules/common/Config.qml souveraine/modules/common/Config.qml modules/common/Config.qml souveraine/modules/common/Config.qml
modules/common/ShellModel.qml souveraine/modules/common/ShellModel.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/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml
modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml

View 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}`]);
}
}

View file

@ -29,34 +29,39 @@ Scope {
// rejected ("cannot be used across IPC"). So every parameter has an // rejected ("cannot be used across IPC"). So every parameter has an
// explicit type. The inventory's list(filter) takes an object in-process; // explicit type. The inventory's list(filter) takes an object in-process;
// over IPC we expose list(category: string) and build the filter here. // 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 { IpcHandler {
target: "apps" target: "apps"
// apps.list() -> all (noDisplay excluded) // apps.list() -> all (noDisplay excluded)
// apps.list("Network") -> only entries in the "Network" category // apps.list("Network") -> only entries in the "Network" category
function list(category: string): var { function list(category: string): string {
return appInventory.listFromCategory(category); return JSON.stringify(appInventory.listFromCategory(category));
} }
// apps.get("firefox") -> {ok, entry?} or {ok:false, reason:"not-found"} // apps.get("firefox") -> {ok, entry?} or {ok:false, reason:"not-found"}
function get(appId: string): var { function get(appId: string): string {
return appInventory.get(appId); return JSON.stringify(appInventory.get(appId));
} }
// apps.find("fire") -> fuzzy-ranked entries (default limit 50) // apps.find("fire") -> fuzzy-ranked entries (default limit 50)
// apps.find("fire", 10) -> capped at 10 // apps.find("fire", 10) -> capped at 10
function find(query: string, limit: int): var { function find(query: string, limit: int): string {
return appInventory.find(query, limit); return JSON.stringify(appInventory.find(query, limit));
} }
// apps.categories() -> {ok, categories:[{category,count}]} // apps.categories() -> {ok, categories:[{category,count}]}
function categories(): var { function categories(): string {
return appInventory.categories(); return JSON.stringify(appInventory.categories());
} }
// apps.refresh() -> trigger a rescan (async; re-query after the log line) // apps.refresh() -> trigger a rescan (async; re-query after the log line)
function refresh(): var { function refresh(): string {
return appInventory.refresh(); return JSON.stringify(appInventory.refresh());
} }
} }
} }

View file

@ -132,29 +132,36 @@ Scope { // Scope
// and the state checks. The agent calls `dock.manifest`, `dock.pin`, // and the state checks. The agent calls `dock.manifest`, `dock.pin`,
// etc. never parsing QML. Refusals return {ok:false, reason}, not // etc. never parsing QML. Refusals return {ok:false, reason}, not
// errors, so a refused mutation is information the agent learns from. // 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 { function manifest(): string {
return dockManifest.manifest(); return JSON.stringify(dockManifest.manifest());
} }
function pin(appId: string): var { function pin(appId: string): string {
return dockManifest.pin(appId); return JSON.stringify(dockManifest.pin(appId));
} }
function unpin(appId: string): var { function unpin(appId: string): string {
return dockManifest.unpin(appId); return JSON.stringify(dockManifest.unpin(appId));
} }
function addToStack(stackId: string, appId: string): var { function addToStack(stackId: string, appId: string): string {
return dockManifest.addToStack(stackId, appId); return JSON.stringify(dockManifest.addToStack(stackId, appId));
} }
function removeFromStack(stackId: string, appId: string): var { function removeFromStack(stackId: string, appId: string): string {
return dockManifest.removeFromStack(stackId, appId); return JSON.stringify(dockManifest.removeFromStack(stackId, appId));
} }
function renameStack(stackId: string, newName: string): var { function renameStack(stackId: string, newName: string): string {
return dockManifest.renameStack(stackId, newName); return JSON.stringify(dockManifest.renameStack(stackId, newName));
} }
} }
@ -187,16 +194,19 @@ Scope { // Scope
IpcHandler { IpcHandler {
target: "shell" 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 // surfaces() registry list, one entry per meaningful surface with
// layer, gating state, config gate, and a live `active` flag. // layer, gating state, config gate, and a live `active` flag.
function surfaces(): var { function surfaces(): string {
return shellModel.surfaces(); return JSON.stringify(shellModel.surfaces());
} }
// state() the GlobalStates bits that matter for layer gating, plus // state() the GlobalStates bits that matter for layer gating, plus
// the shell mode. Read-only snapshot. // the shell mode. Read-only snapshot.
function state(): var { function state(): string {
return shellModel.state(); return JSON.stringify(shellModel.state());
} }
} }

View file

@ -11,6 +11,7 @@ import qs.modules.common.functions
import qs.modules.common.panels.lock import qs.modules.common.panels.lock
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Hyprland import Quickshell.Hyprland
LockScreen { LockScreen {
@ -19,6 +20,84 @@ LockScreen {
// Monitor name -> workspace id to restore on unlock (set when locking) // Monitor name -> workspace id to restore on unlock (set when locking)
property var savedWorkspaces: ({}) 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 { Timer {
id: restoreTimer id: restoreTimer
interval: 150 interval: 150

View file

@ -2,6 +2,7 @@ pragma Singleton
import qs.modules.common import qs.modules.common
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Wayland import Quickshell.Wayland
/** /**
@ -20,9 +21,28 @@ Singleton {
// Keep System Awake toggles the systemd-managed hypridle unit. // Keep System Awake toggles the systemd-managed hypridle unit.
// (Was pkill/setsid, which orphaned hypridle across Hyprland restarts // (Was pkill/setsid, which orphaned hypridle across Hyprland restarts
// and wedged wake see hyprland.lua hyprland.start.) // 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 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 { Connections {