SessionEvents asked loginctl for -p ObjectPath, which is not a property — it returned empty on every boot, so external lock signals were never monitored and the shell had no unlock ingress. Resolve the graphical session the way lockhint.rs does; not GetSession(auto) (this shell is a session-less user unit) and not the /session/auto alias (PropertiesChanged only fires on the concrete path). SessiondBridge read root.registered to decide whether to re-register on reconnect, but the disconnect branch had already cleared it, so the shell never re-registered after a sessiond restart. Latch it.
207 lines
8 KiB
QML
207 lines
8 KiB
QML
// Shell side of the souveraine-sessiond handoff protocol.
|
|
//
|
|
// sessiond takes ext-session-lock before the shell exists; this bridge is
|
|
// how the shell (a) announces itself and takes the lock over, (b) keeps the
|
|
// heartbeat connection open so sessiond can retake the lock the moment the
|
|
// shell dies, and (c) confirms the compositor-acked lock (locked_ack).
|
|
//
|
|
// The session is never unlocked during the handoff: sessiond abandons its
|
|
// lock (connection drop) and misc:allow_session_lock_restore lets our
|
|
// WlSessionLock inherit the locked session. See
|
|
// souveraine/src/sessiond/protocol.rs — that file is the contract.
|
|
//
|
|
// No sessiond on the socket (laptop, or bring-up) = everything no-ops and
|
|
// the legacy launchOnStartup path decides alone.
|
|
pragma Singleton
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs
|
|
import qs.modules.common.functions
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
// Only shell.qml calls claimAuthority(). Importing this singleton from a
|
|
// utility window must never create a session-authority connection.
|
|
property bool authorityScope: false
|
|
|
|
// Registered = shell_ready was answered ok on the CURRENT connection.
|
|
property bool registered: false
|
|
// Latched copy of `registered` taken when the connection drops. The
|
|
// disconnect branch clears `registered` before the reconnect branch runs,
|
|
// so reconnect cannot read it directly to decide whether to re-register.
|
|
property bool wasRegistered: false
|
|
// sessiond held the session lock when we registered; we owe it a lock
|
|
// and a locked_ack.
|
|
property bool oweLock: false
|
|
property bool ackSent: false
|
|
property var pendingReady: null // callback awaiting the shell_ready response
|
|
|
|
function claimAuthority() {
|
|
root.authorityScope = true
|
|
}
|
|
|
|
function load() {
|
|
if (!root.authorityScope)
|
|
console.log("[sessiond-bridge] inactive outside authority scope")
|
|
}
|
|
|
|
// Announce the shell. cb(mustLock) fires exactly once: mustLock true
|
|
// means sessiond was holding and the session IS locked — the shell must
|
|
// raise its own lock surface immediately.
|
|
function shellReady(cb) {
|
|
if (!root.authorityScope) {
|
|
console.log("[sessiond-bridge] shell_ready refused outside authority scope")
|
|
cb(false)
|
|
return
|
|
}
|
|
if (!sock.connected) {
|
|
cb(false);
|
|
return;
|
|
}
|
|
if (root.pendingReady) {
|
|
// Only one handshake in flight; late duplicate answers false.
|
|
cb(false);
|
|
return;
|
|
}
|
|
root.pendingReady = cb;
|
|
readyTimeout.restart();
|
|
sock.write(JSON.stringify({ op: "shell_ready" }) + "\n");
|
|
sock.flush();
|
|
}
|
|
|
|
function sendLockedAck() {
|
|
if (!sock.connected || !root.registered || root.ackSent) return;
|
|
root.ackSent = true;
|
|
sock.write(JSON.stringify({ op: "locked_ack" }) + "\n");
|
|
sock.flush();
|
|
console.log("[sessiond-bridge] locked_ack sent");
|
|
}
|
|
|
|
Timer {
|
|
id: readyTimeout
|
|
interval: 3000
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root.pendingReady) {
|
|
console.log("[sessiond-bridge] shell_ready timed out — proceeding without sessiond");
|
|
const cb = root.pendingReady;
|
|
root.pendingReady = null;
|
|
cb(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reconnect: sessiond may restart (upgrade) or start late. While
|
|
// connected this timer is idle; the Socket does not retry by itself.
|
|
Timer {
|
|
id: reconnect
|
|
interval: 5000
|
|
repeat: true
|
|
running: root.authorityScope && !sock.connected
|
|
onTriggered: sock.connected = true
|
|
}
|
|
|
|
Socket {
|
|
id: sock
|
|
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
|
connected: root.authorityScope
|
|
|
|
// `connectionStateChanged` is the real Signal on Quickshell.Io.Socket;
|
|
// the `onSocketConnected`/`onSocketDisconnected` handler-slots aren't
|
|
// reliably attachable across quickshell builds, so branch on
|
|
// `connected` here. Re-register on reconnect if we were registered
|
|
// before (sessiond restarted underneath us).
|
|
onConnectionStateChanged: {
|
|
if (sock.connected) {
|
|
console.log("[sessiond-bridge] connected");
|
|
const wasRegistered = root.wasRegistered;
|
|
root.registered = false;
|
|
root.wasRegistered = false;
|
|
root.ackSent = false;
|
|
if (wasRegistered) {
|
|
root.shellReady(function(mustLock) {
|
|
if (mustLock) {
|
|
// sessiond locked while we were disconnected (it
|
|
// treats our EOF as shell death). Mirror it.
|
|
GlobalStates.screenLocked = true;
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
console.log("[sessiond-bridge] disconnected");
|
|
root.wasRegistered = root.registered;
|
|
root.registered = false;
|
|
root.pendingReady = null;
|
|
}
|
|
}
|
|
|
|
parser: SplitParser {
|
|
splitMarker: "\n"
|
|
onRead: message => {
|
|
let reply;
|
|
try {
|
|
reply = JSON.parse(message);
|
|
} catch (e) {
|
|
console.log("[sessiond-bridge] unparseable reply: " + message);
|
|
return;
|
|
}
|
|
// Not every line is a reply. The authority pushes directives
|
|
// down this connection: it owns the decision, the shell owns
|
|
// the surface. A blank that needs a lock first arrives here
|
|
// (LOCK-DPMS-LESSONS §1 — lock, then off), and the daemon is
|
|
// holding the panel dark until this is answered.
|
|
if (reply.directive !== undefined) {
|
|
root.handleDirective(reply);
|
|
return;
|
|
}
|
|
if (root.pendingReady) {
|
|
// The only request we await a response for.
|
|
const cb = root.pendingReady;
|
|
root.pendingReady = null;
|
|
readyTimeout.stop();
|
|
if (reply.ok) {
|
|
root.registered = true;
|
|
root.oweLock = reply.must_lock === true;
|
|
console.log("[sessiond-bridge] registered, must_lock=" + root.oweLock);
|
|
cb(root.oweLock);
|
|
} else {
|
|
console.log("[sessiond-bridge] shell_ready refused: " + reply.reason);
|
|
cb(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Carry out an authority directive. The shell is the executor here, not a
|
|
// peer deciding whether it agrees: sessiond is the session authority and
|
|
// it has already withheld the panel waiting for this.
|
|
//
|
|
// Unknown directives are LOUD. A newer daemon asking for something this
|
|
// shell cannot do is a real divergence, and silently dropping it would
|
|
// leave the daemon waiting out its ack budget and then blanking unlocked.
|
|
function handleDirective(msg) {
|
|
if (msg.directive === "lock") {
|
|
console.log("[sessiond-bridge] authority directive: lock ("
|
|
+ (msg.why || "no reason given") + ")");
|
|
Session.lock();
|
|
return;
|
|
}
|
|
console.error("[sessiond-bridge] UNKNOWN authority directive: "
|
|
+ JSON.stringify(msg)
|
|
+ " — this shell is older than the daemon driving it");
|
|
}
|
|
|
|
// The compositor acknowledged OUR lock surface — tell sessiond the
|
|
// handoff is complete. Gated on secure, not the request, per doctrine.
|
|
Connections {
|
|
target: GlobalStates
|
|
function onScreenLockSecureChanged() {
|
|
if (GlobalStates.screenLockSecure && root.oweLock && !root.ackSent) {
|
|
root.sendLockedAck();
|
|
}
|
|
}
|
|
}
|
|
}
|