Watch
1
0
Fork
You've already forked souveraine
0

wry host: sites as apps, the rig as a face; join loads the expression skill

This commit is contained in:
Fimeg 2026-08-05 19:39:50 -04:00
commit 9ec031c8a8
12 changed files with 730 additions and 11 deletions

View file

@ -157,6 +157,14 @@ jobs:
file "$NBIN" | grep -q aarch64 || { echo "sensord is not aarch64" >&2; exit 1; }
cp "$NBIN" souveraine-sensord-aarch64
# The wry host: self-hosted sites as apps, and the rig she wears.
# Needs webkit2gtk on the build sysroot; it is already installed on
# the phone (TASK-59 phase 1 measured against it).
./scripts/build-cross.sh --features web --bin souveraine-web
WBIN="$CARGO_TARGET_DIR/aarch64-unknown-linux-gnu/release/souveraine-web"
file "$WBIN" | grep -q aarch64 || { echo "web host is not aarch64" >&2; exit 1; }
cp "$WBIN" souveraine-web-aarch64
# secrets rail + machined: also hand-copied on the phone until now.
./scripts/build-cross.sh --features secrets --bin souveraine-secrets
./scripts/build-cross.sh --bin souveraine-machined
@ -200,6 +208,7 @@ jobs:
sha256sum souveraine-aarch64 souveraine-x86_64 \
souveraine-sessiond-aarch64 souveraine-sensord-aarch64 \
souveraine-web-aarch64 \
souveraine-secrets-aarch64 souveraine-secrets-x86_64 \
souveraine-machined-aarch64 souveraine-machined-x86_64 \
souveraine-sessiond-x86_64 \

1
.gitignore vendored
View file

@ -74,3 +74,4 @@ vanguard-souveraine-notes.md
/journal/
/scripts/
/website/
assets/face/live2d/

166
Cargo.lock generated
View file

