the avatar draws: serve the rig over loopback, not the opaque scheme
This commit is contained in:
parent
a5f0fadba8
commit
3d3eb2cc8e
4 changed files with 144 additions and 42 deletions
|
|
@ -28,7 +28,7 @@
|
|||
//! serve our own assets would be a listening socket for no reason. A custom
|
||||
//! scheme is wry's answer and costs nothing.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -132,12 +132,23 @@ fn run() -> Result<()> {
|
|||
|
||||
if let Some(dir) = rig {
|
||||
let dir = dir.canonicalize().context("resolving the rig directory")?;
|
||||
// Loopback HTTP, not the custom scheme — and this is the second time
|
||||
// this exact wall has been hit. TASK-59 measured it for `file://`:
|
||||
// `XMLHttpRequest` for `model.json` fails with status 0 no matter what
|
||||
// access flags are set, because the origin is opaque. A custom scheme
|
||||
// is opaque in the same way, so the Cubism runtime — which fetches
|
||||
// `model.json`, the `.moc` and every texture over XHR — draws nothing
|
||||
// and reports no error, which is precisely the "canvas is there and
|
||||
// nothing draws" the task warned would cost an afternoon.
|
||||
//
|
||||
// The task preferred a custom scheme to avoid "a listening socket for
|
||||
// no reason". The reason turned out to be real. It binds 127.0.0.1 on
|
||||
// an ephemeral port, so it is reachable only from this machine and only
|
||||
// for as long as the face is up.
|
||||
let port = serve_rig_over_loopback(dir)?;
|
||||
builder = builder
|
||||
.with_custom_protocol("rig".into(), move |_id, request| {
|
||||
serve_asset(&dir, request.uri().path())
|
||||
})
|
||||
.with_initialization_script(INIT_SCRIPT)
|
||||
.with_url("rig://localhost/index.html");
|
||||
.with_url(format!("http://127.0.0.1:{port}/index.html"));
|
||||
} else if let Some(u) = url {
|
||||
builder = builder.with_url(u);
|
||||
}
|
||||
|
|
@ -194,6 +205,60 @@ fn serve_ipc(path: &std::path::Path, proxy: EventLoopProxy<FromShell>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Serve the rig on 127.0.0.1, returning the port it landed on.
|
||||
///
|
||||
/// Deliberately the smallest thing that answers GET: the client is one webview
|
||||
/// on the same machine fetching a dozen static files, so a dependency here
|
||||
/// would be weight for nothing.
|
||||
fn serve_rig_over_loopback(dir: PathBuf) -> Result<u16> {
|
||||
let listener =
|
||||
std::net::TcpListener::bind(("127.0.0.1", 0)).context("binding the rig server")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let dir = dir.clone();
|
||||
// One thread per request. The webview opens a handful in parallel
|
||||
// for the textures, and a serial loop would deadlock the page
|
||||
// waiting on itself.
|
||||
std::thread::spawn(move || {
|
||||
let _ = answer_request(stream, &dir);
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
fn answer_request(mut stream: std::net::TcpStream, dir: &std::path::Path) -> io::Result<()> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
let path = line.split_whitespace().nth(1).unwrap_or("/").to_string();
|
||||
// Drain the headers so the client is not left writing into a full buffer.
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
if reader.read_line(&mut h)? == 0 || h == "\r\n" || h == "\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let path = path.split('?').next().unwrap_or("/");
|
||||
let response = serve_asset(dir, path);
|
||||
let status = response.status().as_u16();
|
||||
let mime = response
|
||||
.headers()
|
||||
.get("Content-Type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let body = response.body();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status} OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
stream.write_all(body)?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
/// Serve one file out of the rig directory.
|
||||
///
|
||||
/// Path traversal is refused rather than sanitised: the only correct answer to
|
||||
|
|
|
|||
|
|
@ -45,27 +45,6 @@ Item {
|
|||
anchors.fill: parent
|
||||
hoverEnabled: !Config.options.bar.tooltips.clickToShow
|
||||
|
||||
// Double tap summons her face, and dismisses it. TASK-59 Q0 asked how
|
||||
// she is reached; Casey, 2026-08-05: *"I double tap on the clock widget
|
||||
// and I get the avatar."* Deliberately not always-on — the webview is
|
||||
// 283 MB measured, which on a 3.5 GB daily driver is a design argument
|
||||
// and not only a taste one.
|
||||
//
|
||||
// A toggle rather than a summon, because the same gesture has to be the
|
||||
// way out: a presence you cannot dismiss is the user's column being
|
||||
// ignored (doctrine §13, and TASK-59 Q4).
|
||||
property real lastTapAt: -1
|
||||
readonly property int doubleTapInterval: 350
|
||||
onClicked: {
|
||||
const now = Date.now();
|
||||
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
|
||||
lastTapAt = -1;
|
||||
Face.toggle();
|
||||
} else {
|
||||
lastTapAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
ClockWidgetPopup {
|
||||
hoverTarget: mouseArea
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,4 +219,42 @@ Item {
|
|||
style: Config.options.background.widgets.clock.cookie.dateStyle
|
||||
}
|
||||
}
|
||||
// Double tap summons Annie. Casey, 2026-08-05: *"I double tap on the clock
|
||||
// widget and I get the avatar."* The desktop clock, not the bar's — this is
|
||||
// the one on the wallpaper, on home, where she has room to stand.
|
||||
//
|
||||
// A toggle, because the same gesture has to be the way out: a presence you
|
||||
// cannot dismiss is the user's column being ignored (doctrine §13). The
|
||||
// flip/fade/puff the clock should do on the way is deliberately not here
|
||||
// yet — the trigger first, the theatre after.
|
||||
// Touching the singleton here is what constructs it. Quickshell builds
|
||||
// singletons lazily, so `Face`'s IpcHandler did not register until
|
||||
// something reached for it — `qs ipc call face status` answered "Target not
|
||||
// found" while the double tap below worked fine, because the tap was the
|
||||
// first reference. The dial and the agent both need it addressable before
|
||||
// anyone has tapped anything.
|
||||
readonly property bool faceReady: Face.rigPresent
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
// Above the clock's children, not below. `z: -1` was the first guess
|
||||
// and it is why the first version did nothing: the clock's own visual
|
||||
// items sit at the default z, so the handler was underneath every one
|
||||
// of them. Nothing here competes for the tap — the clock has no other
|
||||
// input at all, and never has.
|
||||
z: 1
|
||||
property real lastTapAt: -1
|
||||
onClicked: {
|
||||
const now = Date.now();
|
||||
console.log("[face] clock tap at " + now
|
||||
+ " (delta " + (lastTapAt > 0 ? now - lastTapAt : -1) + ")");
|
||||
if (lastTapAt > 0 && now - lastTapAt <= 350) {
|
||||
lastTapAt = -1;
|
||||
Face.toggle();
|
||||
} else {
|
||||
lastTapAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,25 +50,18 @@ Singleton {
|
|||
+ "These are the postures the presence system already uses. "
|
||||
+ "Use them sparingly, where the shift is real."
|
||||
|
||||
// The rig is not in the repo and never will be — it is licensed assets on
|
||||
// Casey's own device (TASK-59). So its absence is the ordinary state of any
|
||||
// machine that has not staged it, and it must read as "not set up here"
|
||||
// rather than as a broken face.
|
||||
property bool rigPresent: rigProbe.exists
|
||||
FileView {
|
||||
id: rigProbe
|
||||
path: root.rigDir + "/index.html"
|
||||
preload: true
|
||||
}
|
||||
|
||||
// No pre-flight check on the rig.
|
||||
//
|
||||
// There was one, reading `FileView.exists`, and it reported false for a
|
||||
// directory that was plainly there — so the guard meant to explain a
|
||||
// missing rig became the thing preventing a present one from loading. The
|
||||
// host already fails loudly and specifically when the directory is wrong,
|
||||
// and `onExited` puts `joined` back, so the honest answer is to let it try
|
||||
// and report what actually happened. A guard that can be wrong about the
|
||||
// world is worse than no guard.
|
||||
function join() {
|
||||
if (root.joined)
|
||||
return;
|
||||
if (!root.rigPresent) {
|
||||
console.log("[face] no rig at " + root.rigDir
|
||||
+ " — stage it with tools/stage-face.sh; not joining");
|
||||
return;
|
||||
}
|
||||
host.running = true;
|
||||
root.joined = true;
|
||||
}
|
||||
|
|
@ -114,6 +107,33 @@ Singleton {
|
|||
|
||||
signal tapped(string area)
|
||||
|
||||
// Reachable by name, so the dial, a launcher and the agent all summon her
|
||||
// the same way rather than each growing a copy (TASK-30/31). `status`
|
||||
// answers rather than assumes — an agent that cannot ask whether she is up
|
||||
// has to guess, and guessing is what a verb table exists to stop.
|
||||
IpcHandler {
|
||||
target: "face"
|
||||
|
||||
function toggle(): void {
|
||||
root.toggle();
|
||||
}
|
||||
|
||||
function join(): void {
|
||||
root.join();
|
||||
}
|
||||
|
||||
function leave(): void {
|
||||
root.leave();
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
joined: root.joined,
|
||||
rig: root.rigDir
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Socket {
|
||||
id: sock
|
||||
path: root.socketPath
|
||||
|
|
|
|||
Loading…
Reference in a new issue