diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index 6ea7968..26f8bd9 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -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 diff --git a/surfaces/quickshell/modules/common/functions/Session.qml b/surfaces/quickshell/modules/common/functions/Session.qml new file mode 100644 index 0000000..705cb36 --- /dev/null +++ b/surfaces/quickshell/modules/common/functions/Session.qml @@ -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] 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}`]); + } +} diff --git a/surfaces/quickshell/modules/ii/appInventory/AppInventoryScope.qml b/surfaces/quickshell/modules/ii/appInventory/AppInventoryScope.qml index 8744e92..8dbb0e0 100644 --- a/surfaces/quickshell/modules/ii/appInventory/AppInventoryScope.qml +++ b/surfaces/quickshell/modules/ii/appInventory/AppInventoryScope.qml @@ -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()); } } } diff --git a/surfaces/quickshell/modules/ii/dock/Dock.qml b/surfaces/quickshell/modules/ii/dock/Dock.qml index 71d4a26..7c4a5a4 100644 --- a/surfaces/quickshell/modules/ii/dock/Dock.qml +++ b/surfaces/quickshell/modules/ii/dock/Dock.qml @@ -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()); } } diff --git a/surfaces/quickshell/modules/ii/lock/Lock.qml b/surfaces/quickshell/modules/ii/lock/Lock.qml index 0681518..b543416 100644 --- a/surfaces/quickshell/modules/ii/lock/Lock.qml +++ b/surfaces/quickshell/modules/ii/lock/Lock.qml @@ -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 diff --git a/surfaces/quickshell/services/Idle.qml b/surfaces/quickshell/services/Idle.qml index 8247c12..5d83990 100644 --- a/surfaces/quickshell/services/Idle.qml +++ b/surfaces/quickshell/services/Idle.qml @@ -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 {