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:
parent
6eb87a542d
commit
24ce6d382b
4 changed files with 227 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
surfaces/quickshell/services/SessiondPolicy.qml
Normal file
125
surfaces/quickshell/services/SessiondPolicy.qml
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue