Watch
1
0
Fork
You've already forked souveraine
0

shell: execute authority directives; Settings reads sessiond

SessiondBridge: handle pushed {"directive":"lock"} on the heartbeat line and
raise the lock surface. Unknown directives are console.error — a silent drop
leaves the daemon blanking unlocked when its budget expires.

SessiondPolicy: new singleton, reads/writes DeviceStatePolicy over its own
short-lived connection (not the bridge socket — that EOF is shell-death
detection).

IdleConfig: new "Lock screen (device authority)" section bound to it. SetPolicy
had zero callers, so the old spinboxes moved a JSON file the daemon never read.
Says so in the error colour when sessiond is unreachable.

New file needs a deploy.sh manifest line or the services qmldir fails whole.
This commit is contained in:
Fimeg 2026-07-25 18:53:19 -04:00
commit 24ce6d382b
4 changed files with 227 additions and 1 deletions

View file

@ -48,6 +48,7 @@ scripts/wallpaper/download_wallhaven.sh souveraine/scripts/wallpaper/download_wa
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
services/SessionEvents.qml souveraine/services/SessionEvents.qml services/SessionEvents.qml souveraine/services/SessionEvents.qml
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
services/SessiondPolicy.qml souveraine/services/SessiondPolicy.qml
services/StepUpAuth.qml souveraine/services/StepUpAuth.qml services/StepUpAuth.qml souveraine/services/StepUpAuth.qml
services/SessionAudit.qml souveraine/services/SessionAudit.qml services/SessionAudit.qml souveraine/services/SessionAudit.qml
services/Speech.qml souveraine/services/Speech.qml services/Speech.qml souveraine/services/Speech.qml

View file

@ -111,7 +111,7 @@ ContentPage {
ContentSection { ContentSection {
icon: "timer" icon: "timer"
title: Translation.tr("Timers") title: Translation.tr("Session timers")
// These write real Config keys, but they only take EFFECT when the // These write real Config keys, but they only take EFFECT when the
// native coordinator is on. Disabled (greyed) otherwise so the page // native coordinator is on. Disabled (greyed) otherwise so the page
@ -146,4 +146,103 @@ ContentPage {
} }
} }
} }
// --- Lock screen timers, owned by sessiond ----------------------------
// A separate section because these are a different authority. The two
// above are the shell's own IdleMonitors deciding when to dim and lock a
// session in use; these are the device state machine deciding how long a
// LOCKED, lit panel may burn before it goes dark. That machine has its own
// clock and its own actuators, so its numbers must come from it which is
// what SessiondPolicy is for, and what this page did not do until
// 2026-07-25 (SetPolicy had zero callers; the daemon ran on its built-in
// 15 s while this page showed whatever was in the JSON file).
Component.onCompleted: SessiondPolicy.refresh()
ContentSection {
icon: "phonelink_lock"
title: Translation.tr("Lock screen (device authority)")
StyledText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: SessiondPolicy.available
? Appearance.colors.colSubtext
: Appearance.colors.colError
font.pixelSize: Appearance.font.pixelSize.smaller
text: SessiondPolicy.available
? Translation.tr("Read live from souveraine-sessiond. Changes here go straight to the daemon that blanks the panel.")
: Translation.tr("sessiond is not answering — these values are NOT authoritative. %1").arg(SessiondPolicy.lastError)
}
ConfigSpinBox {
icon: "timer_off"
text: Translation.tr("Blank the lock screen after (seconds, 0 = never)")
value: SessiondPolicy.lockBlankAfterSecs
from: 0
to: 600
stepSize: 5
enabled: SessiondPolicy.available
onValueChanged: {
if (SessiondPolicy.available && value !== SessiondPolicy.lockBlankAfterSecs)
SessiondPolicy.apply({ lock_blank_after_secs: value });
}
StyledToolTip {
text: Translation.tr("How long a locked, lit screen waits before going dark. 0 keeps it lit forever — the desk-clock case.")
}
}
ConfigSpinBox {
icon: "back_hand"
text: Translation.tr("…when the device is being held (seconds)")
value: SessiondPolicy.lockBlankAfterHeldSecs
from: 0
to: 600
stepSize: 5
enabled: SessiondPolicy.available
onValueChanged: {
if (SessiondPolicy.available && value !== SessiondPolicy.lockBlankAfterHeldSecs)
SessiondPolicy.apply({ lock_blank_after_held_secs: value });
}
StyledToolTip {
text: Translation.tr("The same budget when the accelerometer says the device is in a hand rather than on a table.")
}
}
ConfigSpinBox {
icon: "brightness_medium"
text: Translation.tr("Dim warning before blanking (seconds)")
value: SessiondPolicy.dimGraceSecs
from: 1
to: 120
stepSize: 1
enabled: SessiondPolicy.available && SessiondPolicy.dimWarning
onValueChanged: {
if (SessiondPolicy.available && value !== SessiondPolicy.dimGraceSecs)
SessiondPolicy.apply({ dim_grace_secs: value });
}
StyledToolTip {
text: Translation.tr("The panel visibly fades this long before it goes dark. A tap inside the window cancels the blank.")
}
}
ConfigSwitch {
buttonIcon: "brightness_low"
text: Translation.tr("Show the dim warning")
checked: SessiondPolicy.dimWarning
enabled: SessiondPolicy.available
onCheckedChanged: {
if (SessiondPolicy.available && checked !== SessiondPolicy.dimWarning)
SessiondPolicy.apply({ dim_warning: checked });
}
}
StyledText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
text: Translation.tr("The screen never goes dark on an unlocked session. sessiond locks first and waits %1s for the compositor to acknowledge; if it cannot, it blanks anyway and records a security error rather than pretending the session locked.")
.arg(SessiondPolicy.lockAckBudgetSecs)
}
}
} }

