This is a projection, not a development branch. The tree above was constructed from the internal source named below under a manifest that decides which paths may leave, then scanned as a whole tree rather than as a series of patches, and only then published. Public history starts here because the history before it was not admissible, and neither was the tree. What used to stand in this repository included a rescue copy of another machine, a directory of phone handoffs, deployment wired to one house, and a submodule pointing at a forge no stranger can reach. None of that was ever the product. It stays in the private forge, which is allowed to hold the whole working organism, and this is what was deliberately sent out instead. Three mechanisms produced this tree, in decreasing order of trust. A top-level path the manifest does not name never arrives at all, which is the one that catches directories nobody has thought of yet. Named internal files inside admitted roots are dropped. A short, reviewed table replaces deployment defaults that a public build must not carry -- an endpoint aimed at one LAN, a VPN profile belonging to one phone, packaging built from one checkout path. Everything after this commit is an ordinary publication with the same three trailers, so a force push stops being routine and starts meaning that something deliberate happened. The trailers bind the projection to its source without pretending the public SHA is the private one: same lineage, different tree, and the record says so. Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047 Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27 Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
467 lines
20 KiB
QML
467 lines
20 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
|
|
// We owe the authority a registration that has not landed yet — set when a
|
|
// handshake times out or is refused because the lease is still held,
|
|
// cleared once one is answered. Drives registerRetry.
|
|
property bool needsRegistration: false
|
|
// Consecutive "already registered" refusals. Only for log cadence — the
|
|
// retry itself is unconditional, because the common cause is our own
|
|
// outgoing connection not having hit EOF yet.
|
|
property int refusalStreak: 0
|
|
// 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")
|
|
}
|
|
|
|
// One short-lived request connection for power authority. Never put this
|
|
// on `sock`: suspend can keep the daemon's synchronous request handler
|
|
// occupied until resume, while the heartbeat connection must remain free
|
|
// to carry locked_ack, directives and the EOF that means shell death.
|
|
//
|
|
// This is deliberately only the transport seam for now. Session.qml keeps
|
|
// its legacy executor until the packaged daemon and its polkit subject have
|
|
// been proven on each target. A locally accepted request returns pending;
|
|
// the callback and powerFinished carry the daemon's eventual verdict.
|
|
property var pendingPower: null
|
|
property int powerRequestSequence: 0
|
|
signal powerFinished(string requestId, var reply)
|
|
|
|
function requestPower(verb, callback) {
|
|
const powerVerb = String(verb ?? "");
|
|
if (!["poweroff", "reboot", "suspend", "hibernate"].includes(powerVerb)) {
|
|
return {
|
|
ok: false,
|
|
code: "unsupported",
|
|
reason: "unsupported power verb: " + powerVerb
|
|
};
|
|
}
|
|
if (root.pendingPower !== null) {
|
|
return {
|
|
ok: false,
|
|
code: "refused_by_state",
|
|
reason: "another power request is already in flight"
|
|
};
|
|
}
|
|
|
|
root.powerRequestSequence += 1;
|
|
const requestId = "power-" + Date.now() + "-" + root.powerRequestSequence;
|
|
root.pendingPower = {
|
|
requestId: requestId,
|
|
verb: powerVerb,
|
|
callback: typeof callback === "function" ? callback : null,
|
|
sent: false
|
|
};
|
|
powerConnectTimeout.restart();
|
|
powerSock.connected = true;
|
|
return { ok: true, status: "pending", request_id: requestId };
|
|
}
|
|
|
|
function _finishPower(reply) {
|
|
if (root.pendingPower === null)
|
|
return;
|
|
const pending = root.pendingPower;
|
|
root.pendingPower = null;
|
|
powerConnectTimeout.stop();
|
|
|
|
const result = {};
|
|
for (const key in reply)
|
|
result[key] = reply[key];
|
|
result.request_id = pending.requestId;
|
|
|
|
// Clear our request before closing. The disconnect edge must not turn
|
|
// a parsed refusal/acceptance into a second outcome_unknown callback.
|
|
powerSock.connected = false;
|
|
root.powerFinished(pending.requestId, result);
|
|
if (pending.callback !== null) {
|
|
try {
|
|
pending.callback(result);
|
|
} catch (error) {
|
|
console.error("[sessiond-bridge] power callback failed: " + error);
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: powerConnectTimeout
|
|
interval: 1500
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root.pendingPower !== null && !root.pendingPower.sent) {
|
|
root._finishPower({
|
|
ok: false,
|
|
code: "unavailable",
|
|
reason: "sessiond power socket unavailable"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Socket {
|
|
id: powerSock
|
|
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
|
|
|
onConnectionStateChanged: {
|
|
if (powerSock.connected && root.pendingPower !== null
|
|
&& !root.pendingPower.sent) {
|
|
const pending = root.pendingPower;
|
|
root.pendingPower = {
|
|
requestId: pending.requestId,
|
|
verb: pending.verb,
|
|
callback: pending.callback,
|
|
sent: true
|
|
};
|
|
powerConnectTimeout.stop();
|
|
powerSock.write(JSON.stringify({
|
|
op: "power",
|
|
verb: pending.verb
|
|
}) + "\n");
|
|
powerSock.flush();
|
|
} else if (!powerSock.connected && root.pendingPower !== null) {
|
|
const sent = root.pendingPower.sent;
|
|
root._finishPower(sent ? {
|
|
ok: false,
|
|
code: "outcome_unknown",
|
|
status: "outcome_unknown",
|
|
reason: "sessiond disconnected after the power request was sent"
|
|
} : {
|
|
ok: false,
|
|
code: "unavailable",
|
|
reason: "sessiond power socket unavailable"
|
|
});
|
|
}
|
|
}
|
|
|
|
parser: SplitParser {
|
|
splitMarker: "\n"
|
|
onRead: message => {
|
|
if (root.pendingPower === null)
|
|
return;
|
|
let reply;
|
|
try {
|
|
reply = JSON.parse(message);
|
|
} catch (error) {
|
|
root._finishPower({
|
|
ok: false,
|
|
code: "outcome_unknown",
|
|
status: "outcome_unknown",
|
|
reason: "sessiond returned an unparseable power reply"
|
|
});
|
|
return;
|
|
}
|
|
root._finishPower(reply);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// EVERY failure path here answers TRUE. Not knowing whether the session is
|
|
// locked is not the same as knowing it is not, and the two must never be
|
|
// collapsed: sessiond takes ext-session-lock before any shell surface can
|
|
// exist (protocol.rs §1), so a shell that cannot get an answer is a shell
|
|
// that arrived after something already locked the session. The daemon side
|
|
// of this contract is explicit — heartbeat EOF retakes the lock "whether or
|
|
// not the session was locked at the time. Fail closed." — and this side has
|
|
// to match it or the pair fails open at exactly the moment the authority is
|
|
// unreachable.
|
|
//
|
|
// Answering true costs an unnecessary lock surface the user dismisses with
|
|
// PAM. Answering false costs an unlocked phone.
|
|
function shellReady(cb) {
|
|
if (!root.authorityScope) {
|
|
// Not a fail-open: a utility QML process is not the session
|
|
// authority and has no lock to owe. Only shell.qml claims scope.
|
|
console.log("[sessiond-bridge] shell_ready refused outside authority scope")
|
|
cb(false)
|
|
return
|
|
}
|
|
if (!sock.connected) {
|
|
console.warn("[sessiond-bridge] shell_ready with no socket — assuming locked");
|
|
cb(true);
|
|
return;
|
|
}
|
|
if (root.pendingReady) {
|
|
// A handshake is already in flight; it will answer authoritatively.
|
|
// This duplicate assumes locked rather than racing it to "unlocked".
|
|
console.warn("[sessiond-bridge] duplicate shell_ready — assuming locked");
|
|
cb(true);
|
|
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
|
|
// Longer than sessiond's own deadline, and that ordering is the whole
|
|
// point. `shell_ready` blocks in the daemon for up to 5 s waiting for
|
|
// its lock-session thread to drop its Wayland connection, because the
|
|
// compositor refuses a second locker while the first is alive
|
|
// (server.rs, `wait_timeout_while`). At 3 s this timer fired *first*,
|
|
// so the shell gave up on a handshake the daemon was still answering,
|
|
// assumed locked, and asked for a lock sessiond had not released yet —
|
|
// straight into TASK-48's `Tried to show lockscreen surfaces without
|
|
// active lock`.
|
|
//
|
|
// Under Hyprland that race is usually won: the release lands in
|
|
// milliseconds. Measured against viewtop on blueline 2026-08-02 it
|
|
// loses every time — the shell crash-looped every 11 s and never came
|
|
// up. Same latent bug, a compositor that exposes it.
|
|
//
|
|
// The daemon has an answer for this case and it is a refusal
|
|
// (`lock session did not release in time`). Waiting for a real refusal
|
|
// beats inventing a verdict: `assuming locked` is the shell holding
|
|
// state the authority owns, which is the failure doctrine §4 is about.
|
|
interval: 7000
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root.pendingReady) {
|
|
// Was "proceeding without sessiond" with cb(false): a silent
|
|
// fail-open that left the session unlocked precisely when the
|
|
// authority was not answering. Assume locked, and keep trying —
|
|
// an unanswered handshake is a transient (sessiond restarting),
|
|
// not a verdict.
|
|
console.warn("[sessiond-bridge] shell_ready timed out — assuming locked, will retry");
|
|
const cb = root.pendingReady;
|
|
root.pendingReady = null;
|
|
root.needsRegistration = true;
|
|
cb(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register, and apply whatever the authority says we owe. One path, used by
|
|
// the reconnect handler and the retry timer alike, so the two can never
|
|
// drift into handling the answer differently.
|
|
function registerWithAuthority() {
|
|
root.shellReady(function(mustLock) {
|
|
if (!mustLock)
|
|
return;
|
|
// sessiond locked while we were disconnected (it treats our EOF as
|
|
// shell death), or we could not confirm and are failing closed.
|
|
GlobalStates.screenLocked = true;
|
|
// Our surface may ALREADY be secure from before the daemon
|
|
// restarted. onScreenLockSecureChanged is an edge, and that edge is
|
|
// in the past, so nothing would ever send the ack this new handoff
|
|
// owes — sessiond waits out its timer and retakes the lock
|
|
// ("shell never confirmed its lock after handoff").
|
|
if (GlobalStates.screenLockSecure)
|
|
root.sendLockedAck();
|
|
});
|
|
}
|
|
|
|
// A handshake that timed out is retried until it lands. Without this a
|
|
// shell that merely started while sessiond was restarting stays
|
|
// unregistered for its whole life: sessiond sees no heartbeat, believes the
|
|
// shell is dead, and raises its own fallback surface over ours forever.
|
|
Timer {
|
|
id: registerRetry
|
|
interval: 5000
|
|
repeat: true
|
|
running: root.authorityScope && root.needsRegistration
|
|
&& !root.registered && sock.connected
|
|
onTriggered: root.registerWithAuthority()
|
|
}
|
|
|
|
// 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.needsRegistration) {
|
|
root.registerWithAuthority();
|
|
}
|
|
} 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.needsRegistration = false;
|
|
root.refusalStreak = 0;
|
|
root.oweLock = reply.must_lock === true;
|
|
console.log("[sessiond-bridge] registered, must_lock=" + root.oweLock);
|
|
cb(root.oweLock);
|
|
} else {
|
|
// "already registered" means the lease is held right
|
|
// now. It does NOT mean it is held by someone else,
|
|
// and treating it as a verdict was a real bug: a scene
|
|
// RELOAD re-runs this file while the outgoing
|
|
// connection is still open, so the reload's shell_ready
|
|
// races its own predecessor's EOF and is refused. The
|
|
// old code then stopped retrying — and when that EOF
|
|
// landed a second later it cleared shell_alive, leaving
|
|
// sessiond believing there was no shell at all, for the
|
|
// life of the session. Observed 2026-07-29 09:14:35:
|
|
// refused, old socket closed 09:16:36, and the daemon
|
|
// reported shell_alive=false with a live shell on the
|
|
// other end of a connected socket.
|
|
//
|
|
// So it is a TRANSIENT. Keep registering. If the lease
|
|
// really is another live shell's, every retry is
|
|
// refused again and costs nothing — and the moment that
|
|
// shell dies we are the one that should hold it.
|
|
const leaseHeld =
|
|
String(reply.reason || "").indexOf("already registered") !== -1;
|
|
if (leaseHeld) {
|
|
root.needsRegistration = true;
|
|
root.refusalStreak += 1;
|
|
// Loud once, then once a minute: a lease that never
|
|
// frees is a real problem, but 12 lines a minute is
|
|
// how a real problem gets scrolled past.
|
|
if (root.refusalStreak === 1 || root.refusalStreak % 12 === 0)
|
|
console.warn("[sessiond-bridge] shell_ready refused: "
|
|
+ reply.reason + " — lease still held, retrying ("
|
|
+ root.refusalStreak + ")");
|
|
} else {
|
|
console.warn("[sessiond-bridge] shell_ready refused: "
|
|
+ reply.reason + " — assuming locked");
|
|
}
|
|
cb(!leaseHeld);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
if (msg.directive === "accessory_presentation") {
|
|
// sessiond sends this only after producer admission and content
|
|
// classification. The shell projects it; it does not reinterpret
|
|
// a Bluetooth observation into an authority decision.
|
|
AccessoryPresentation.present(msg.presentation ?? {});
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|