@ -2065,7 +2065,20 @@ version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
dependencies = [
"cssparser-macros",
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.13.1",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"dtoa-short",
"itoa",
"phf 0.13.1",
@ -2082,6 +2095,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "ctor"
version = "0.8.0"
@ -2596,11 +2619,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set 0.8.0",
"cssparser",
"cssparser 0.36.0",
"foldhash 0.2.0",
"html5ever 0.38.0",
"precomputed-hash",
"selectors",
"selectors 0.36.1",
"tendril",
]
[[package]]
name = "dom_query"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc"
dependencies = [
"bit-set 0.8.0",
"cssparser 0.37.0",
"foldhash 0.2.0",
"html5ever 0.39.0",
"precomputed-hash",
"selectors 0.38.0",
"tendril",
]
@ -8521,7 +8559,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.11.1",
"cssparser",
"cssparser 0.36.0",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"precomputed-hash",
"rustc-hash 2.1.2",
"servo_arc",
"smallvec",
]
[[package]]
name = "selectors"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags 2.11.1",
"cssparser 0.37.0",
"derive_more",
"log",
"new_debug_unreachable",
@ -9150,6 +9207,7 @@ dependencies = [
"shellexpand",
"sqlx",
"sysinfo",
"tao 0.36.0",
"tauri",
"tauri-plugin-shell",
"tempfile",
@ -9173,6 +9231,7 @@ dependencies = [
"walkdir",
"wayland-client",
"wayland-protocols",
"wry 0.56.0",
"zbus",
"zvariant",
]
@ -9900,10 +9959,51 @@ dependencies = [
]
[[package]]
name = "tao-macros"
version = "0.1.3"
name = "tao"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
checksum = "e9fa4618f999c4249db1681cba0a19b890718f274de7fa93c445d46bd3a8a999"
dependencies = [
"bitflags 2.11.1",
"block2 0.6.2",
"core-foundation 0.10.1",
"core-graphics 0.25.0",
"crossbeam-channel",
"dbus",
"dispatch2",
"dlopen2",
"dpi",
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni 0.21.1",
"libc",
"log",
"ndk 0.9.0",
"ndk-context",
"ndk-sys 0.6.0+11769913",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"once_cell",
"parking_lot",
"percent-encoding",
"raw-window-handle",
"tao-macros",
"unicode-segmentation",
"url",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
]
[[package]]
name = "tao-macros"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
dependencies = [
"proc-macro2",
"quote",
@ -10113,14 +10213,14 @@ dependencies = [
"percent-encoding",
"raw-window-handle",
"softbuffer",
"tao",
"tao 0.35.2",
"tauri-runtime",
"tauri-utils",
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"wry",
"wry 0.55.1",
]
[[package]]
@ -10133,7 +10233,7 @@ dependencies = [
"brotli",
"cargo_metadata",
"ctor",
"dom_query",
"dom_query 0.27.0",
"dunce",
"glob",
"http",
@ -13065,7 +13165,51 @@ dependencies = [
"cookie",
"crossbeam-channel",
"dirs 6.0.0",
"dom_query",
"dom_query 0.27.0",
"dpi",
"dunce",
"gdkx11",
"gtk",
"http",
"javascriptcore-rs",
"jni 0.21.1",
"libc",
"ndk 0.9.0",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"objc2-web-kit",
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.18",
"url",
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
]
[[package]]
name = "wry"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1730ab21a962e7ff44df17d20804572beee66d998afc7fcbc3f846a3a12dbda7"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",
"cookie",
"crossbeam-channel",
"dirs 6.0.0",
"dom_query 0.28.0",
"dpi",
"dunce",
"gdkx11",

View file

@ -168,6 +168,8 @@ argon2 = { version = "0.5", optional = true }
# same reasoning as machined: fewer deps = fewer on-device build failures).
wayland-client = { version = "0.31", optional = true }
wayland-protocols = { version = "0.32", optional = true, features = ["client", "staging"] }
wry = { version = "0.56.0", optional = true }
tao = { version = "0.36.0", optional = true }
@ -220,6 +222,7 @@ strip = true
panic = "abort"
[features]
web = ["dep:wry", "dep:tao"]
default = ["figlet-rs"]
figlet-rs = ["dep:figlet-rs"]
cowsay = ["dep:cowsay"]
@ -234,3 +237,10 @@ gui = ["tuie/gui"]
secrets = ["dep:zbus", "dep:zvariant", "dep:aes", "dep:cbc", "dep:cipher", "dep:hkdf", "dep:num-bigint", "dep:num-traits", "dep:aes-gcm", "dep:argon2"]
# Session authority daemon — holds ext-session-lock before/under the shell.
sessiond = ["dep:wayland-client", "dep:wayland-protocols"]
wry = ["dep:wry"]
tao = ["dep:tao"]
[[bin]]
name = "souveraine-web"
path = "src/bin/souveraine-web.rs"
required-features = ["web"]

80
assets/face/index.html Normal file
View file

@ -0,0 +1,80 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<style>
html, body {
margin: 0; padding: 0; height: 100%; width: 100%;
background: transparent; overflow: hidden;
-webkit-user-select: none; user-select: none;
}
#live2d-widget { position: fixed; inset: 0; }
#live2d { width: 100%; height: 100%; }
#bubble {
position: fixed; left: 8px; right: 8px; top: 12px;
padding: 10px 14px; border-radius: 14px;
background: rgba(20,20,24,0.82); color: #f2f2f2;
font: 15px/1.35 system-ui, sans-serif;
opacity: 0; transition: opacity .2s ease;
}
#bubble.up { opacity: 1; }
</style>
</head>
<body>
<div id="live2d-widget"><canvas id="live2d"></canvas></div>
<div id="bubble"></div>
<script src="rig://localhost/live2d/js/live2d.js"></script>
<script>
// The character layer, reduced to what the shell drives. The reference's
// message.js owns idle chatter, a talk box and sessionStorage position; none of
// that belongs here — the shell owns the conversation and the summoning, and
// idle chatter that is hers comes from the subconscious, not a random line
// table (TASK-59 Q2).
(function () {
var canvas = document.getElementById('live2d');
var bubble = document.getElementById('bubble');
var model = null;
function fit() {
canvas.width = window.innerWidth * window.devicePixelRatio;
canvas.height = window.innerHeight * window.devicePixelRatio;
}
window.addEventListener('resize', fit);
fit();
// loadlive2d is live2d.js's entry point: canvas id, model.json url.
try {
loadlive2d('live2d', 'rig://localhost/live2d/model/histoire/model.json');
} catch (e) {
console.error('rig failed to load', e);
}
var hideAt = null;
window.face = {
// One line of her speech. Called repeatedly as tokens stream, so it
// replaces rather than appends — the shell holds the accumulated text.
say: function (text) {
bubble.textContent = text;
bubble.classList.add('up');
hideAt = Date.now() + 6000;
},
clear: function () { bubble.classList.remove('up'); },
// Posture from the shell. The rig's own motion groups are named by the
// reference (idle, tap); anything richer waits on her own rig.
posture: function (name) {
if (typeof window.live2dMotion === 'function') window.live2dMotion(name);
}
};
setInterval(function () {
if (hideAt && Date.now() > hideAt) { hideAt = null; window.face.clear(); }
}, 500);
canvas.addEventListener('click', function () {
if (window.souveraine) window.souveraine.tapped('body');
});
})();
</script>
</body>
</html>

View file

@ -24,7 +24,8 @@ sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
# rather than declaring them as sources for every arch.
if [ "$CARCH" = aarch64 ]; then
source+=('souveraine-sessiond-binary' 'souveraine-sessiond.service'
'souveraine-sensord-binary' 'souveraine-sensord.service')
'souveraine-sensord-binary' 'souveraine-sensord.service'
'souveraine-web-binary')
sha256sums+=('SKIP' 'SKIP' 'SKIP' 'SKIP')
fi
@ -44,6 +45,11 @@ package() {
fi
# Sensor reporter, aarch64 only — the laptop has no iio-sensor-proxy
# sources worth reporting and no device state machine listening.
if [ -f "$srcdir/souveraine-web-binary" ]; then
install -Dm755 "$srcdir/souveraine-web-binary" \
"$pkgdir/usr/bin/souveraine-web"
fi
if [ -f "$srcdir/souveraine-sensord-binary" ]; then
install -Dm755 "$srcdir/souveraine-sensord-binary" \
"$pkgdir/usr/bin/souveraine-sensord"

299
src/bin/souveraine-web.rs Normal file
View file

@ -0,0 +1,299 @@
//! A wry host: self-hosted sites as apps, and the rig she wears.
//!
//! Two modes, one binary, because they are the same thing pointed at different
//! content — a webview on a Wayland surface with no browser around it.
//!
//! - `--url <URL>` wraps a site as an app. Own window, own `app_id`, so the
//! compositor tiles it and the dock names it like anything else.
//! - `--rig <DIR>` is the avatar: transparent over the wallpaper, assets served
//! from a custom scheme rather than `file://`, and an IPC line to the shell.
//!
//! wry binds the system webview (WebKitGTK here) rather than shipping a second
//! browser engine — which on a 3.5 GB daily driver is the whole argument.
//! Measured 2026-08-05: the Cubism runtime renders at ~58 fps in this engine on
//! blueline, 283 MB RSS.
//!
//! **This process holds no connection to the server.** `Souveraine.qml` is the
//! shell's one transport and stays that way; the face is a limb the shell
//! drives over `--ipc`, so the avatar and the sidebar are the same conversation
//! by construction rather than by two clients agreeing. Casey, 2026-08-05:
//! *"I will want it to be in sync with the sidebar — meaning if we 'resume'
//! it's resumed."*
//!
//! ## file:// is not enough
//!
//! `XMLHttpRequest` for the rig's `model.json` is blocked from `file://` even
//! with `allow-file-access-from-file-urls` set — it fails with status 0. The
//! phase-1 spike worked around it with a local HTTP server; shipping one to
//! 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::path::PathBuf;
use anyhow::{Context, Result};
use tao::event::{Event, StartCause, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
use tao::window::WindowBuilder;
use wry::WebViewBuilder;
use wry::http::Response;
/// What the shell can tell the face to do, and what it says back.
///
/// One JSON object per line, the same house grammar sessiond and viewtop speak,
/// so nothing here is a third idea of what talking to a process looks like.
#[derive(Debug)]
enum FromShell {
/// Run a script in the page. Everything the shell drives — a line of her
/// speech, a posture change, a motion — arrives as one of these, because
/// the vocabulary belongs to the page's character layer and not to this
/// file. See `docs/tasks/59-her-face-on-the-glass.md`.
Eval(String),
Quit,
}
fn main() -> Result<()> {
let mut url: Option<String> = None;
let mut rig: Option<PathBuf> = None;
let mut ipc: Option<PathBuf> = None;
let mut app_id = String::from("org.souveraine.web");
let mut transparent = false;
let mut width = 540.0;
let mut height = 960.0;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--url" => url = args.next(),
"--rig" => rig = args.next().map(PathBuf::from),
"--ipc" => ipc = args.next().map(PathBuf::from),
"--app-id" => {
if let Some(v) = args.next() {
app_id = v;
}
}
"--transparent" => transparent = true,
"--size" => {
if let Some(v) = args.next()
&& let Some((w, h)) = v.split_once('x')
{
width = w.parse().unwrap_or(width);
height = h.parse().unwrap_or(height);
}
}
"--help" | "-h" => {
eprintln!(
"souveraine-web --url <URL> [--app-id ID] [--size WxH]\n\
souveraine-web --rig <DIR> --ipc <SOCK> --transparent"
);
return Ok(());
}
other => anyhow::bail!("unknown argument {other}"),
}
}
if url.is_none() && rig.is_none() {
anyhow::bail!("one of --url or --rig is required");
}
let event_loop: EventLoop<FromShell> = EventLoop::with_user_event();
let proxy = event_loop.create_proxy();
// The shell drives this process; it never drives the shell. Reading on its
// own thread and waking the loop through the proxy keeps the webview's
// thread free, which matters because that thread is also the renderer.
if let Some(path) = ipc.clone() {
std::thread::spawn(move || serve_ipc(&path, proxy));
}
let window = WindowBuilder::new()
.with_title("Souveraine")
.with_transparent(transparent)
.with_decorations(!transparent)
.with_inner_size(tao::dpi::LogicalSize::new(width, height))
.build(&event_loop)
.context("creating the window")?;
set_app_id(&window, &app_id);
let mut builder = WebViewBuilder::new()
.with_transparent(transparent)
// The character layer decides what a tap means and what she says; this
// only carries it. Anything the page wants the shell to know goes out
// the IPC as a line, so the shell stays the one thing talking to her.
.with_ipc_handler(move |req| {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{}", req.body());
let _ = out.flush();
});
if let Some(dir) = rig {
let dir = dir.canonicalize().context("resolving the rig directory")?;
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");
} else if let Some(u) = url {
builder = builder.with_url(u);
}
let webview = build_webview(builder, &window)?;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::NewEvents(StartCause::Init) => {}
Event::UserEvent(FromShell::Eval(script)) => {
let _ = webview.evaluate_script(&script);
}
Event::UserEvent(FromShell::Quit)
| Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => *control_flow = ControlFlow::Exit,
_ => {}
}
});
}
/// Read one JSON object per line and hand it to the loop.
fn serve_ipc(path: &std::path::Path, proxy: EventLoopProxy<FromShell>) {
let listener = match std::os::unix::net::UnixListener::bind(path) {
Ok(l) => l,
Err(e) => {
eprintln!("[face] cannot bind {}: {e}", path.display());
return;
}
};
for stream in listener.incoming().flatten() {
let reader = BufReader::new(stream);
for line in reader.lines().map_while(Result::ok) {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&line) else {
eprintln!("[face] unparseable: {line}");
continue;
};
let event = match msg.get("op").and_then(|v| v.as_str()) {
Some("eval") => msg
.get("script")
.and_then(|v| v.as_str())
.map(|s| FromShell::Eval(s.to_string())),
Some("quit") => Some(FromShell::Quit),
_ => None,
};
if let Some(event) = event
&& proxy.send_event(event).is_err()
{
return;
}
}
}
}
/// Serve one file out of the rig directory.
///
/// Path traversal is refused rather than sanitised: the only correct answer to
/// `../../etc/passwd` is no.
fn serve_asset(root: &std::path::Path, path: &str) -> Response<Vec<u8>> {
let relative = path.trim_start_matches('/');
let candidate = root.join(relative);
let ok = candidate
.canonicalize()
.map(|p| p.starts_with(root))
.unwrap_or(false);
if !ok {
return Response::builder()
.status(404)
.body(Vec::new())
.unwrap_or_default();
}
let mime = match candidate.extension().and_then(|e| e.to_str()) {
Some("html") => "text/html",
Some("js") => "text/javascript",
Some("css") => "text/css",
Some("json") => "application/json",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("moc" | "mtn") => "application/octet-stream",
_ => "application/octet-stream",
};
match std::fs::read(&candidate) {
Ok(bytes) => Response::builder()
.header("Content-Type", mime)
.body(bytes)
.unwrap_or_default(),
Err(_) => Response::builder()
.status(404)
.body(Vec::new())
.unwrap_or_default(),
}
}
/// Injected before the page's own scripts.
///
/// Two jobs. The first is a one-line removal the upstream README lists as a
/// *feature*: `message.js` refuses to load on any user agent containing
/// "android", and this is a phone. The rig loads fine, the canvas is there, and
/// nothing draws — with no error. It is worth the injection rather than a patch
/// to the vendored file so that re-vendoring upstream cannot silently restore
/// it.
///
/// The second is the talk path. The reference opens an `EventSource` straight
/// at a chat API; here the page has no server to talk to, because this process
/// has no connection to one. It posts to the shell instead.
const INIT_SCRIPT: &str = r#"
(function () {
// Look like a desktop to the character layer's mobile blocklist.
try {
Object.defineProperty(window.navigator, 'userAgent', {
get: function () { return 'Mozilla/5.0 (X11; Linux x86_64) souveraine-web'; }
});
} catch (e) {}
// The shell owns the conversation. Anything the page wants to say goes out
// as a line; anything she says comes back as an eval that calls showMessage.
window.souveraine = {
say: function (text) {
window.ipc.postMessage(JSON.stringify({ event: 'said', text: text }));
},
tapped: function (area) {
window.ipc.postMessage(JSON.stringify({ event: 'tapped', area: area }));
}
};
window.talkAPI = '';
})();
"#;
/// wry on Linux draws into the window's GTK container rather than adopting the
/// surface, so the build goes through the Unix extension. Kept in one place so
/// the two modes do not each grow a platform branch.
#[cfg(target_os = "linux")]
fn build_webview(builder: WebViewBuilder<'_>, window: &tao::window::Window) -> Result<wry::WebView> {
use tao::platform::unix::WindowExtUnix;
use wry::WebViewBuilderExtUnix;
let vbox = window
.default_vbox()
.context("the window has no gtk container")?;
builder.build_gtk(vbox).context("building the webview")
}
#[cfg(not(target_os = "linux"))]
fn build_webview(builder: WebViewBuilder<'_>, window: &tao::window::Window) -> Result<wry::WebView> {
builder.build(window).context("building the webview")
}
/// Name the surface so the compositor can tile it and the dock can label it.
/// Without this every wrapped site is an untitled window, which is TASK-51's
/// "windows stack with nothing to tell them apart" arriving by a new route.
#[cfg(target_os = "linux")]
fn set_app_id(window: &tao::window::Window, app_id: &str) {
use tao::platform::unix::WindowExtUnix;
if let Some(gtk) = window.gtk_window().window() {
let _ = gtk;
}
gtk::glib::set_prgname(Some(app_id));
gtk::gdk::set_program_class(app_id);
}
#[cfg(not(target_os = "linux"))]
fn set_app_id(_window: &tao::window::Window, _app_id: &str) {}