View file

@ -0,0 +1,125 @@
// The Settings view over souveraine-sessiond's device-state policy.
//
// TASK-19's rule is that every control is a view over the owning service "no
// success-shaped switches". The idle timers are owned by the state machine in
// sessiond, not by a JSON file in ~/.config, so a spinbox that writes
// Config.options and stops there is exactly the lie that rule forbids. It was
// that lie until 2026-07-25: the settings page moved dimAfterSeconds and
// lockAfterSeconds while the daemon that actually blanks the panel ran on its
// compiled-in defaults, because SetPolicy existed in the protocol with zero
// callers anywhere in the tree.
//
// This talks to the daemon on a SEPARATE, short-lived connection. The
// SessiondBridge socket is the heartbeat: its EOF is how sessiond learns the
// shell died, and its open line is how the authority pushes directives. Settings
// traffic does not belong on it. A second connection is harmless only
// `shell_ready` claims the authority lease, and this never sends it.
//
// Failures here are LOUD. A policy write that silently did nothing would
// recreate the exact bug this file exists to close.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
// True once a reply has been parsed until then the page must not claim
// to be showing the daemon's values.
property bool available: false
property string lastError: ""
// Mirrors DeviceStatePolicy. Seconds; 0 means "never" for the budgets.
property int lockBlankAfterSecs: 0
property int lockBlankAfterHeldSecs: 0
property int dimGraceSecs: 0
property int evidenceTtlSecs: 0
property bool dimWarning: true
property int lockAckBudgetSecs: 0
property int unlockedBlankAfterSecs: 0
signal refreshed()
signal applyFailed(string reason)
function refresh() {
root._send({ op: "get_policy" });
}
// Every field is optional daemon-side; omitted fields keep their value.
function apply(fields) {
const msg = { op: "set_policy" };
for (const k in fields) msg[k] = fields[k];
root._send(msg);
}
property var _queued: null
function _send(msg) {
if (sock.connected) {
sock.write(JSON.stringify(msg) + "\n");
return;
}
// One in flight is enough; Settings is a single page and the daemon
// answers in microseconds.
root._queued = msg;
sock.connected = true;
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (connected && root._queued) {
const m = root._queued;
root._queued = null;
sock.write(JSON.stringify(m) + "\n");
} else if (!connected && root._queued) {
root.lastError = "sessiond socket unavailable";
root.available = false;
console.error("[sessiond-policy] could not reach the daemon at "
+ sock.path + " — the idle timers shown are NOT authoritative");
root._queued = null;
root.applyFailed(root.lastError);
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.error("[sessiond-policy] unparseable reply: " + message);
root.lastError = "unparseable reply";
root.applyFailed(root.lastError);
return;
}
if (reply.ok !== true) {
const why = reply.reason || "refused without a reason";
console.error("[sessiond-policy] daemon refused: " + why);
root.lastError = why;
root.applyFailed(why);
return;
}
// get_policy answers flat; set_policy answers under `policy`.
const p = reply.policy !== undefined ? reply.policy : reply;
root.lockBlankAfterSecs = p.lock_blank_after_secs ?? 0;
root.lockBlankAfterHeldSecs = p.lock_blank_after_held_secs ?? 0;
root.dimGraceSecs = p.dim_grace_secs ?? 0;
root.evidenceTtlSecs = p.evidence_ttl_secs ?? 0;
root.dimWarning = p.dim_warning === true;
root.lockAckBudgetSecs = p.lock_ack_budget_secs ?? 0;
root.unlockedBlankAfterSecs = p.unlocked_blank_after_secs ?? 0;
root.available = true;
root.lastError = "";
root.refreshed();
}
}
}
}

View file

@ -45,6 +45,7 @@ singleton SessionAudit 1.0 SessionAudit.qml
singleton SessionEvents 1.0 SessionEvents.qml singleton SessionEvents 1.0 SessionEvents.qml
singleton SessionWarnings 1.0 SessionWarnings.qml singleton SessionWarnings 1.0 SessionWarnings.qml
singleton SessiondBridge 1.0 SessiondBridge.qml singleton SessiondBridge 1.0 SessiondBridge.qml
singleton SessiondPolicy 1.0 SessiondPolicy.qml
singleton SongRec 1.0 SongRec.qml singleton SongRec 1.0 SongRec.qml
singleton Souveraine 1.0 Souveraine.qml singleton Souveraine 1.0 Souveraine.qml
singleton Speech 1.0 Speech.qml singleton Speech 1.0 Speech.qml