/* * Copyright (C) 2026 Casey Tunturi * SPDX-License-Identifier: AGPL-3.0-or-later */ // One live hand between the face and usb-hid-inject. // // The page never opens /dev/hidg* and never spawns a process per pointer // report. This singleton owns one streaming helper while USB Hands is joined, // batches motion to the display clock, and exposes the same verbs over // QuickShell IPC so the active Souveraine agent can use the hand Casey opened. // Configfs and role changes remain sessiond -> usb-signaller business. pragma Singleton pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Io import qs Singleton { id: root property bool active: false property bool ready: false property string lastError: "" readonly property bool modeReady: UsbState.mode === "hid" || UsbState.mode === "kvm" readonly property bool permitted: root.active && !GlobalStates.screenLockSecure readonly property bool canInject: root.permitted && root.modeReady && root.ready // Loaded only while this surface is joined. It tells the agent what the // newly-present hand can do without teaching every ordinary conversation // about a USB gadget it is not using. readonly property string skill: "USB Hands is joined to this conversation. " + "Casey is using the phone as a trackpad and voice-first controller for an attached host. " + "When he asks you to type or press a key there, use the existing bridge: " + "`qs -c souveraine ipc call usbHands sendText TEXT`, " + "`qs -c souveraine ipc call usbHands tap KEY`, " + "`qs -c souveraine ipc call usbHands key KEY MODIFIERS`, or " + "`qs -c souveraine ipc call usbHands click left|right|middle`. " + "Ask `qs -c souveraine ipc call usbHands status` rather than assuming the cable is armed. " + "Do not inject anything Casey did not ask to send to the attached host." signal controllerChanged() signal refused(string reason) function open(): bool { if (GlobalStates.screenLockSecure) { root.lastError = "Unlock before giving the glass a hand on another machine"; root.refused(root.lastError); return false; } root.lastError = ""; root.active = true; UsbState.refresh(); root._reconcile(); root.controllerChanged(); return true; } function close() { if (root.ready) { root._flushPointer(); injector.write("release\n"); } root.active = false; root.ready = false; root._pendingX = 0; root._pendingY = 0; root._pendingWheel = 0; flushTimer.stop(); root._reconcile(); root.controllerChanged(); } function arm() { if (!root.active) { root.lastError = "USB Hands is not joined"; root.refused(root.lastError); root.controllerChanged(); return false; } if (!UsbState.hasMode("hid_mode")) { root.lastError = "The installed gadget does not advertise HID mode"; root.refused(root.lastError); root.controllerChanged(); return false; } UsbState.setMode("hid"); return true; } function retry() { root.lastError = ""; if (injector.running) injector.running = false; root._reconcile(); } function _reconcile() { const wanted = root.permitted && root.modeReady; if (!wanted) { root.ready = false; if (injector.running) injector.running = false; return; } if (!injector.running) { root.ready = false; injector.running = true; } } function _notReadyReason(): string { if (!root.active) return "USB Hands is not joined"; if (GlobalStates.screenLockSecure) return "The glass is locked"; if (!root.modeReady) return "Arm HID or KVM mode first"; if (root.lastError.length > 0) return root.lastError; return "The HID bridge is still waking"; } function _write(command): bool { if (!root.canInject) { root.lastError = root._notReadyReason(); root.refused(root.lastError); root.controllerChanged(); return false; } injector.write(command + "\n"); return true; } function sendText(value): bool { const lines = String(value ?? "").replace(/\r\n/g, "\n").split("\n"); if (lines.length === 1 && lines[0].length === 0) return false; for (let i = 0; i < lines.length; i++) { if (lines[i].length > 0 && !root._write("type " + lines[i])) return false; if (i + 1 < lines.length && !root._write("key enter")) return false; } return true; } function key(name, modifiers = ""): bool { const keyName = String(name ?? "").trim(); const mods = String(modifiers ?? "").trim().replace(/[,]+/g, " "); if (!/^[A-Za-z0-9]+$/.test(keyName) || (mods.length > 0 && !/^[A-Za-z ]+$/.test(mods))) { root.lastError = "Key names and modifiers must be plain words"; root.refused(root.lastError); root.controllerChanged(); return false; } return root._write("key " + keyName + (mods.length > 0 ? " " + mods : "")); } function click(button = "left"): bool { const name = String(button ?? "left").toLowerCase(); if (!["left", "right", "middle"].includes(name)) { root.lastError = "Unknown pointer button: " + name; root.refused(root.lastError); root.controllerChanged(); return false; } root._flushPointer(); return root._write("click " + name); } property int _pendingX: 0 property int _pendingY: 0 property int _pendingWheel: 0 function movePointer(x, y, wheel = 0): bool { if (!root.canInject) { root.lastError = root._notReadyReason(); root.refused(root.lastError); root.controllerChanged(); return false; } root._pendingX += Math.round(Number(x)); root._pendingY += Math.round(Number(y)); root._pendingWheel += Math.round(Number(wheel)); if (!flushTimer.running) flushTimer.start(); return true; } function _flushPointer() { if (!root.ready) return; const x = root._pendingX; const y = root._pendingY; const wheel = root._pendingWheel; root._pendingX = 0; root._pendingY = 0; root._pendingWheel = 0; if (x !== 0 || y !== 0 || wheel !== 0) root._write("pointer " + x + " " + y + " " + wheel + " 0"); } Timer { id: flushTimer interval: 16 repeat: false onTriggered: root._flushPointer() } Process { id: injector command: ["usb-hid-inject", "stream"] stdinEnabled: true stdout: SplitParser { splitMarker: "\n" onRead: line => { if (line.trim() !== "ready") return; root.ready = true; root.lastError = ""; root.controllerChanged(); } } stderr: SplitParser { splitMarker: "\n" onRead: line => { const message = line.trim().replace(/^error:\s*/, ""); if (message.length === 0) return; root.lastError = message; root.controllerChanged(); } } onExited: (exitCode, exitStatus) => { root.ready = false; if (root.permitted && root.modeReady && root.lastError.length === 0) root.lastError = "HID bridge exited (" + exitCode + ")"; root.controllerChanged(); } } Connections { target: UsbState function onRefreshed() { root._reconcile(); root.controllerChanged(); } function onChangeFailed(reason) { root.lastError = reason; root.controllerChanged(); } } Connections { target: GlobalStates function onScreenLockSecureChanged() { if (GlobalStates.screenLockSecure && root.active) root.close(); } } onActiveChanged: root._reconcile() onModeReadyChanged: { root._reconcile(); root.controllerChanged(); } IpcHandler { target: "usbHands" function status(): string { return JSON.stringify({ active: root.active, ready: root.ready, mode: UsbState.mode, error: root.lastError }); } function sendText(text: string): string { return root.sendText(text) ? "sent" : root._notReadyReason(); } function key(keyName: string, modifiers: string): string { return root.key(keyName, modifiers) ? "sent" : root._notReadyReason(); } function tap(keyName: string): string { return root.key(keyName) ? "sent" : root._notReadyReason(); } function click(button: string): string { return root.click(button) ? "sent" : root._notReadyReason(); } function pointer(x: int, y: int, wheel: int): string { return root.movePointer(x, y, wheel) ? "sent" : root._notReadyReason(); } } }