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:
parent
c8507e924b
commit
147999a918
3 changed files with 234 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
206
surfaces/quickshell/services/Selection.qml
Normal file
206
surfaces/quickshell/services/Selection.qml
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue