Watch
1
0
Fork
You've already forked souveraine
0

the avatar draws: serve the rig over loopback, not the opaque scheme

This commit is contained in:
casey 2026-08-05 22:14:07 -04:00
commit 3d3eb2cc8e
4 changed files with 144 additions and 42 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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;
}
}
}
}

View file

@ -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