wry host: sites as apps, the rig as a face; join loads the expression skill
This commit is contained in:
parent
d7965a57fd
commit
9ec031c8a8
12 changed files with 730 additions and 11 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -74,3 +74,4 @@ vanguard-souveraine-notes.md
|
|||
/journal/
|
||||
/scripts/
|
||||
/website/
|
||||
assets/face/live2d/
|
||||
|
|
|
|||
166
Cargo.lock
generated
166
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -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
80
assets/face/index.html
Normal 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>
|
||||
|
|
@ -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
299
src/bin/souveraine-web.rs
Normal 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) {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
140
surfaces/quickshell/services/Face.qml
Normal file
140
surfaces/quickshell/services/Face.qml
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue