usb: land the HID controller the doorway was already calling
78d6743 committed PowerMenu's half and never added the file, so the
singleton in qmldir resolved to nothing and the shell would not load on a
tree that had not been hand-linked.
HidController streams to usb-hid-inject and acknowledges every accepted
report, closes both endpoint fds on the busy edge before usb-signaller
tears the functions down, and refuses non-ASCII on the glass instead of
clearing a field whose first report was never written.
The face reports the agent and host results back to the page, and
deploy.sh stages the tracked face assets before the QML switch so a new
control cannot arrive without the page that implements it.
This commit is contained in:
parent
abdf31087d
commit
32c019875c
8 changed files with 857 additions and 28 deletions
|
|
@ -9,7 +9,12 @@
|
|||
background: transparent; overflow: hidden;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
#live2d-widget { position: fixed; inset: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
button, input { font: inherit; }
|
||||
#live2d-widget {
|
||||
position: fixed; left: 0; right: 0; top: 0; height: 760px;
|
||||
z-index: 1;
|
||||
}
|
||||
#live2d { width: 100%; height: 100%; }
|
||||
#bubble {
|
||||
position: fixed; left: 8px; right: 8px; top: 12px;
|
||||
|
|
@ -17,13 +22,144 @@
|
|||
background: rgba(20,20,24,0.82); color: #f2f2f2;
|
||||
font: 15px/1.35 system-ui, sans-serif;
|
||||
opacity: 0; transition: opacity .2s ease;
|
||||
z-index: 4;
|
||||
}
|
||||
#bubble.up { opacity: 1; }
|
||||
|
||||
/*
|
||||
* The room viewtop left under her, given a purpose. Trackpad first; the
|
||||
* literal keyboard only replaces it when summoned. The dark sheer gradient
|
||||
* keeps the controls legible without putting an app-shaped slab around her.
|
||||
*/
|
||||
#hands {
|
||||
position: fixed; left: 8px; right: 8px; top: 766px; height: 226px;
|
||||
padding: 9px;
|
||||
border: 1px solid rgba(221, 205, 237, .24);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(140% 180% at 84% -50%, rgba(187, 142, 214, .25), transparent 58%),
|
||||
linear-gradient(155deg, rgba(30, 25, 38, .92), rgba(9, 9, 14, .86));
|
||||
box-shadow: 0 12px 35px rgba(0, 0, 0, .28), inset 0 1px rgba(255,255,255,.05);
|
||||
color: rgba(255,255,255,.92);
|
||||
font: 13px/1.2 system-ui, sans-serif;
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(.985);
|
||||
pointer-events: none;
|
||||
transition: opacity .18s ease, transform .22s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
#hands.up { opacity: 1; transform: none; pointer-events: auto; }
|
||||
#hands button {
|
||||
min-width: 38px; height: 38px; padding: 0 10px;
|
||||
border: 1px solid rgba(255,255,255,.10); border-radius: 13px;
|
||||
background: rgba(255,255,255,.075); color: rgba(255,255,255,.90);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
#hands button:active, #hands button.on {
|
||||
background: rgba(210, 177, 230, .23);
|
||||
transform: scale(.96);
|
||||
}
|
||||
#hands button:disabled { opacity: .42; }
|
||||
.hands-head { height: 26px; display: flex; align-items: center; gap: 6px; padding: 0 3px 5px; }
|
||||
#hands-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #d09191; box-shadow: 0 0 0 3px rgba(208,145,145,.10);
|
||||
}
|
||||
#hands.ready #hands-dot {
|
||||
background: #9bd1ad; box-shadow: 0 0 10px rgba(155,209,173,.55);
|
||||
}
|
||||
#hands-listening { display: none; color: #e8b7d8; }
|
||||
#hands.listening #hands-listening { display: inline; }
|
||||
#hands-status { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: rgba(255,255,255,.68); }
|
||||
.thread-button { width: 30px; min-width: 30px !important; height: 26px !important; padding: 0 !important; border-radius: 10px !important; }
|
||||
|
||||
#agent-form, #host-form { height: 40px; display: flex; gap: 6px; }
|
||||
#agent-input, #host-input {
|
||||
min-width: 0; flex: 1; height: 40px; padding: 0 13px;
|
||||
border: 1px solid rgba(255,255,255,.12); border-radius: 14px;
|
||||
outline: none; color: #fff; background: rgba(0,0,0,.20);
|
||||
-webkit-user-select: text; user-select: text;
|
||||
}
|
||||
#agent-input:focus, #host-input:focus { border-color: rgba(217,180,235,.55); }
|
||||
#agent-input::placeholder, #host-input::placeholder { color: rgba(255,255,255,.43); }
|
||||
#mic.listening { color: #f0b6db; box-shadow: inset 0 0 0 1px rgba(240,182,219,.45); }
|
||||
|
||||
#track-row, #keyboard-pane { height: 92px; margin-top: 6px; }
|
||||
#track-row { display: flex; gap: 6px; }
|
||||
#trackpad, #scrollpad {
|
||||
position: relative; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,.09); border-radius: 15px;
|
||||
background:
|
||||
linear-gradient(110deg, rgba(255,255,255,.035), transparent 45%),
|
||||
rgba(0,0,0,.18);
|
||||
touch-action: none;
|
||||
}
|
||||
#trackpad { flex: 1; }
|
||||
#trackpad::after {
|
||||
content: 'trackpad'; position: absolute; left: 12px; bottom: 9px;
|
||||
color: rgba(255,255,255,.27); letter-spacing: .08em; font-size: 11px;
|
||||
}
|
||||
#scrollpad { width: 42px; }
|
||||
#scrollpad::after {
|
||||
content: '↕'; position: absolute; inset: 0; display: grid; place-items: center;
|
||||
color: rgba(255,255,255,.35); font-size: 20px;
|
||||
}
|
||||
#keyboard-pane { display: none; }
|
||||
#keyboard-pane .quick-keys { display: flex; gap: 5px; margin-top: 6px; }
|
||||
#keyboard-pane .quick-keys button { flex: 1; height: 40px; padding: 0 5px; }
|
||||
#hands.keyboard #track-row { display: none; }
|
||||
#hands.keyboard #keyboard-pane { display: block; }
|
||||
|
||||
.hands-actions { height: 40px; display: flex; gap: 5px; margin-top: 6px; }
|
||||
.hands-actions button { flex: 1; height: 40px !important; padding: 0 5px !important; }
|
||||
#cad.holding {
|
||||
background: linear-gradient(90deg, rgba(210,177,230,.30), rgba(255,255,255,.08));
|
||||
transition: background .9s linear;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="live2d-widget"><canvas id="live2d"></canvas></div>
|
||||
<div id="bubble"></div>
|
||||
<section id="hands" aria-label="USB Hands">
|
||||
<div class="hands-head">
|
||||
<span id="hands-dot"></span>
|
||||
<span id="hands-status">USB hand asleep</span>
|
||||
<span id="hands-listening">listening</span>
|
||||
<button class="thread-button" id="resume-thread" title="Resume latest conversation">↶</button>
|
||||
<button class="thread-button" id="new-thread" title="Begin a new conversation">+</button>
|
||||
</div>
|
||||
<form id="agent-form">
|
||||
<button type="button" id="mic" aria-label="Start or stop listening">●</button>
|
||||
<input id="agent-input" autocomplete="off" enterkeyhint="send"
|
||||
placeholder="Tell your agent what the other screen needs…">
|
||||
<button type="submit" aria-label="Send to agent">↑</button>
|
||||
</form>
|
||||
<div id="track-row">
|
||||
<div id="trackpad" aria-label="Trackpad"></div>
|
||||
<div id="scrollpad" aria-label="Scroll"></div>
|
||||
</div>
|
||||
<div id="keyboard-pane">
|
||||
<form id="host-form">
|
||||
<input id="host-input" autocomplete="off" enterkeyhint="send"
|
||||
placeholder="Type into the attached host…">
|
||||
<button type="submit" aria-label="Type on host">↑</button>
|
||||
</form>
|
||||
<div class="quick-keys">
|
||||
<button data-key="escape">Esc</button>
|
||||
<button data-key="tab">Tab</button>
|
||||
<button data-key="backspace">⌫</button>
|
||||
<button data-key="enter">Enter</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hands-actions">
|
||||
<button id="left-click">Left</button>
|
||||
<button id="right-click">Right</button>
|
||||
<button id="keyboard-toggle">⌨</button>
|
||||
<button id="arm-hid">Arm</button>
|
||||
<button id="cad" title="Hold for Ctrl+Alt+Delete">CAD</button>
|
||||
</div>
|
||||
</section>
|
||||
<!--
|
||||
Not decoration, and not optional: `live2d.js` is not only the Cubism
|
||||
runtime. It is the reference's webpack bundle, character layer included, and
|
||||
|
|
@ -57,13 +193,16 @@
|
|||
// idle chatter that is hers comes from the subconscious, not a random line
|
||||
// table (TASK-59 Q2).
|
||||
(function () {
|
||||
var widget = document.getElementById('live2d-widget');
|
||||
var canvas = document.getElementById('live2d');
|
||||
var bubble = document.getElementById('bubble');
|
||||
var hands = document.getElementById('hands');
|
||||
var model = null;
|
||||
|
||||
function fit() {
|
||||
canvas.width = window.innerWidth * window.devicePixelRatio;
|
||||
canvas.height = window.innerHeight * window.devicePixelRatio;
|
||||
var r = widget.getBoundingClientRect();
|
||||
canvas.width = r.width * window.devicePixelRatio;
|
||||
canvas.height = r.height * window.devicePixelRatio;
|
||||
}
|
||||
window.addEventListener('resize', fit);
|
||||
fit();
|
||||
|
|
@ -183,13 +322,183 @@
|
|||
if (hideAt && Date.now() > hideAt) { hideAt = null; window.face.clear(); }
|
||||
}, 500);
|
||||
|
||||
// Press and hold to speak; a short press is still a tap.
|
||||
// ## USB Hands — one conversation wearing a trackpad
|
||||
//
|
||||
// This surface has no keyboard and is not going to get one — Casey,
|
||||
// 2026-08-06: "input is audio only on this interface, the sidepanel is the
|
||||
// text interface." So the whole of talking to her is one gesture, and the
|
||||
// page only reports its edges: the shell owns the recorder, the endpoint
|
||||
// and the transcript, the same way it already owns the conversation.
|
||||
// The page reports gestures and words; HidController owns the persistent
|
||||
// writer and Souveraine owns the conversation. Nothing here can compose a
|
||||
// gadget or grow a second chat transport.
|
||||
function hid(op, fields) {
|
||||
if (!window.souveraine) return;
|
||||
var message = fields || {};
|
||||
message.op = op;
|
||||
window.souveraine.hid(message);
|
||||
}
|
||||
|
||||
var agentPending = false, hostPending = false;
|
||||
window.hands = {
|
||||
state: function (state) {
|
||||
state = state || {};
|
||||
hands.classList.toggle('up', !!state.active);
|
||||
hands.classList.toggle('ready', !!state.ready);
|
||||
hands.classList.toggle('listening', !!state.listening);
|
||||
document.getElementById('mic').classList.toggle('listening', !!state.listening);
|
||||
|
||||
var status = document.getElementById('hands-status');
|
||||
if (state.error) status.textContent = state.error;
|
||||
else if (state.ready) status.textContent = (state.agent || 'agent') + ' · hand awake · ' + state.mode;
|
||||
else if (state.mode === 'hid' || state.mode === 'kvm') status.textContent = 'waking the HID hand…';
|
||||
else status.textContent = (state.agent || 'agent') + ' · arm the USB hand';
|
||||
|
||||
document.getElementById('agent-input').placeholder = state.thinking
|
||||
? (state.agent || 'agent') + ' is thinking…'
|
||||
: 'Tell ' + (state.agent || 'your agent') + ' what the other screen needs…';
|
||||
hands.classList.toggle('thinking', !!state.thinking);
|
||||
document.getElementById('agent-input').disabled = !!state.thinking || agentPending;
|
||||
document.getElementById('resume-thread').classList.toggle('on', state.conversation === 'resumed');
|
||||
document.getElementById('new-thread').classList.toggle('on', state.conversation === 'new');
|
||||
requestAnimationFrame(publishShape);
|
||||
},
|
||||
agentResult: function (result) {
|
||||
result = result || {};
|
||||
agentPending = false;
|
||||
var input = document.getElementById('agent-input');
|
||||
input.disabled = hands.classList.contains('thinking');
|
||||
if (result.accepted) input.value = '';
|
||||
else if (result.reason) document.getElementById('hands-status').textContent = result.reason;
|
||||
},
|
||||
hostResult: function (result) {
|
||||
result = result || {};
|
||||
hostPending = false;
|
||||
var input = document.getElementById('host-input');
|
||||
input.disabled = false;
|
||||
if (result.accepted) input.value = '';
|
||||
else if (result.reason) document.getElementById('hands-status').textContent = result.reason;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('agent-form').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var input = document.getElementById('agent-input');
|
||||
var text = input.value.trim();
|
||||
if (!text || !window.souveraine || agentPending || hands.classList.contains('thinking')) return;
|
||||
agentPending = true;
|
||||
input.disabled = true;
|
||||
window.souveraine.say(text);
|
||||
});
|
||||
|
||||
document.getElementById('mic').addEventListener('click', function () {
|
||||
if (!window.souveraine) return;
|
||||
window.souveraine.talk(hands.classList.contains('listening') ? 'end' : 'start');
|
||||
});
|
||||
document.getElementById('resume-thread').addEventListener('click', function () {
|
||||
if (window.souveraine) window.souveraine.thread('resume');
|
||||
});
|
||||
document.getElementById('new-thread').addEventListener('click', function () {
|
||||
if (window.souveraine) window.souveraine.thread('new');
|
||||
});
|
||||
|
||||
var trackpad = document.getElementById('trackpad');
|
||||
var trackDown = false, trackX = 0, trackY = 0, trackTravel = 0, trackAt = 0;
|
||||
trackpad.addEventListener('pointerdown', function (event) {
|
||||
trackDown = true; trackX = event.clientX; trackY = event.clientY;
|
||||
trackTravel = 0; trackAt = Date.now();
|
||||
try { trackpad.setPointerCapture(event.pointerId); } catch (_) {}
|
||||
});
|
||||
trackpad.addEventListener('pointermove', function (event) {
|
||||
if (!trackDown) return;
|
||||
var dx = event.clientX - trackX, dy = event.clientY - trackY;
|
||||
trackX = event.clientX; trackY = event.clientY;
|
||||
trackTravel += Math.abs(dx) + Math.abs(dy);
|
||||
hid('move', { x: Math.round(dx * 1.45), y: Math.round(dy * 1.45), wheel: 0 });
|
||||
});
|
||||
function endTrack(event) {
|
||||
if (!trackDown) return;
|
||||
trackDown = false;
|
||||
if (trackTravel < 9 && Date.now() - trackAt < 320)
|
||||
hid('click', { button: 'left' });
|
||||
try { trackpad.releasePointerCapture(event.pointerId); } catch (_) {}
|
||||
}
|
||||
trackpad.addEventListener('pointerup', endTrack);
|
||||
trackpad.addEventListener('pointercancel', function () { trackDown = false; });
|
||||
|
||||
var scrollpad = document.getElementById('scrollpad');
|
||||
var scrollDown = false, scrollY = 0, scrollCarry = 0;
|
||||
scrollpad.addEventListener('pointerdown', function (event) {
|
||||
scrollDown = true; scrollY = event.clientY;
|
||||
try { scrollpad.setPointerCapture(event.pointerId); } catch (_) {}
|
||||
});
|
||||
scrollpad.addEventListener('pointermove', function (event) {
|
||||
if (!scrollDown) return;
|
||||
scrollCarry += (event.clientY - scrollY) / 11;
|
||||
scrollY = event.clientY;
|
||||
var steps = scrollCarry < 0 ? Math.ceil(scrollCarry) : Math.floor(scrollCarry);
|
||||
if (steps !== 0) {
|
||||
scrollCarry -= steps;
|
||||
hid('move', { x: 0, y: 0, wheel: steps });
|
||||
}
|
||||
});
|
||||
scrollpad.addEventListener('pointerup', function () { scrollDown = false; scrollCarry = 0; });
|
||||
scrollpad.addEventListener('pointercancel', function () { scrollDown = false; scrollCarry = 0; });
|
||||
|
||||
document.getElementById('left-click').addEventListener('click', function () {
|
||||
hid('click', { button: 'left' });
|
||||
});
|
||||
document.getElementById('right-click').addEventListener('click', function () {
|
||||
hid('click', { button: 'right' });
|
||||
});
|
||||
document.getElementById('arm-hid').addEventListener('click', function () { hid('arm'); });
|
||||
|
||||
var keyboardToggle = document.getElementById('keyboard-toggle');
|
||||
keyboardToggle.addEventListener('click', function () {
|
||||
var showing = !hands.classList.contains('keyboard');
|
||||
hands.classList.toggle('keyboard', showing);
|
||||
keyboardToggle.classList.toggle('on', showing);
|
||||
var input = document.getElementById('host-input');
|
||||
if (showing) input.focus(); else input.blur();
|
||||
});
|
||||
document.getElementById('host-form').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var input = document.getElementById('host-input');
|
||||
if (input.value.length === 0 || hostPending) return;
|
||||
hostPending = true;
|
||||
input.disabled = true;
|
||||
hid('type', { text: input.value });
|
||||
});
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-key]'), function (button) {
|
||||
button.addEventListener('click', function () { hid('key', { key: button.dataset.key }); });
|
||||
});
|
||||
|
||||
// Ctrl+Alt+Delete is the one quick key a stray tap must not send. Its hold
|
||||
// is local feedback; only the completed edge crosses to the HID owner.
|
||||
var cad = document.getElementById('cad'), cadTimer = null, cadPointer = null;
|
||||
cad.addEventListener('pointerdown', function (event) {
|
||||
if (cadPointer !== null) return;
|
||||
cadPointer = event.pointerId;
|
||||
try { cad.setPointerCapture(event.pointerId); } catch (_) {}
|
||||
cad.classList.add('holding');
|
||||
cadTimer = setTimeout(function () {
|
||||
cadTimer = null; cad.classList.remove('holding');
|
||||
hid('key', { key: 'delete', modifiers: 'ctrl alt' });
|
||||
}, 900);
|
||||
});
|
||||
['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture'].forEach(function (name) {
|
||||
cad.addEventListener(name, function (event) {
|
||||
if (cadPointer !== null && event.pointerId !== undefined && event.pointerId !== cadPointer) return;
|
||||
if (cadTimer) { clearTimeout(cadTimer); cadTimer = null; }
|
||||
if (cadPointer !== null) {
|
||||
try { cad.releasePointerCapture(cadPointer); } catch (_) {}
|
||||
}
|
||||
cadPointer = null;
|
||||
cad.classList.remove('holding');
|
||||
});
|
||||
});
|
||||
|
||||
// Press and hold on her remains a voice path; a short press is still a tap.
|
||||
//
|
||||
// The 2026-08-06 shape was audio-only. USB Hands adds an explicit microphone
|
||||
// and a small agent field beneath her, while keeping this gesture for the
|
||||
// ordinary face. Every route still reports only its edges: the shell owns
|
||||
// the recorder, endpoint and transcript.
|
||||
//
|
||||
// The threshold separates the two meanings of touching her rather than
|
||||
// giving the tap a modifier. Below it she plays a motion; above it she
|
||||
|
|
@ -284,7 +593,8 @@
|
|||
+ ' shape_fn=' + (window.souveraine && typeof window.souveraine.shape));
|
||||
return;
|
||||
}
|
||||
var cw = window.innerWidth / GRID_X, ch = window.innerHeight / GRID_Y;
|
||||
var canvasRect = canvas.getBoundingClientRect();
|
||||
var cw = canvasRect.width / GRID_X, ch = canvasRect.height / GRID_Y;
|
||||
try {
|
||||
// Scaled down by the GPU on the way in, so the readback is 24x36 and
|
||||
// not the full panel. Must happen inside a frame: the WebGL canvas has
|
||||
|
|
@ -304,12 +614,18 @@
|
|||
// Runs merged along the row, so a full-width band is one rectangle
|
||||
// instead of 24 — the region is unioned rect by rect on the other side.
|
||||
if (!solid && runStart >= 0) {
|
||||
rects.push([Math.floor(runStart * cw), Math.floor(gy * ch),
|
||||
rects.push([Math.floor(canvasRect.left + runStart * cw),
|
||||
Math.floor(canvasRect.top + gy * ch),
|
||||
Math.ceil((gx - runStart) * cw), Math.ceil(ch)]);
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hands.classList.contains('up')) {
|
||||
var hr = hands.getBoundingClientRect();
|
||||
rects.push([Math.floor(hr.left), Math.floor(hr.top),
|
||||
Math.ceil(hr.width), Math.ceil(hr.height)]);
|
||||
}
|
||||
// Nothing drawn yet: say nothing. An empty report means "all of it" to the
|
||||
// host, and publishing that on every frame before the rig loads would make
|
||||
// her briefly swallow the screen each time she is summoned.
|
||||
|
|
@ -350,6 +666,7 @@
|
|||
// it takes to reach for something behind her, and it costs one 24x36
|
||||
// readback.
|
||||
setInterval(function () { requestAnimationFrame(publishShape); }, 500);
|
||||
if (window.souveraine) window.souveraine.ready();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ use anyhow::{Context, Result};
|
|||
use tao::event::{Event, StartCause, WindowEvent};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy};
|
||||
use tao::window::WindowBuilder;
|
||||
use wry::http::Response;
|
||||
use wry::WebViewBuilder;
|
||||
use wry::http::Response;
|
||||
|
||||
/// What the shell can tell the face to do, and what it says back.
|
||||
///
|
||||
|
|
@ -463,14 +463,22 @@ const INIT_SCRIPT: &str = r#"
|
|||
tapped: function (area) {
|
||||
window.ipc.postMessage(JSON.stringify({ event: 'tapped', area: area }));
|
||||
},
|
||||
// Press and hold her to speak, release to send (TASK-59 Q1a). Only the
|
||||
// edges cross; the shell owns the recorder, the endpoint and the
|
||||
// transcript, because this surface has no text input at all — Casey,
|
||||
// 2026-08-06: "input is audio only on this interface, the sidepanel is
|
||||
// the text interface."
|
||||
// Speech edges only. The page now also has a small agent field when USB
|
||||
// Hands is joined, but the shell still owns the recorder, endpoint and
|
||||
// transcript; the webview remains a view over one conversation.
|
||||
talk: function (phase) {
|
||||
window.ipc.postMessage(JSON.stringify({ event: 'talk', phase: phase }));
|
||||
},
|
||||
hid: function (message) {
|
||||
var body = Object.assign({ event: 'hid' }, message || {});
|
||||
window.ipc.postMessage(JSON.stringify(body));
|
||||
},
|
||||
thread: function (mode) {
|
||||
window.ipc.postMessage(JSON.stringify({ event: 'thread', mode: mode }));
|
||||
},
|
||||
ready: function () {
|
||||
window.ipc.postMessage(JSON.stringify({ event: 'ready' }));
|
||||
},
|
||||
// The way back. A double tap dismisses her, which is the same gesture in
|
||||
// the same place that summoned her from the clock — and the clock cannot
|
||||
// be the way back, because by then it has faded out and a double tap on
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SRC/../.." && pwd)"
|
||||
FACE_SRC="$REPO_ROOT/assets/face"
|
||||
QS="${HOME}/.config/quickshell"
|
||||
II="${QS}/ii"
|
||||
SV="${QS}/souveraine"
|
||||
|
|
@ -41,6 +43,7 @@ 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/HidController.qml souveraine/services/HidController.qml
|
||||
services/Selection.qml souveraine/services/Selection.qml
|
||||
services/DeviceEvidence.qml souveraine/services/DeviceEvidence.qml
|
||||
services/UsbState.qml souveraine/services/UsbState.qml
|
||||
|
|
@ -301,6 +304,20 @@ if [[ "${1:-}" == "--phone" ]]; then
|
|||
echo " → if any of that is work you want, copy it back into the repo now." >&2
|
||||
fi
|
||||
|
||||
# The face host reads its page and rig from ~/.souveraine/face, outside
|
||||
# the composed QML tree. Stage that tracked limb before the QML switch so
|
||||
# a newly-added control cannot arrive without the page that implements it.
|
||||
# Preserve the prior phone copy first; this is the daily driver and the
|
||||
# destination has no git history of its own.
|
||||
[[ -f "$FACE_SRC/index.html" && -d "$FACE_SRC/live2d" ]] || {
|
||||
echo "ERROR: tracked face assets are incomplete at $FACE_SRC" >&2
|
||||
exit 1
|
||||
}
|
||||
"${ssh_i[@]}" "$PHONE_USB" \
|
||||
"mkdir -p ~/.souveraine/.face-previous ~/.souveraine/face; rsync -a --delete ~/.souveraine/face/ ~/.souveraine/.face-previous/"
|
||||
rsync -ac -e "ssh -F /dev/null -i $HOME/.ssh/ani" \
|
||||
"$FACE_SRC/" "$PHONE_USB:.souveraine/face/"
|
||||
|
||||
# Checksum, not timestamp. Plain -a reports files that differ only in mtime
|
||||
# from earlier hand copies, which lies about what is actually changing.
|
||||
rsync -ac -e "ssh -F /dev/null -i $HOME/.ssh/ani" "$SRC/" "$PHONE_USB:$PHONE_DEST/"
|
||||
|
|
@ -517,4 +534,6 @@ fi
|
|||
if grep -q 'qsConfig", "ii"' "$HOME/.config/hypr/hyprland/variables.lua" 2>/dev/null; then
|
||||
echo "NOTE: hyprland qsConfig is still 'ii' — flip variables.lua to 'souveraine' to switch."
|
||||
fi
|
||||
"$REPO_ROOT/tools/stage-face.sh" >/dev/null
|
||||
echo "face assets staged at $HOME/.souveraine/face"
|
||||
echo "Run with: qs -c souveraine (restart: pkill -f 'qs -c souveraine')"
|
||||
|
|
|
|||
|
|
@ -332,10 +332,20 @@ Scope {
|
|||
SheetLocal.PowerOptionRow {
|
||||
Layout.fillWidth: true
|
||||
icon: "keyboard_mouse"
|
||||
title: "Keyboard & trackpad"
|
||||
detail: "Typing, pointer and guarded quick actions"
|
||||
badge: "app next"
|
||||
title: "Agent hands"
|
||||
detail: HidController.active
|
||||
? "Voice, trackpad and the summoned host keyboard"
|
||||
: "Join the agent to the wire; trackpad first"
|
||||
badge: HidController.ready ? "awake"
|
||||
: HidController.active ? "arm"
|
||||
: GlobalStates.screenLockSecure ? "locked" : "open"
|
||||
inset: 28
|
||||
available: UsbState.hasMode("hid_mode")
|
||||
&& !GlobalStates.screenLockSecure
|
||||
onTapped: {
|
||||
if (Face.joinHands())
|
||||
scope.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@
|
|||
// if we 'resume' it's resumed."* Two transports could not promise that; one
|
||||
// does by construction.
|
||||
//
|
||||
// USB Hands joins the same limb rather than opening a controller app beside
|
||||
// her. Its agent field, explicit microphone, trackpad and summoned host
|
||||
// keyboard occupy the room the compositor already left below her. The HID
|
||||
// reports still belong to HidController; this service only routes page intent.
|
||||
//
|
||||
// ## Turning her on is joining
|
||||
//
|
||||
// `joined` is the whole state. While it is true she is on the glass **and** the
|
||||
|
|
@ -67,7 +72,25 @@ Singleton {
|
|||
root.joined = true;
|
||||
}
|
||||
|
||||
function joinHands(): bool {
|
||||
if (!HidController.open())
|
||||
return false;
|
||||
if (!root.joined)
|
||||
root.join();
|
||||
// Continue the attached thread when one exists. Otherwise ask the
|
||||
// server for this agent's latest; an agent with no history naturally
|
||||
// mints a new conversation on the first utterance.
|
||||
if (Souveraine.conversationId.length === 0 && !Souveraine.turnActive)
|
||||
Souveraine.resumeLatestConversation();
|
||||
root._syncHands();
|
||||
return true;
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (root.listening)
|
||||
root._talk("cancel");
|
||||
if (HidController.active)
|
||||
HidController.close();
|
||||
root.joined = false;
|
||||
root._send({ op: "quit" });
|
||||
host.running = false;
|
||||
|
|
@ -80,7 +103,10 @@ Singleton {
|
|||
root.join();
|
||||
}
|
||||
|
||||
// Her size on the glass, and the canvas is not a crop — it is her scale.
|
||||
// Her body keeps the measured 540x760 canvas. The transparent 240px below
|
||||
// it is the room viewtop deliberately reserved for whatever she shares;
|
||||
// USB Hands fills that room when joined and otherwise publishes no input
|
||||
// region there, so the home screen continues to receive touch.
|
||||
//
|
||||
// The Cubism view fits the rig to the canvas, so shrinking the canvas
|
||||
// shrinks *her*; it does not trim the empty margin around her. Tried on
|
||||
|
|
@ -89,7 +115,7 @@ Singleton {
|
|||
// came down with it. The dead space is the rig's own layout (`center_y`
|
||||
// and `width` in model.json), and moving it is a rig change, not a window
|
||||
// one. 760 is the size that reads right.
|
||||
readonly property string faceSize: "540x760"
|
||||
readonly property string faceSize: "540x1000"
|
||||
|
||||
Process {
|
||||
id: host
|
||||
|
|
@ -110,31 +136,106 @@ Singleton {
|
|||
return;
|
||||
}
|
||||
// Anything the page wants to say goes through the one transport.
|
||||
if (msg.event === "said" && msg.text)
|
||||
Souveraine.send(msg.text);
|
||||
if (msg.event === "said" && msg.text) {
|
||||
const result = Souveraine.send(msg.text);
|
||||
const accepted = result === true;
|
||||
const reason = result === "step-up"
|
||||
? "Authentication is required before sending"
|
||||
: accepted ? "" : "The agent is unavailable or already answering";
|
||||
root._eval(`window.hands && window.hands.agentResult(${JSON.stringify({
|
||||
accepted: accepted,
|
||||
reason: reason
|
||||
})})`);
|
||||
}
|
||||
else if (msg.event === "tapped")
|
||||
root.tapped(msg.area ?? "body");
|
||||
else if (msg.event === "talk")
|
||||
root._talk(msg.phase);
|
||||
else if (msg.event === "hid")
|
||||
root._hid(msg);
|
||||
else if (msg.event === "thread")
|
||||
root._thread(msg.mode);
|
||||
else if (msg.event === "ready")
|
||||
root._syncHands();
|
||||
else if (msg.event === "dismiss")
|
||||
root.leave();
|
||||
else if (msg.event === "console")
|
||||
root._pageSaid(msg.level, msg.text);
|
||||
}
|
||||
}
|
||||
onExited: root.joined = false
|
||||
onExited: {
|
||||
root.joined = false;
|
||||
if (HidController.active)
|
||||
HidController.close();
|
||||
}
|
||||
}
|
||||
|
||||
signal tapped(string area)
|
||||
|
||||
function _hid(message) {
|
||||
if (!HidController.active)
|
||||
return;
|
||||
switch (message.op) {
|
||||
case "move":
|
||||
HidController.movePointer(message.x ?? 0, message.y ?? 0, message.wheel ?? 0);
|
||||
break;
|
||||
case "click":
|
||||
HidController.click(message.button ?? "left");
|
||||
break;
|
||||
case "key":
|
||||
HidController.key(message.key ?? "", message.modifiers ?? "");
|
||||
break;
|
||||
case "type": {
|
||||
const accepted = HidController.sendText(message.text ?? "");
|
||||
root._eval(`window.hands && window.hands.hostResult(${JSON.stringify({
|
||||
accepted: accepted,
|
||||
reason: accepted ? "" : HidController.lastError
|
||||
})})`);
|
||||
break;
|
||||
}
|
||||
case "arm":
|
||||
HidController.arm();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function _thread(mode) {
|
||||
if (Souveraine.turnActive)
|
||||
return;
|
||||
if (mode === "new") {
|
||||
Souveraine.newConversation();
|
||||
root._syncHands();
|
||||
} else if (mode === "resume") {
|
||||
Souveraine.resumeLatestConversation();
|
||||
}
|
||||
}
|
||||
|
||||
function _syncHands() {
|
||||
if (!root.joined)
|
||||
return;
|
||||
const agent = Souveraine.agents[Souveraine.currentAgentId];
|
||||
const state = {
|
||||
active: HidController.active,
|
||||
ready: HidController.ready,
|
||||
mode: UsbState.mode,
|
||||
error: HidController.lastError,
|
||||
agent: agent?.name ?? Souveraine.currentAgentId ?? "agent",
|
||||
conversation: Souveraine.conversationId.length > 0 ? "resumed" : "new",
|
||||
listening: root.listening,
|
||||
thinking: Souveraine.turnActive
|
||||
};
|
||||
root._eval(`window.hands && window.hands.state(${JSON.stringify(state)})`);
|
||||
}
|
||||
|
||||
// Whether she is recording right now. One flag, so a second press cannot
|
||||
// start a second recorder over the first one's WAV.
|
||||
property bool listening: false
|
||||
|
||||
// ## Holding her to speak
|
||||
// ## Speaking through her
|
||||
//
|
||||
// Press and hold, speak, release: the utterance goes through the existing
|
||||
// STT, the answer through the existing turn, and the sidebar logs both.
|
||||
// The explicit microphone beneath her is the primary affordance in USB
|
||||
// Hands; press-and-hold on the figure remains available when she is joined
|
||||
// without it. Both edges enter this one recorder and transcription path.
|
||||
// She is the *face* of the voice pipeline, not a second chat surface
|
||||
// (TASK-59 Q1a) — which is why nothing here holds a transcript or a
|
||||
// conversation, it only hands text to `Souveraine.send()`.
|
||||
|
|
@ -156,6 +257,7 @@ Singleton {
|
|||
if (root.listening)
|
||||
return;
|
||||
root.listening = true;
|
||||
root._syncHands();
|
||||
root._eval(`window.face.posture("listening")`);
|
||||
recorder.command = ["sh", "-c",
|
||||
"rm -f \"$W\"; pw-record --rate 16000 --channels 1 --format s16 \"$W\" & echo $! > \"$P\"; wait"];
|
||||
|
|
@ -165,6 +267,7 @@ Singleton {
|
|||
if (!root.listening)
|
||||
return;
|
||||
root.listening = false;
|
||||
root._syncHands();
|
||||
recorder.running = false;
|
||||
// Cancelled — a finger that slid off her, or the window losing focus.
|
||||
// The recording is dropped rather than transcribed: sending whatever
|
||||
|
|
@ -270,6 +373,18 @@ Singleton {
|
|||
id: sock
|
||||
path: root.socketPath
|
||||
connected: root.joined
|
||||
|
||||
onConnectionStateChanged: {
|
||||
if (sock.connected)
|
||||
pageReadyTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: pageReadyTimer
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: root._syncHands()
|
||||
}
|
||||
|
||||
function _send(msg) {
|
||||
|
|
@ -432,6 +547,27 @@ Singleton {
|
|||
function onTurnActiveChanged() {
|
||||
if (Souveraine.turnActive)
|
||||
root._line = "";
|
||||
root._syncHands();
|
||||
}
|
||||
|
||||
function onConversationResumed(agentId, conversationId, messages) {
|
||||
root._syncHands();
|
||||
}
|
||||
|
||||
function onCurrentAgentIdChanged() {
|
||||
root._syncHands();
|
||||
}
|
||||
|
||||
function onConversationIdChanged() {
|
||||
root._syncHands();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: HidController
|
||||
|
||||
function onControllerChanged() {
|
||||
root._syncHands();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
surfaces/quickshell/services/HidController.qml
Normal file
334
surfaces/quickshell/services/HidController.qml
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
/*
|
||||
* 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: ""
|
||||
property bool _stopping: false
|
||||
|
||||
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() {
|
||||
root._stopInjector();
|
||||
root.active = false;
|
||||
root._pendingX = 0;
|
||||
root._pendingY = 0;
|
||||
root._pendingWheel = 0;
|
||||
flushTimer.stop();
|
||||
root._reconcile();
|
||||
root.controllerChanged();
|
||||
}
|
||||
|
||||
function _stopInjector() {
|
||||
if (root.ready) {
|
||||
root._flushPointer();
|
||||
injector.write("release\n");
|
||||
}
|
||||
root.ready = false;
|
||||
if (injector.running) {
|
||||
root._stopping = true;
|
||||
injector.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 _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 text = String(value ?? "");
|
||||
// Mirror the helper's boot-keyboard map before accepting the page's
|
||||
// field. The helper still performs the authoritative full preflight,
|
||||
// but this keeps rejected Unicode on the glass instead of clearing a
|
||||
// field whose first report was never written.
|
||||
if (/[^\x09\x0a\x0d\x20-\x7e]/.test(text)) {
|
||||
root.lastError = "Host typing currently accepts ASCII keyboard characters only";
|
||||
root.refused(root.lastError);
|
||||
root.controllerChanged();
|
||||
return false;
|
||||
}
|
||||
const lines = text.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 => {
|
||||
const event = line.trim();
|
||||
if (event !== "ready" && event !== "ok")
|
||||
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 => {
|
||||
root.ready = false;
|
||||
if (root._stopping) {
|
||||
root._stopping = false;
|
||||
} else if (root.permitted && root.modeReady && root.lastError.length === 0) {
|
||||
root.lastError = "HID bridge exited (" + exitCode + ")";
|
||||
}
|
||||
root.controllerChanged();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: UsbState
|
||||
|
||||
// setMode() raises busy before it writes to sessiond. Close both HID
|
||||
// endpoint fds on that edge, before usb-signaller tears their configfs
|
||||
// functions down; a failed switch restarts the helper against the
|
||||
// still-live old mode in onChangeFailed below.
|
||||
function onBusyChanged() {
|
||||
if (UsbState.busy)
|
||||
root._stopInjector();
|
||||
}
|
||||
|
||||
function onRefreshed() {
|
||||
root._reconcile();
|
||||
root.controllerChanged();
|
||||
}
|
||||
|
||||
function onChangeFailed(reason) {
|
||||
root.lastError = reason;
|
||||
root._reconcile();
|
||||
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) ? "queued" : root._notReadyReason();
|
||||
}
|
||||
|
||||
function key(keyName: string, modifiers: string): string {
|
||||
return root.key(keyName, modifiers) ? "queued" : root._notReadyReason();
|
||||
}
|
||||
|
||||
function tap(keyName: string): string {
|
||||
return root.key(keyName) ? "queued" : root._notReadyReason();
|
||||
}
|
||||
|
||||
function click(button: string): string {
|
||||
return root.click(button) ? "queued" : root._notReadyReason();
|
||||
}
|
||||
|
||||
function pointer(x: int, y: int, wheel: int): string {
|
||||
return root.movePointer(x, y, wheel) ? "queued" : root._notReadyReason();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -343,6 +343,9 @@ Singleton {
|
|||
if (typeof Face !== "undefined" && Face.joined) {
|
||||
lines.push(Face.skill);
|
||||
}
|
||||
if (typeof HidController !== "undefined" && HidController.active) {
|
||||
lines.push(HidController.skill);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
|
@ -375,6 +378,7 @@ Singleton {
|
|||
function send(text) {
|
||||
if (!root.serverUp || root.currentAgentId.length === 0) return false;
|
||||
if (text.length === 0) return false;
|
||||
if (root.turnActive) return false;
|
||||
// Step-up gate: if enabled and no valid send grant exists, block
|
||||
// the send and emit a signal so the UI can trigger auth + retry.
|
||||
// Break-glass grants bypass normal step-up — they are one-time,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ singleton Gestures 1.0 Gestures.qml
|
|||
singleton GlobalFocusGrab 1.0 GlobalFocusGrab.qml
|
||||
singleton GoogleCloud 1.0 GoogleCloud.qml
|
||||
singleton Haptics 1.0 Haptics.qml
|
||||
singleton HidController 1.0 HidController.qml
|
||||
singleton HyprlandAntiFlashbangShader 1.0 HyprlandAntiFlashbangShader.qml
|
||||
singleton HyprlandConfig 1.0 HyprlandConfig.qml
|
||||
singleton HyprlandData 1.0 HyprlandData.qml
|
||||
|
|
|
|||
Loading…
Reference in a new issue