Overlay layer following DialHost, keyboardFocus None throughout, and an input mask limited to the card so taps elsewhere reach the app underneath. Anchors to the pointer hint above the touch point, clamped on screen. Chip shows a character count, never a preview: the surface floats over the app that owns the selection and the content may be a password, so it stays ambient and discloses nothing. Read Aloud is live via Speech; agent and reference actions render with the reason they cannot act yet. Adds DeviceEvidence, reporting input to the state machine's existing Request::Input with an intent label, so a new input surface is not another isolated actor per DEVICE-STATE-MACHINE 1. Opt-in: nothing loads or watches until Config.options.selection.enable.
130 lines
4.7 KiB
QML
130 lines
4.7 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
|
|
/**
|
|
* DeviceEvidence — the shell's ingress to the device state machine.
|
|
*
|
|
* DEVICE-STATE-MACHINE §1 is a list of seven actors that each saw one facet of
|
|
* the device and could not see the others. Every shell surface that takes user
|
|
* input is a candidate for becoming an eighth. This singleton exists so that
|
|
* "report that the user did something" is one line, and a new surface has no
|
|
* excuse to be an isolated unknown.
|
|
*
|
|
* The wire is `Request::Input { trigger }` (souveraine/src/sessiond/protocol.rs
|
|
* — that file is the contract): `{"op":"input","trigger":"touch"}`. It resets
|
|
* the idle budget the lock/blank rules count against, which is what lets the
|
|
* machine distinguish "the user is looking at this" from "this has been lit for
|
|
* ten minutes."
|
|
*
|
|
* `intent` rides along in the Envelope. protocol.rs is explicit that it is
|
|
* "declared, never verified... evidence in exactly the sense doctrine §9 means —
|
|
* useful for reconstruction, never a basis for a decision. Nothing branches on
|
|
* it." So it is safe to be honest in, and it is what §11 wants recorded: the
|
|
* intent, not only the leaf. Never put user content in it — a surface name, not
|
|
* what the surface was showing.
|
|
*
|
|
* No sessiond on the socket (laptop, or bring-up) = every call no-ops quietly.
|
|
* This is evidence, not an authority: a dropped report must never be an error
|
|
* the user sees.
|
|
*/
|
|
Singleton {
|
|
id: root
|
|
|
|
// Reports are coalesced: a keyboard would otherwise emit one request per
|
|
// keystroke to reset a budget measured in tens of seconds. The machine only
|
|
// needs to know the user is still there.
|
|
readonly property int _throttleMs: 2000
|
|
|
|
property double _lastSentAt: 0
|
|
property string _pendingTrigger: ""
|
|
property string _pendingIntent: ""
|
|
|
|
/**
|
|
* Report real user input.
|
|
*
|
|
* trigger: "touch" | "key" | "power_button" | "double_tap_to_wake"
|
|
* | "squeeze" | "unknown" (InputTrigger, snake_case)
|
|
* intent: short surface label, e.g. "selection-menu". No user content.
|
|
*/
|
|
function report(trigger, intent) {
|
|
const t = String(trigger ?? "unknown");
|
|
const now = Date.now();
|
|
if (now - root._lastSentAt < root._throttleMs) {
|
|
// Keep the newest label; the budget reset is idempotent so dropping
|
|
// the intervening reports costs nothing.
|
|
root._pendingTrigger = t;
|
|
root._pendingIntent = String(intent ?? "");
|
|
flushTimer.running = true;
|
|
return;
|
|
}
|
|
root._send(t, String(intent ?? ""));
|
|
}
|
|
|
|
/** Convenience for the common case: a tap on one of our own surfaces. */
|
|
function touched(intent) {
|
|
root.report("touch", intent);
|
|
}
|
|
|
|
Timer {
|
|
id: flushTimer
|
|
interval: root._throttleMs
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root._pendingTrigger.length === 0) return;
|
|
root._send(root._pendingTrigger, root._pendingIntent);
|
|
root._pendingTrigger = "";
|
|
root._pendingIntent = "";
|
|
}
|
|
}
|
|
|
|
property var _queued: null
|
|
|
|
function _send(trigger, intent) {
|
|
root._lastSentAt = Date.now();
|
|
const msg = { op: "input", trigger: trigger };
|
|
if (intent.length > 0) msg.intent = intent;
|
|
if (sock.connected) {
|
|
sock.write(JSON.stringify(msg) + "\n");
|
|
return;
|
|
}
|
|
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) {
|
|
// Quiet on purpose. Evidence is best-effort; a missing daemon is
|
|
// the laptop's normal state and must not look like a fault.
|
|
root._queued = null;
|
|
}
|
|
}
|
|
|
|
parser: SplitParser {
|
|
splitMarker: "\n"
|
|
onRead: message => {
|
|
// Nothing to do with a reply — this is fire-and-forget. Only a
|
|
// refusal is worth a line, so a protocol drift is not silent.
|
|
try {
|
|
const reply = JSON.parse(message);
|
|
if (reply.ok !== true)
|
|
console.log("[device-evidence] refused:",
|
|
reply.code ?? "?", reply.reason ?? "");
|
|
} catch (e) {
|
|
// Malformed reply is not worth escalating for a fire-and-forget.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|