From 0bebc779c622bc6bb075a9b173a39ece0dffc1a5 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Wed, 29 Jul 2026 17:47:20 -0400 Subject: [PATCH] Selection menu surface: chip that expands to actions (TASK-18) 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. --- surfaces/quickshell/deploy.sh | 8 + .../souveraine/selection/SelectionAction.qml | 79 +++++++++ .../souveraine/selection/SelectionActions.qml | 134 +++++++++++++++ .../souveraine/selection/SelectionChip.qml | 77 +++++++++ .../souveraine/selection/SelectionHost.qml | 154 ++++++++++++++++++ .../selection/SelectionSeparator.qml | 10 ++ .../modules/souveraine/selection/qmldir | 5 + .../panelFamilies/SouveraineFamily.qml | 8 + .../quickshell/services/DeviceEvidence.qml | 130 +++++++++++++++ surfaces/quickshell/services/qmldir | 1 + 10 files changed, 606 insertions(+) create mode 100644 surfaces/quickshell/modules/souveraine/selection/SelectionAction.qml create mode 100644 surfaces/quickshell/modules/souveraine/selection/SelectionActions.qml create mode 100644 surfaces/quickshell/modules/souveraine/selection/SelectionChip.qml create mode 100644 surfaces/quickshell/modules/souveraine/selection/SelectionHost.qml create mode 100644 surfaces/quickshell/modules/souveraine/selection/SelectionSeparator.qml create mode 100644 surfaces/quickshell/modules/souveraine/selection/qmldir create mode 100644 surfaces/quickshell/services/DeviceEvidence.qml diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index fb6fd33..e0e090a 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -38,10 +38,18 @@ services/Ai.qml souveraine/services/Ai.qml services/Cellular.qml souveraine/services/Cellular.qml services/ChargeRate.qml souveraine/services/ChargeRate.qml services/Haptics.qml souveraine/services/Haptics.qml +services/Selection.qml souveraine/services/Selection.qml +services/DeviceEvidence.qml souveraine/services/DeviceEvidence.qml services/Gestures.qml souveraine/services/Gestures.qml modules/souveraine/dial/RadialDial.qml souveraine/modules/souveraine/dial/RadialDial.qml modules/souveraine/dial/DialHost.qml souveraine/modules/souveraine/dial/DialHost.qml modules/souveraine/dial/qmldir souveraine/modules/souveraine/dial/qmldir +modules/souveraine/selection/SelectionHost.qml souveraine/modules/souveraine/selection/SelectionHost.qml +modules/souveraine/selection/SelectionChip.qml souveraine/modules/souveraine/selection/SelectionChip.qml +modules/souveraine/selection/SelectionActions.qml souveraine/modules/souveraine/selection/SelectionActions.qml +modules/souveraine/selection/SelectionAction.qml souveraine/modules/souveraine/selection/SelectionAction.qml +modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml +modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir services/Network.qml souveraine/services/Network.qml services/TaskbarApps.qml souveraine/services/TaskbarApps.qml services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionAction.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionAction.qml new file mode 100644 index 0000000..8c814d4 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionAction.qml @@ -0,0 +1,79 @@ +// One row in the selection menu. +// +// A disabled row still renders and still explains itself — the same choice +// RadialDial makes with its `reason` in the middle of the ring. A greyed-out +// entry teaches nothing; a row that says "Needs the side-shoot seam in Ai.qml" +// tells you what is missing. +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +Item { + id: root + + property string icon: "radio_button_unchecked" + property string label: "" + property string sublabel: "" + /** LockContentPolicy tier this action's effect belongs to. */ + property string tier: LockContentPolicy.ambient + /** Why this is unavailable. Shown in place of the sublabel when disabled. */ + property string reason: "" + + // `enabled` is the Item property; a disabled row is visible but inert. + signal triggered() + + Layout.fillWidth: true + implicitHeight: root.sublabel.length > 0 || (!root.enabled && root.reason.length > 0) + ? 56 : 44 + + MouseArea { + anchors.fill: parent + enabled: root.enabled + onClicked: { + Haptics.trigger("button-pressed"); + root.triggered(); + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 12 + + MaterialSymbol { + text: root.icon + iconSize: 20 + opacity: root.enabled ? 1.0 : 0.4 + color: Appearance.colors.colOnLayer1 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + StyledText { + Layout.fillWidth: true + text: root.label + elide: Text.ElideRight + opacity: root.enabled ? 1.0 : 0.4 + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.small + } + + StyledText { + Layout.fillWidth: true + visible: text.length > 0 + // The refusal replaces the description: when a row cannot act, + // what it would have done is less useful than why it cannot. + text: root.enabled ? root.sublabel : root.reason + elide: Text.ElideRight + wrapMode: Text.NoWrap + color: Appearance.colors.colSubtext + font.pixelSize: Appearance.font.pixelSize.smaller + } + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionActions.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionActions.qml new file mode 100644 index 0000000..4f281fb --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionActions.qml @@ -0,0 +1,134 @@ +// The expanded state: the action list. +// +// Grouping follows TASK-18's sections, and its stated default for progressive +// disclosure: Read Aloud and the agent actions at this level, Reference behind +// "More…" because the full set is long for a phone screen. +// +// Tiers (SESSION-AUTHORITY §2) are enforced here rather than assumed. The whole +// surface is already unreachable on the lock screen — Selection.qml kills its +// watcher unless the session is genuinely unlocked — but each action still +// declares its own tier so the rule is legible where the action lives, and so +// this file stays correct if the surface is ever reached another way. +// +// An action with no backend says so. TASK-18: "A menu whose actions are stubs is +// acceptable; a janky menu is not" — but a stub that looks live and does nothing +// is worse than either, so `reason` is shown rather than a dead tap. +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import qs +import qs.services +import qs.modules.common + +ColumnLayout { + id: root + + signal acted() + + property bool showReference: false + + readonly property string tierAmbient: LockContentPolicy.ambient + readonly property string tierPersonal: LockContentPolicy.personal + + implicitWidth: 264 + spacing: 0 + + // ── Read Aloud — the one live backend ──────────────────────────────── + // The literal selected text, no agent ingestion. Routes through + // Speech.speak(), which reads the who->voice mapping from souveraine's + // /v1/config, so the shell still picks no voice of its own. + SelectionAction { + icon: "volume_up" + label: Translation.tr("Read aloud") + tier: root.tierAmbient + enabled: Config.options.selection.readAloud && Speech.enabled + reason: !Config.options.selection.readAloud + ? Translation.tr("Turned off in Settings") + : Translation.tr("Enable TTS in Settings → Speech") + onTriggered: { + Speech.speak(Selection.text); + root.acted(); + } + } + + SelectionSeparator { visible: Config.options.selection.agentActions } + + // ── Agent actions ──────────────────────────────────────────────────── + // Both reach conversation history, which is `personal`. TASK-18 §2 is the + // spec for the distinction and it is the same one the OCR pipeline wants: + // one seeds a side-shoot, the other appends to the primary conversation. + SelectionAction { + visible: Config.options.selection.agentActions + icon: "forum" + label: Translation.tr("Talk about this") + sublabel: Translation.tr("Starts a side conversation") + tier: root.tierPersonal + enabled: false + reason: Translation.tr("Needs the side-shoot seam in Ai.qml") + onTriggered: root.acted() + } + + SelectionAction { + visible: Config.options.selection.agentActions + icon: "add_comment" + label: Translation.tr("Add to our conversation") + sublabel: Translation.tr("Appends to the current thread") + tier: root.tierPersonal + enabled: false + reason: Translation.tr("Needs the primary-conversation seam in Ai.qml") + onTriggered: root.acted() + } + + SelectionSeparator {} + + // ── Utility ────────────────────────────────────────────────────────── + SelectionAction { + icon: "content_copy" + label: Translation.tr("Copy") + tier: root.tierAmbient + // The selection is already in the primary buffer; this promotes it to + // the clipboard, which is what a phone user means by "copy". + onTriggered: { + copyProc.command = ["sh", "-c", + "WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-1} " + + "wl-paste --primary --no-newline | wl-copy"]; + copyProc.running = true; + root.acted(); + } + } + + SelectionSeparator { visible: Config.options.selection.referenceActions } + + // ── Reference, behind one more tap ─────────────────────────────────── + SelectionAction { + visible: Config.options.selection.referenceActions && !root.showReference + icon: "more_horiz" + label: Translation.tr("More…") + tier: root.tierAmbient + onTriggered: root.showReference = true + } + + // TASK-18's default stance, unchanged: strictly local, never the agent. + // None of these have a local backend on the device yet, so all three say so. + Repeater { + model: root.showReference && Config.options.selection.referenceActions + ? [ + { icon: "book_2", label: Translation.tr("Define") }, + { icon: "translate", label: Translation.tr("Translate") }, + { icon: "search", label: Translation.tr("Search the web") }, + ] + : [] + + SelectionAction { + required property var modelData + icon: modelData.icon + label: modelData.label + tier: root.tierAmbient + enabled: false + reason: Translation.tr("No local backend yet") + onTriggered: root.acted() + } + } + + Process { id: copyProc } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionChip.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionChip.qml new file mode 100644 index 0000000..4148f57 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionChip.qml @@ -0,0 +1,77 @@ +// The collapsed state: a small chip that says a selection is live and invites a +// tap. Deliberately does NOT preview the selected text. +// +// A preview would be the obvious design and it is the wrong one here. The chip +// floats over whatever app owns the selection, so a preview duplicates content +// already on screen while adding a surface that can outlive the context it came +// from — and the content may be a password. SESSION-AUTHORITY §2 puts that in +// `personal`; showing a count instead keeps the chip `ambient` and means the +// surface itself never discloses anything. +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +RowLayout { + id: root + + required property int charCount + + signal tapped() + signal dismissed() + + implicitHeight: 44 + spacing: 0 + + // Tap target: the whole chip except the dismiss affordance. + Item { + Layout.fillHeight: true + implicitWidth: label.implicitWidth + icon.implicitWidth + 26 + + MouseArea { + anchors.fill: parent + onClicked: root.tapped() + } + + RowLayout { + anchors.centerIn: parent + spacing: 6 + + MaterialSymbol { + id: icon + text: "text_select_start" + iconSize: 20 + color: Appearance.colors.colOnLayer1 + } + + StyledText { + id: label + text: root.charCount === 1 + ? Translation.tr("1 character selected") + : Translation.tr("%1 characters selected").arg(root.charCount) + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.smaller + } + } + } + + // Dismiss. Present because the mask means we never see a tap landing + // elsewhere — without this the chip's only exit is changing the selection. + Item { + Layout.fillHeight: true + implicitWidth: 40 + + MouseArea { + anchors.fill: parent + onClicked: root.dismissed() + } + + MaterialSymbol { + anchors.centerIn: parent + text: "close" + iconSize: 18 + color: Appearance.colors.colSubtext + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionHost.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionHost.qml new file mode 100644 index 0000000..9f24f92 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionHost.qml @@ -0,0 +1,154 @@ +// The selection action menu's surface (TASK-18). +// +// Two states on one layer surface: a compact chip that appears when a selection +// settles, and the action list it expands into on tap. Apple's shape, and the +// one the protocol allows — see Selection.qml for why we get the selected TEXT +// but never its bounding rectangle, and therefore why this anchors to the +// pointer rather than to the highlight. +// +// TASK-18's priority is explicit: the surface must be solid before any backend +// is wired, because "a menu whose actions are stubs is acceptable; a janky menu +// is not." So Read Aloud (the one action with a live backend) is real and the +// rest declare themselves unimplemented rather than failing silently. +// +// ── Z-order, settled here ────────────────────────────────────────────────── +// WlrLayer.Overlay, following DialHost: it puts the surface above the OSK's +// layer entirely instead of competing inside Top, which is the collision that +// made the stevia pill/menu bug (TASK-17). keyboardFocus stays None throughout +// — taking focus would disturb the source app that owns the selection, and an +// Exclusive grab is the documented way to stop touch reaching the layer below +// (tried on the polkit surface 2026-07-26). Every action here is a tap. +// +// The input mask is load-bearing. This window covers the screen so the chip can +// be placed anywhere, but `mask: Region { item: ... }` narrows the input region +// to the visible card so taps elsewhere reach the app underneath. Note the trap +// documented in OnScreenKeyboard.qml: `Region { item: null }` does NOT mean +// "no input" — it behaves like a full-window region and silently eats taps. +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs +import qs.services +import qs.modules.common + +Scope { + id: scope + + // Expanded = the action list is showing. Reset whenever the selection goes. + property bool expanded: false + + Connections { + target: Selection + function onSelectionSettled(text, x, y) { + // A new selection always re-collapses: the previous expansion was + // about the previous text. + scope.expanded = false; + } + function onSelectionCleared() { + scope.expanded = false; + } + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: win + required property var modelData + screen: win.modelData + + anchors { top: true; left: true; right: true; bottom: true } + color: "transparent" + visible: Selection.hasSelection + WlrLayershell.namespace: "souveraine:selection" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + + // Only the card is interactive; the rest of the screen is not ours. + mask: Region { item: card } + + // Anchor. Selection gives a pointer hint, which is where the finger + // lifted; -1 means it could not be resolved, and centre-bottom is + // the thumb-reachable fallback. Clamped so the card is never partly + // offscreen regardless of where the touch was. + readonly property int pad: 12 + readonly property int hintX: Selection.anchorX + readonly property int hintY: Selection.anchorY + + readonly property real targetX: { + if (win.hintX < 0) return (win.width - card.width) / 2; + return Math.max(win.pad, + Math.min(win.hintX - card.width / 2, + win.width - card.width - win.pad)); + } + readonly property real targetY: { + if (win.hintY < 0) return win.height * 0.72; + // Prefer above the touch point so the finger does not cover the + // menu; flip below when there is no room up there. + const above = win.hintY - card.height - 16; + if (above >= win.pad) return above; + return Math.min(win.hintY + 16, win.height - card.height - win.pad); + } + + Item { + id: card + x: win.targetX + y: win.targetY + width: content.implicitWidth + height: content.implicitHeight + + // Position eases so a re-selection slides rather than teleports, + // but only once mapped — animating from 0,0 on first show reads + // as the menu flying in from the corner. + Behavior on x { enabled: card.opacity > 0; animation: Appearance.animation.elementMove.numberAnimation.createObject(this) } + Behavior on y { enabled: card.opacity > 0; animation: Appearance.animation.elementMove.numberAnimation.createObject(this) } + + opacity: Selection.hasSelection ? 1 : 0 + scale: Selection.hasSelection ? 1 : 0.88 + transformOrigin: Item.Bottom + Behavior on opacity { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on scale { animation: Appearance.animation.elementMove.numberAnimation.createObject(this) } + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + border.width: 1 + border.color: Appearance.colors.colLayer2 + } + + ColumnLayout { + id: content + anchors.centerIn: parent + spacing: 0 + + // ── collapsed: the chip ────────────────────────────── + SelectionChip { + visible: !scope.expanded + charCount: Selection.text.length + onTapped: { + DeviceEvidence.touched("selection-menu"); + Haptics.trigger("button-pressed"); + scope.expanded = true; + } + onDismissed: { + DeviceEvidence.touched("selection-menu"); + Selection.dismiss(); + } + } + + // ── expanded: the actions ──────────────────────────── + SelectionActions { + visible: scope.expanded + onActed: { + DeviceEvidence.touched("selection-menu"); + Selection.dismiss(); + } + } + } + } + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionSeparator.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionSeparator.qml new file mode 100644 index 0000000..9f1fa8a --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionSeparator.qml @@ -0,0 +1,10 @@ +// Hairline between action groups. +import QtQuick +import QtQuick.Layouts +import qs.modules.common + +Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Appearance.colors.colLayer2 +} diff --git a/surfaces/quickshell/modules/souveraine/selection/qmldir b/surfaces/quickshell/modules/souveraine/selection/qmldir new file mode 100644 index 0000000..d8d5799 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/qmldir @@ -0,0 +1,5 @@ +SelectionHost 1.0 SelectionHost.qml +SelectionChip 1.0 SelectionChip.qml +SelectionActions 1.0 SelectionActions.qml +SelectionAction 1.0 SelectionAction.qml +SelectionSeparator 1.0 SelectionSeparator.qml diff --git a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml index 326d37d..82e2063 100644 --- a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml +++ b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml @@ -28,6 +28,7 @@ import qs.modules.ii.verticalBar import qs.modules.ii.wallpaperSelector import qs.modules.souveraine.dial import qs.modules.souveraine.navigation +import qs.modules.souveraine.selection // The Souveraine panel family — one family for both modes. // Starts at exact panel parity with IllogicalImpulseFamily (our overridden @@ -64,6 +65,13 @@ Scope { PanelLoader { component: Overview {} } PanelLoader { component: Polkit {} } PanelLoader { component: DialHost {} } + // Text-selection action menu (TASK-18). Gated on the opt-in config: the + // service behind it watches every selection on the device, so it must not + // load by default. See Config.options.selection. + PanelLoader { + extraCondition: Config.options.selection.enable + component: SelectionHost {} + } // Gestures is a singleton and QML instantiates singletons lazily, so its // IpcHandler never registered — `gesture` answered "Target not found" diff --git a/surfaces/quickshell/services/DeviceEvidence.qml b/surfaces/quickshell/services/DeviceEvidence.qml new file mode 100644 index 0000000..3788c01 --- /dev/null +++ b/surfaces/quickshell/services/DeviceEvidence.qml @@ -0,0 +1,130 @@ +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. + } + } + } + } +} diff --git a/surfaces/quickshell/services/qmldir b/surfaces/quickshell/services/qmldir index 0310a16..bc4d571 100644 --- a/surfaces/quickshell/services/qmldir +++ b/surfaces/quickshell/services/qmldir @@ -13,6 +13,7 @@ singleton Cliphist 1.0 Cliphist.qml singleton ConflictKiller 1.0 ConflictKiller.qml singleton CrashReporter 1.0 CrashReporter.qml singleton DateTime 1.0 DateTime.qml +singleton DeviceEvidence 1.0 DeviceEvidence.qml singleton EasyEffects 1.0 EasyEffects.qml singleton Emojis 1.0 Emojis.qml singleton FileSearch 1.0 FileSearch.qml