Watch
1
0
Fork
You've already forked souveraine
0

Selection service: compositor-wide primary selection for TASK-18

Verified on blueline that Hyprland advertises primary-selection plus both
data-control managers, so wl-paste --primary --watch sees every selection with
no per-app hooks. Settles TASK-18's selection-detection question as
compositor-level; viewtop is not needed.

The protocol gives no selection rectangle, so anchor is the pointer position at
selection time. Opt-in by default: the watcher observes every highlight on the
device, including passwords, so it is killed while locked and selection text is
never persisted, logged, or trailed.
This commit is contained in:
Fimeg 2026-07-29 17:25:34 -04:00
commit 147999a918
3 changed files with 234 additions and 0 deletions

View file

@ -567,6 +567,33 @@ Singleton {
}
}
// Text-selection action menu (TASK-18).
//
// OPT-IN ON PURPOSE, and do not flip this default casually. When
// enabled, the shell watches the compositor's primary selection,
// which means it observes EVERY piece of text the user highlights
// anywhere on the device — including a password highlighted inside
// a password manager. No tier in SESSION-AUTHORITY-DOCTRINE §2
// covers an ambient capability of that reach, so the user turns it
// on knowingly or it does not run. Selection.qml additionally kills
// its watcher whenever the session is locked and never persists or
// logs selection content.
property JsonObject selection: JsonObject {
property bool enable: false
// Read Aloud is the one action with a live backend today; it
// routes through Speech.speak() and so also obeys
// speech.tts.enable. The rest are stubs until they earn a
// backend (TASK-18: a stubbed action is fine, a janky menu is not).
property bool readAloud: true
// Agent actions ("Talk about this" / "Add to our conversation")
// reach conversation history, which is `personal` tier — they
// stay dark until the session is genuinely unlocked.
property bool agentActions: true
// Reference lookups stay strictly local/offline by default,
// per TASK-18's stated default stance: never touch the agent.
property bool referenceActions: true
}
property JsonObject overlay: JsonObject {
property bool openingZoomAnimation: true
property bool darkenScreen: true

View file

@ -0,0 +1,206 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import Quickshell
import Quickshell.Io
import QtQuick
/**
* Selection — compositor-wide text selection, for the TASK-18 action menu.
*
* Mechanism (verified on blueline 2026-07-29): the phone's Hyprland advertises
* `zwp_primary_selection_device_manager_v1` plus BOTH data-control managers
* (`zwlr_data_control_manager_v1`, `ext_data_control_manager_v1`). data-control
* is what lets an unfocused client observe selection changes, so
* `wl-paste --primary --watch` sees every selection with no per-app hooks and no
* compositor patch. That answers TASK-18's open "selection-detection mechanism"
* question: compositor-level, not per-app. viewtop is not required for this.
*
* What the protocol does NOT give us is the selection's bounding rectangle.
* Android solves this at the toolkit layer (ActionMode/FloatingToolbar) and Apple
* in-app; neither is available to us. So `anchorX/anchorY` is the pointer
* position at selection time, which on a phone is where the finger lifted — the
* same place Android puts its floating toolbar anyway. Consumers should treat it
* as a hint and clamp themselves on screen.
*
* ── Privacy, and why this service is written defensively ──────────────────
*
* A primary-selection watcher is a firehose of personal content: it receives
* every selection on the device, including a password highlighted inside a
* password manager. Nothing in the doctrine's tier table (SESSION-AUTHORITY
* §2) covers an ambient capability of that shape, so this service takes the
* conservative reading of §9 (a reading is evidence, and this one is sensitive):
*
* - The watcher only runs while `enabled` AND the session is genuinely
* unlocked. On lock it is KILLED, not paused-and-buffered — there is
* nothing to leak from a process that isn't running.
* - `text` is cleared on lock, and never written to disk, never logged, and
* never put in the forensic trail. DEVICE-STATE-MACHINE §11 wants intent
* recorded, not leaves; the leaf here is the user's private content.
* - Lock state is read live from GlobalStates (which mirrors
* WlSessionLock.secure) rather than cached, per doctrine §4: never hold
* state the protocol owns. Gating on `screenLockSecure` and not
* `screenLocked` is deliberate — GlobalStates.qml:109 says disclosure gates
* on the compositor ack, not on the request.
* - console.log NEVER receives selection text, only its length.
*/
Singleton {
id: root
// User kill switch (settings → Selection). Default off: this capability
// reads everything the user highlights, so it is opt-in, not opt-out.
readonly property bool enabled: Config.options?.selection?.enable ?? false
// The live selection. Empty when there is none, or whenever the session is
// not genuinely unlocked. Never persisted.
property string text: ""
readonly property bool hasSelection: root.text.length > 0
// Pointer position when the selection last changed — a hint for anchoring,
// not the selection's real geometry (see the header). -1 when unknown.
property int anchorX: -1
property int anchorY: -1
// True only when it is safe to observe selections at all.
readonly property bool _permitted: root.enabled && !GlobalStates.screenLockSecure
// Selections shorter than this are almost always an accidental drag.
readonly property int _minChars: 2
// A drag fires primary-selection repeatedly as it grows. Settle before
// announcing, so consumers see one selection and not thirty.
readonly property int _settleMs: 220
signal selectionSettled(string text, int x, int y)
signal selectionCleared()
onEnabledChanged: root._reconcile()
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
root._reconcile();
}
}
// Bring the watcher in line with policy, and scrub on the way down.
function _reconcile() {
if (root._permitted) {
if (!watcher.running) {
watcher.running = true;
console.log("[Selection] watching primary selection");
}
return;
}
if (watcher.running || root.text.length > 0) {
console.log("[Selection] stopping watcher and clearing (permitted=false)");
}
watcher.running = false;
settleTimer.running = false;
root._clear();
}
function _clear() {
const had = root.text.length > 0;
root.text = "";
root.anchorX = -1;
root.anchorY = -1;
if (had) root.selectionCleared();
}
// Called by the menu when the user dismisses it or an action consumes the
// selection. Does not touch the compositor's selection — only our view of it.
function dismiss() {
settleTimer.running = false;
root._clear();
}
property string _pending: ""
Component.onCompleted: root._reconcile()
// ── the watcher ──────────────────────────────────────────────────────
// `--watch cat` makes wl-paste run `cat` per change with the selection on
// that child's stdin; wl-paste relays it to our stdout, so one long-lived
// process yields a stream of selections. -n keeps trailing newlines off.
// WAYLAND_DISPLAY is set explicitly: remote/`systemctl --user` contexts do
// not inherit it reliably on this device.
Process {
id: watcher
command: ["sh", "-c",
"WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-1} " +
"exec wl-paste --primary --watch sh -c 'cat; printf \"\\036\"'"]
// \036 (record separator) terminates each selection: selections contain
// newlines, so a line-based split would fragment multi-line text.
stdout: SplitParser {
splitMarker: "\u001e"
onRead: data => root._onSelection(data)
}
onExited: (exitCode) => {
if (!root._permitted) return; // intentional stop
if (exitCode !== 0 && exitCode !== 15)
console.log("[Selection] watcher exited", exitCode, "- selection menu is inert");
}
}
function _onSelection(raw) {
if (!root._permitted) return;
const t = String(raw ?? "");
if (t.trim().length < root._minChars) {
// A cleared or trivial selection retires the menu rather than
// leaving a stale one anchored over nothing.
settleTimer.running = false;
root._clear();
return;
}
root._pending = t;
// Restart the settle window on every growth tick.
settleTimer.restart();
}
Timer {
id: settleTimer
interval: root._settleMs
repeat: false
onTriggered: {
if (!root._permitted || root._pending.length === 0) return;
// Ask for the pointer position only once the selection has settled —
// one hyprctl call per selection, not one per drag tick.
cursorPos.running = true;
}
}
// Anchor hint. A query, not a dispatch — the phone's hyprctl is a Lua-eval
// variant where classic dispatch syntax fails, but queries are unaffected.
// Failure is non-fatal: the menu falls back to its own placement.
Process {
id: cursorPos
command: ["sh", "-c",
"WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-1} hyprctl cursorpos 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
let x = -1, y = -1;
const m = String(text).match(/(-?\d+)\s*,\s*(-?\d+)/);
if (m) { x = parseInt(m[1]); y = parseInt(m[2]); }
root._announce(x, y);
}
}
onExited: (exitCode) => {
// StdioCollector already announced on success; only cover the case
// where the process died without producing parseable output.
if (exitCode !== 0 && root._pending.length > 0)
root._announce(-1, -1);
}
}
function _announce(x, y) {
if (!root._permitted || root._pending.length === 0) return;
root.text = root._pending;
root._pending = "";
root.anchorX = x;
root.anchorY = y;
// Length only — never the content.
console.log(`[Selection] settled: ${root.text.length} chars, anchor ${x},${y}`);
root.selectionSettled(root.text, x, y);
}
}

View file

@ -44,6 +44,7 @@ singleton PhysicalKeyboard 1.0 PhysicalKeyboard.qml
singleton PolkitService 1.0 PolkitService.qml
singleton Privacy 1.0 Privacy.qml
singleton ResourceUsage 1.0 ResourceUsage.qml
singleton Selection 1.0 Selection.qml
singleton SessionAudit 1.0 SessionAudit.qml
singleton SessionEvents 1.0 SessionEvents.qml
singleton SessionWarnings 1.0 SessionWarnings.qml