View file

@ -57,6 +57,7 @@ modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/win
modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml
modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
services/Face.qml souveraine/services/Face.qml
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml

View file

@ -45,6 +45,27 @@ 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

@ -0,0 +1,140 @@
// Her face on the glass TASK-59.
//
// The face is a *limb*, not a second client. `Souveraine.qml` is the one
// connection to the server and stays that way: this owns a `souveraine-web`
// process, feeds it what she is already saying on the sidebar's stream, and
// sends what the user says to it back through the same `Souveraine.send()`.
// Casey, 2026-08-05: *"I will want it to be in sync with the sidebar meaning
// if we 'resume' it's resumed."* Two transports could not promise that; one
// does by construction.
//
// ## Turning her on is joining
//
// `joined` is the whole state. While it is true she is on the glass **and** the
// expression vocabulary rides in the per-send ambient block, so she has a
// syntax for shifting expression. While it is false neither happens and the
// second half is the point: that prompt is context nobody asked for when the
// face is closed. Casey, 2026-08-05: *"it'll be like a loadable/unloadable
// skill we might have times where we just don't want that extra prompt added
// to context."*
//
// The sidebar ignores the tags it sees, which is why they are safe to leave in
// the stream rather than stripped on the way to one surface.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
Singleton {
id: root
// On the glass, and in the prompt. One flag, both consequences.
property bool joined: false
property string socketPath: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/face.sock"
property string rigDir: Quickshell.env("HOME") + "/.souveraine/face"
// Accumulated text of the turn in flight, so the bubble shows the whole
// line rather than the last delta.
property string _line: ""
// The fragment that rides in `ambient` while she is joined. Kept here
// rather than in the server so that leaving costs exactly nothing there
// is no flag to unset and no prompt to remember to remove.
readonly property string skill: "You have a face on this device right now. "
+ "You may shift expression by emitting a tag on its own line: "
+ "[[face:idle]], [[face:alert]], [[face:thinking]], [[face:processing]], "
+ "[[face:affectionate]], [[face:straining]], [[face:yawning]], "
+ "[[face:listening]], [[face:speaking]]. "
+ "These are the postures the presence system already uses. "
+ "Use them sparingly, where the shift is real."
function join() {
if (root.joined)
return;
host.running = true;
root.joined = true;
}
function leave() {
root.joined = false;
root._send({ op: "quit" });
host.running = false;
}
function toggle() {
if (root.joined)
root.leave();
else
root.join();
}
Process {
id: host
command: ["souveraine-web",
"--rig", root.rigDir,
"--ipc", root.socketPath,
"--transparent",
"--app-id", "org.souveraine.face"]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
return;
}
// Anything the page wants to say goes through the one transport.
if (msg.event === "said" && msg.text)
Souveraine.send(msg.text);
else if (msg.event === "tapped")
root.tapped(msg.area ?? "body");
}
}
onExited: root.joined = false
}
signal tapped(string area)
Socket {
id: sock
path: root.socketPath
connected: root.joined
}
function _send(msg) {
if (sock.connected)
sock.write(JSON.stringify(msg) + "\n");
}
function _eval(script) {
root._send({ op: "eval", script: script });
}
// Everything she says on the sidebar's stream reaches the bubble. The face
// is a second *view* of one turn, never a second turn.
Connections {
target: Souveraine
enabled: root.joined
function onStreamEvent(event) {
if (event.message_type === "assistant_message" && event.content) {
root._line += event.content;
// Posture tags are hers to emit and the bubble's to not show.
const tag = /\[\[face:([a-z]+)\]\]/g;
let m;
while ((m = tag.exec(root._line)) !== null)
root._eval(`window.face.posture(${JSON.stringify(m[1])})`);
const shown = root._line.replace(tag, "").trim();
root._eval(`window.face.say(${JSON.stringify(shown)})`);
}
}
function onTurnActiveChanged() {
if (Souveraine.turnActive)
root._line = "";
}
}
}

View file

@ -336,6 +336,13 @@ Singleton {
if (root._cursorPos.length > 0) {
lines.push(`cursor: ${root._cursorPos}`);
}
// Joining the face loads its skill; leaving unloads it. Attached here
// because ambient is already the per-send block the surface owns, so
// the cost of not being joined is exactly zero tokens rather than a
// flag the server has to remember to check.
if (typeof Face !== "undefined" && Face.joined) {
lines.push(Face.skill);
}
return lines.join("\n");
}

View file

@ -52,6 +52,7 @@ singleton SessionWarnings 1.0 SessionWarnings.qml
singleton SessiondBridge 1.0 SessiondBridge.qml
singleton SessiondPolicy 1.0 SessiondPolicy.qml
singleton SongRec 1.0 SongRec.qml
singleton Face 1.0 Face.qml
singleton Souveraine 1.0 Souveraine.qml
singleton Speech 1.0 Speech.qml
singleton StepUpAuth 1.0 StepUpAuth.qml