Watch
1
0
Fork
You've already forked souveraine
0

rescue: preserve unversioned source copy found on archdev

/home/casey/souveraine on the build box is a full copy of this repo with no
.git at all, newest sources 2026-08-09 21:07. Audited by hashing every file
and querying this repo's object database: 1545 checked, ten unknown after
excluding the vendored upower submodule.

Eight carry real deltas; roughly fifty substantive lines have no upstream
equivalent. They are the follow-up to b2774c8 that was never committed: a
logind capability query before firing a power verb, and a failure-path release
of the in-flight power latch without which a refused suspend deafens the verb
until the next boot.

Preserved verbatim, not merged. The tree has moved 100+ commits and the
sessiond power seam wants a human.
This commit is contained in:
Fimeg 2026-08-11 11:06:11 -04:00
commit c367a4dbd9
11 changed files with 11672 additions and 0 deletions

View file

@ -0,0 +1,84 @@
# Rescue: unversioned source copy on archdev
Found 2026-08-11 while auditing archdev for uncommitted work.
## What it is
`/home/casey/souveraine` on archdev (10.10.20.123) is a **full copy of this
repo with no `.git` directory at all**. 236 Rust files, a live `souveraine.toml`
and `souveraine.log`, newest source files timestamped **2026-08-09 21:07**.
It is not a worktree and not a clone. Nothing tracks it. Anything written there
was one `rm -rf` away from being gone, with no reflog and no remote.
Not to be confused with `/home/casey/Projects/souveraine` on the same box,
which *is* a git repo (branch `primary`, HEAD `d2f51e9`, 107 commits behind).
That tree's 499-line uncommitted sessiond diff was checked line by line and is
**already upstream** — 356 of 359 substantive added lines present at HEAD, the
3 misses being later refinements. No loss there.
## How it was audited
Every file under `src/ surfaces/ packaging/ tools/ docs/ scripts/` in the copy
was hashed with `git hash-object` and tested against this repo's object
database with `git cat-file -e`. Content that has ever been committed —
on any branch, at any point — is known to the object db. What isn't known has
never been committed anywhere.
1545 files checked. 175 unknown, of which 165 belong to the vendored
`packaging/upower-souveraine` submodule (separate object db — expected).
**Ten files remained.** Two are identical to HEAD (untracked paths). The rest
are below.
This method generalises: to prove a stray tree holds nothing new, hash its
files and ask the object db. Timestamps and `diff` cannot tell "never
committed" from "committed then superseded". Blob identity can.
## What is actually unique
The copy sits just after `b2774c8` (*sessiond: a power seam that cannot report
an outcome it does not have*, Aug 9 19:31) and carries follow-up work done at
21:07 that was never committed. That commit's own message says "Session.qml
keeps the legacy executor until this is exercised on hardware" — this is the
next step, orphaned.
Roughly 50 substantive lines with no upstream equivalent:
- `device_state.rs``power_request_failed()`. Releases the in-flight power
latch **only on the failure path**, because success is the machine going
down. Without it a refused suspend (lid policy, a blocking inhibitor) leaves
the latch set and deafens the verb for the rest of the uptime.
- `protocol.rs``logind_capability()` plumbing for the capability query.
- `server.rs``logind_can()`: ask logind live (`CanPowerOff` etc.) before
firing a power verb, and refuse honestly instead of firing one the machine
cannot honour. Deliberately not cached: hibernate depends on swap that can
change after boot, and a cached answer would be exactly the shadow copy
doctrine §4 forbids. Carries a parse fix — an earlier version lived in `sh`
behind two escaping layers, matched a literal `\"` busctl never emits, and
left every capability reading "unknown" on the phone.
- `HidController.qml` — 8 lines, `_notReadyReason()` threading.
Superseded, do not resurrect: `Action::Dim => brightnessctl set 10`. That is
the absolute-value form `eb0292c` replaced with a percentage twelve minutes
after this copy was made. `Face.qml`'s `joinHands` landed upstream already.
## Contents here
- `files/` — the ten files verbatim as found on archdev.
- `vs-HEAD-517d8c1.diff` — their delta against this repo at rescue time.
**Not merged.** The tree has moved 100+ commits since; applying this needs a
human who knows the sessiond power seam. Preserved so the decision can be made
later instead of never.
## Follow-ups
- Decide whether the logind capability gate lands. If yes it wants rebasing by
hand, not `git apply`.
- Delete or convert `/home/casey/souveraine` once the decision is made — an
unversioned copy of the repo on a build box is a standing hazard.
- `/home/casey/Projects/souveraine` on archdev is 107 commits behind and holds
`.git.broken-2026-07-29/`. Worth reconciling.
- Paths differ per machine: the souveraine remote is `gitea` on the laptop and
`origin` on archdev. Check `git remote -v` before blaming the network.

View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Install Souveraine as the desktop system: binary, systemd user unit,
# quickshell surface. Idempotent — safe to re-run for upgrades.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "== build =="
cargo build --release --manifest-path "$REPO/Cargo.toml"
echo "== binary =="
install -Dm755 "$REPO/target/release/souveraine" "$HOME/.local/bin/souveraine"
echo "== systemd user unit =="
install -Dm644 "$REPO/packaging/souveraine.service" \
"$HOME/.config/systemd/user/souveraine.service"
if command -v systemctl >/dev/null; then
systemctl --user daemon-reload
systemctl --user enable souveraine.service
systemctl --user restart souveraine.service || true
fi
echo "== quickshell surface =="
if [[ -d "$HOME/.config/quickshell/ii" ]]; then
"$REPO/surfaces/quickshell/deploy.sh"
else
echo " (no illogical-impulse config found — skipping surface deploy)"
fi
echo "== done =="
echo "server: systemctl --user status souveraine"

View file

@ -0,0 +1,586 @@
//! 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::{self, BufRead, BufReader, Write};
use std::path::PathBuf;
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;
/// 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,
/// Where on this window touches actually land, in CSS pixels.
///
/// A transparent window is still a rectangle to the compositor, and hers
/// covers most of the home screen — so every tap meant for a widget
/// underneath was being swallowed by empty glass she happens to occupy.
/// `wl_surface.set_input_region` is the answer the protocol already has,
/// and GTK reaches it through an input shape.
///
/// The *policy* — a rectangle over her body now, her true silhouette
/// later — belongs to the page, which is the only thing that knows where
/// she is drawn. This carries whatever it decides.
Shape(Vec<(i32, i32, i32, i32)>),
}
fn main() -> Result<()> {
run()
}
fn run() -> 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 title = String::from("Souveraine");
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;
}
}
"--title" => {
if let Some(v) = args.next() {
title = v;
}
}
"--transparent" => transparent = true,
"--size" => {
if let Some(v) = args.next() {
if 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] [--title T]\n\
souveraine-web --rig <DIR> --ipc <SOCK> --transparent [--title T]"
);
return Ok(());
}
other => anyhow::bail!("unknown argument {other}"),
}
}
if url.is_none() && rig.is_none() {
anyhow::bail!("one of --url or --rig is required");
}
// Before the event loop, because building it initialises GTK and GTK reads
// the program name when it creates the surface — not when the window
// handle is returned. Called after `build()` (where it used to be) the
// compositor had already been told `souveraine-web`, so `--app-id` was a
// flag that parsed, stored and did nothing. Measured 2026-08-06: the
// compositor reported `souveraine-web`, which is why the only thing the
// laptop's window rules could match on was the title.
set_app_id(&app_id);
let event_loop = EventLoopBuilder::<FromShell>::with_user_event().build();
let shape_proxy = event_loop.create_proxy();
// The X11 half, and it can only run here: `gdk::set_program_class` panics
// if GDK is not initialised yet, and building the loop is what initialises
// it. Wayland never reads this — `g_get_prgname()` above is what becomes
// the app_id, and this is its fallback — so the ordering costs nothing.
set_program_class(&app_id);
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(&title)
.with_transparent(transparent)
.with_decorations(!transparent)
.with_inner_size(tao::dpi::LogicalSize::new(width, height))
.build(&event_loop)
.context("creating the window")?;
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 body = req.body();
// The shape is ours to apply, not the shell's to route. It names
// window geometry, which the shell has no opinion about and could
// only hand straight back — and a round trip through the shell
// would make the input region depend on the shell being up, which
// is exactly when a face that eats every touch is worst.
let shape = serde_json::from_str::<serde_json::Value>(body)
.ok()
.filter(|m| m.get("event").and_then(|v| v.as_str()) == Some("shape"));
if let Some(msg) = shape {
let rects: Vec<(i32, i32, i32, i32)> = msg
.get("rects")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|r| {
let r = r.as_array()?;
Some((
r.first()?.as_f64()? as i32,
r.get(1)?.as_f64()? as i32,
r.get(2)?.as_f64()? as i32,
r.get(3)?.as_f64()? as i32,
))
})
.collect()
})
.unwrap_or_default();
let _ = shape_proxy.send_event(FromShell::Shape(rects));
return;
}
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{body}");
let _ = out.flush();
});
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_initialization_script(INIT_SCRIPT)
.with_url(format!("http://127.0.0.1:{port}/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::Shape(rects)) => {
apply_input_shape(&window, &rects);
}
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>) {
// The socket's parent directory is not guaranteed to exist: on the phone
// sessiond or the compositor creates /run/user/NAME/souveraine/, but a
// laptop with neither still needs the face reachable. Create it rather
// than depend on whoever else got there first.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// A crashed host leaves its socket file behind, and a socket file that is
// observed on blueline 2026-08-06: the face died, the shell's next join
// died on "Address already in use", and the only fix was `rm`. Bind is the
// moment to learn whether anything is actually listening: an error here
// means the file is a corpse, so unlink it and take the bind once more.
let listener = match std::os::unix::net::UnixListener::bind(path) {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
eprintln!("[face] {} is stale; removing and retrying", path.display());
let _ = std::fs::remove_file(path);
match std::os::unix::net::UnixListener::bind(path) {
Ok(l) => l,
Err(e) => {
eprintln!("[face] cannot bind {}: {e}", path.display());
return;
}
}
}
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 {
if proxy.send_event(event).is_err() {
return;
}
}
}
}
}
/// 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
/// `../../etc/passwd` is no.
fn serve_asset(root: &std::path::Path, path: &str) -> Response<std::borrow::Cow<'static, [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 not_found();
}
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(std::borrow::Cow::Owned(bytes))
.unwrap_or_else(|_| not_found()),
Err(_) => not_found(),
}
}
fn not_found() -> Response<std::borrow::Cow<'static, [u8]>> {
Response::builder()
.status(404)
.body(std::borrow::Cow::Borrowed(&[][..]))
.expect("a 404 with an empty body is always well formed")
}
/// 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) {}
// Everything the page says, out on the same line protocol.
//
// Without this a rig that fails to draw is silent in every direction: the
// canvas is there, WebGL reports fine, no exception reaches Rust, and the
// only symptom is a transparent window. That cost this session an afternoon
// of probing a live page over the IPC to ask it questions one at a time,
// and DUMP-bugs-2026-08-06 §3 had already written down that the next step
// when the avatar renders nothing is to capture the host's output.
//
// Runs in the initialisation script, so it is installed before any page
// script — a message logged while the runtime is loading is exactly the one
// worth having. The shell ignores what it does not recognise.
try {
['log', 'warn', 'error'].forEach(function (level) {
var original = console[level].bind(console);
console[level] = function () {
original.apply(null, arguments);
try {
window.ipc.postMessage(JSON.stringify({
event: 'console',
level: level,
text: Array.prototype.map.call(arguments, String).join(' ')
}));
} catch (e) {}
};
});
window.addEventListener('error', function (e) {
try {
window.ipc.postMessage(JSON.stringify({
event: 'console',
level: 'error',
text: e.message + ' @ ' + (e.filename || '?') + ':' + (e.lineno || 0)
}));
} catch (_) {}
});
window.addEventListener('unhandledrejection', function (e) {
try {
window.ipc.postMessage(JSON.stringify({
event: 'console', level: 'error', text: 'unhandled rejection: ' + e.reason
}));
} catch (_) {}
});
} 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 }));
},
// 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
// an invisible object is not a route, it is a secret.
dismiss: function () {
window.ipc.postMessage(JSON.stringify({ event: 'dismiss' }));
},
// Where this window should take touches, in CSS pixels. Everything the
// page does not name here falls through to whatever is behind her.
shape: function (rects) {
window.ipc.postMessage(JSON.stringify({ event: 'shape', rects: rects }));
}
};
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(app_id: &str) {
// On Wayland the app_id is GTK's program name, not a window property —
// there is nothing on the handle to set, so this cannot wait for one to
// exist. GTK reads the name while creating the surface, which happens
// inside the window build, so the only place this works is before the
// event loop is built at all. Pure glib, so it needs nothing initialised.
gtk::glib::set_prgname(Some(app_id));
}
#[cfg(not(target_os = "linux"))]
fn set_app_id(_app_id: &str) {}
#[cfg(target_os = "linux")]
fn set_program_class(app_id: &str) {
gtk::gdk::set_program_class(app_id);
}
/// Restrict where this window takes touches to the rectangles the page named.
///
/// The whole point of a transparent face is that the home screen is still
/// there — and it was not, because a surface with no input region takes every
/// contact inside its bounds whether it drew anything there or not. She is
/// 540px wide on a 540px panel, so that was the entire width of the screen.
///
/// An empty list is "all of it", not "none of it": a page that has not worked
/// out its shape yet, or one whose report was malformed, must not end up
/// untouchable. Losing taps to her is a nuisance; a face that cannot be
/// dismissed because nothing can reach it is a device you have to restart.
#[cfg(target_os = "linux")]
fn apply_input_shape(window: &tao::window::Window, rects: &[(i32, i32, i32, i32)]) {
use gtk::prelude::WidgetExt;
use tao::platform::unix::WindowExtUnix;
let gtk_window = window.gtk_window();
if rects.is_empty() {
gtk_window.input_shape_combine_region(None);
return;
}
let region = gtk::cairo::Region::create();
for (x, y, w, h) in rects {
if *w <= 0 || *h <= 0 {
continue;
}
if region
.union_rectangle(&gtk::cairo::RectangleInt::new(*x, *y, *w, *h))
.is_err()
{
// A region that failed to build is not a region to install —
// a partial one would silently make part of her untouchable.
return;
}
}
gtk_window.input_shape_combine_region(Some(&region));
}
#[cfg(not(target_os = "linux"))]
fn apply_input_shape(_window: &tao::window::Window, _rects: &[(i32, i32, i32, i32)]) {}
#[cfg(not(target_os = "linux"))]
fn set_program_class(_app_id: &str) {}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,780 @@
//! Wire protocol for souveraine-sessiond — the session authority daemon.
//!
//! One JSON object per line over a Unix socket in the user's runtime dir,
//! guarded `ok`/`reason` responses, same shape as machined's protocol. The
//! socket lives on the user tier (unlike machined's system-tier socket): the
//! session authority serves exactly one seat and dies with it.
//!
//! The handoff contract with the shell:
//!
//! 1. sessiond starts before the shell and takes ext-session-lock — the
//! session is locked before any shell surface can exist.
//! 2. The shell connects, sends `shell_ready`, and KEEPS the connection
//! open. That connection is the heartbeat; EOF means the shell died.
//! 3. sessiond releases by dropping its Wayland connection WITHOUT
//! unlocking. The compositor keeps the session locked (abandoned-client
//! state) and `misc:allow_session_lock_restore` lets the shell's own
//! WlSessionLock take over. There is never an unlocked instant.
//! 4. The shell sends `locked_ack` once its lock surface is secure. If the
//! ack does not arrive in time, sessiond takes the lock back.
//! 5. Heartbeat EOF at any point → sessiond retakes the lock immediately,
//! whether or not the session was locked at the time. Fail closed.
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
/// Socket path relative to `$XDG_RUNTIME_DIR`.
pub const SOCKET_RELPATH: &str = "souveraine/sessiond.sock";
/// Upper bound on one request line.
pub const MAX_REQUEST_BYTES: u64 = 16 * 1024;
/// PAM service for the fallback unlock surface. The shell's lock uses
/// quickshell's default (`login`); the dedicated file lets the PIN stack be
/// audited separately and is shipped as root-owned system config by the OS
/// overlay, never by this crate (same stance as `souveraine-stepup`).
/// How long after releasing the lock we wait for the shell's `locked_ack`
/// before deciding the shell is broken and taking the lock back.
pub const LOCKED_ACK_TIMEOUT_SECS: u64 = 15;
/// Why a request was refused, in a form a caller can branch on.
///
/// Refusals were prose. A human reads "a live shell owns the session lock" and
/// knows what to do; an agent composing several verbs into one intent
/// (doctrine §13) cannot branch on a sentence. The reason stays — it is the
/// only thing that explains *this* refusal rather than its class — but the
/// code is what a chain reads.
///
/// The taxonomy is deliberately small. RedFlag's executor exit codes are the
/// precedent (`NET_LAYER_PLAN.md` cites them for the same reason): a fixed
/// vocabulary a caller can exhaust, not a growing list it must keep up with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RefusalCode {
/// The op does not exist, or the line did not parse as a request.
/// Retrying is pointless; the caller is speaking a vocabulary this daemon
/// does not have. `describe` is the cure.
UnsupportedOp,
/// The op exists and the arguments are wrong — out of range, mismatched,
/// or self-contradictory. Retrying with the same arguments is pointless.
InvalidArgument,
/// The op and arguments are fine and the machine's current state forbids
/// it. This is the one a chain most often wants: it means *not now*, and
/// the state that forbade it is worth reading before deciding what next.
RefusedByState,
/// The caller may not do this. Reserved — nothing issues it yet, because
/// caller identity is uid 1000 for everything (audit P3). It exists so
/// that when capability tokens land, callers already branch on it.
NotPermitted,
/// A dependency this op needs is absent — no compositor, no PAM stack, no
/// sensor. Not the caller's fault and possibly transient.
Unavailable,
}
impl RefusalCode {
pub fn as_str(self) -> &'static str {
match self {
RefusalCode::UnsupportedOp => "unsupported_op",
RefusalCode::InvalidArgument => "invalid_argument",
RefusalCode::RefusedByState => "refused_by_state",
RefusalCode::NotPermitted => "not_permitted",
RefusalCode::Unavailable => "unavailable",
}
}
}
/// One entry in the verb table `describe` returns.
///
/// Serialize-only: it is a const table on this side of the wire and JSON on the
/// other, so the borrowed `&'static` fields never need to come back.
#[derive(Debug, Clone, Serialize)]
pub struct VerbDoc {
/// The `op` string, exactly as it goes on the wire.
pub op: &'static str,
/// True when the op changes something. A caller planning a chain needs to
/// know which steps are reversible reads and which are not.
pub mutates: bool,
/// What it does, in one line.
pub summary: &'static str,
/// The refusal codes this op can return. A chain plans its branches from
/// this rather than discovering them by being refused.
pub refuses: &'static [RefusalCode],
/// A minimal request line that parses. Knowing an op exists is not enough
/// to call it — `panel` needs `on`, `sensor_input` needs a source and a
/// matching value — and a caller should not have to discover required
/// fields by being refused. The test round-trips every one of these, so an
/// example that stops parsing fails the build rather than the agent.
pub example: &'static str,
}
/// The verb table. Every `Request` variant appears here; a test enforces it.
///
/// This exists because the surface was not enumerable. An agent that owns the
/// device (doctrine §13) has to be able to ask what it may do rather than be
/// told, and a verb that is missing from this table is as unreachable as one
/// that was never written.
pub const VERBS: &[VerbDoc] = &[
VerbDoc {
op: "describe",
mutates: false,
summary: "this table — the verbs, which ones mutate, how each can refuse",
refuses: &[],
example: r#"{"op":"describe"}"#,
},
VerbDoc {
op: "status",
mutates: false,
summary: "liveness and who currently owns the session lock",
refuses: &[],
example: r#"{"op":"status"}"#,
},
VerbDoc {
op: "shell_ready",
mutates: true,
summary: "the shell announces itself; this connection becomes the heartbeat",
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"shell_ready"}"#,
},
VerbDoc {
op: "locked_ack",
mutates: true,
summary: "the shell's lock surface reached compositor-acknowledged secure",
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"locked_ack"}"#,
},
VerbDoc {
op: "lock",
mutates: true,
summary: "sessiond takes the session lock itself; refused while a live shell owns it",
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
example: r#"{"op":"lock"}"#,
},
VerbDoc {
op: "device_state",
mutates: false,
summary: "the unified state machine: state, panel, evidence, confidence, health",
refuses: &[],
example: r#"{"op":"device_state"}"#,
},
VerbDoc {
op: "sensor_input",
mutates: true,
summary: "report a sensor reading as evidence; never an authority (doctrine §9)",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"sensor_input","source":"proximity","value":{"near":true}}"#,
},
VerbDoc {
op: "input",
mutates: true,
summary: "real user input happened; resets the idle budget",
// A squeeze is refusable — a pocket can produce one, and proximity is
// the veto (DEVICE-STATE-MACHINE §4). A power button never is.
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"input","trigger":"touch"}"#,
},
VerbDoc {
op: "button",
mutates: true,
summary: "a hardware button edge; the machine recognises taps and holds from these",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"button","button":"power","edge":"down"}"#,
},
VerbDoc {
op: "gesture",
mutates: true,
summary: "a recognised touch gesture; the machine decides what it means",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"gesture","fingers":3,"gesture":"tap","target":1}"#,
},
VerbDoc {
op: "panel",
mutates: true,
summary: "the DPMS executor reports the panel's real power state",
refuses: &[],
example: r#"{"op":"panel","on":false}"#,
},
VerbDoc {
op: "screen",
mutates: true,
summary: "turn the panel on or off; a blank still locks the session first",
// Refusable because turning it OFF routes through the same
// lock-before-blank path everything else does — the agent owns
// operation (§13) and still cannot blank an unlocked session, because
// that is an ordering invariant rather than a permission.
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"screen","on":true}"#,
},
VerbDoc {
op: "forensic_log",
mutates: false,
summary: "recent trail entries from the in-memory buffer",
refuses: &[],
example: r#"{"op":"forensic_log","count":50}"#,
},
VerbDoc {
op: "subscribe",
mutates: false,
summary: "this connection becomes an event stream of notable belief changes",
refuses: &[],
example: r#"{"op":"subscribe"}"#,
},
VerbDoc {
op: "get_policy",
mutates: false,
summary: "read the timed policy — the Auto-Lock shaped settings",
refuses: &[],
example: r#"{"op":"get_policy"}"#,
},
VerbDoc {
op: "set_policy",
mutates: true,
summary: "change the timed policy; persisted, so a setting survives restart",
refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
},
VerbDoc {
op: "bearer",
mutates: false,
summary: "which link carries traffic, and whether the tunnel is being answered",
refuses: &[],
example: r#"{"op":"bearer"}"#,
},
VerbDoc {
op: "usb",
mutates: false,
summary: "USB-C role, gadget mode, charger evidence, attachment identity and probe owner",
refuses: &[RefusalCode::Unavailable],
example: r#"{"op":"usb"}"#,
},
VerbDoc {
op: "set_usb_mode",
mutates: true,
summary: "ask the device-state authority to change the USB gadget posture",
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
example: r#"{"op":"set_usb_mode","mode":"hid"}"#,
},
VerbDoc {
op: "power",
mutates: true,
summary: "power the machine off, restart it, or put it to sleep",
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
example: r#"{"op":"power","verb":"poweroff"}"#,
},
];
/// A request plus the caller's declared intent.
///
/// The wire shape is unchanged — `{"op":"panel","on":false}` still parses,
/// because `intent` is optional and flattened alongside the tagged enum. A
/// caller composing several verbs into one decision (doctrine §13) sets it
/// once per verb and the trail joins the leaves back to the intent that
/// produced them: `{"op":"panel","on":false,"intent":"quiet the room"}`.
///
/// Declared, never verified. It is a label the caller supplies about itself,
/// so it is evidence in exactly the sense doctrine §9 means — useful for
/// reconstruction, never a basis for a decision. Nothing branches on it.
#[derive(Debug, Clone, Deserialize)]
pub struct Envelope {
#[serde(flatten)]
pub request: Request,
#[serde(default)]
pub intent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
/// The verb table — what this daemon can be asked, which ops mutate, and
/// how each can refuse. A caller composing verbs reads this first.
Describe,
/// Liveness + who currently owns the session lock.
Status,
/// The shell announces itself. The connection carrying this request
/// becomes the heartbeat. If sessiond is holding the lock, it releases
/// (see module docs) and the shell must lock and then `locked_ack`.
ShellReady,
/// The shell's lock surface reached compositor-acknowledged `secure`.
LockedAck,
/// Ask sessiond to take the session lock itself. Refused while a live
/// shell owns steady state — the shell's lock IPC is the front door.
Lock,
/// Full device state: the unified state machine's current state,
/// sensor evidence confidence, and doze tier. Superset of Status.
DeviceState,
/// Report a sensor reading to the device state machine.
/// The shell or a sensor daemon feeds proximity/accel/touch evidence.
SensorInput(SensorInput),
/// Real user input happened — touch, key, power button, dt2w wake.
/// Resets the idle budget that the lock-blank rule counts against.
/// This is the wire that lets the machine tell "the user is looking at
/// the lock screen" from "the lock screen has been lit for ten minutes."
Input { trigger: Option<InputTrigger> },
/// A hardware button went down or came up. Edges only — the caller reports
/// what the hardware did and nothing else.
///
/// `blueline-power-button` used to *be* the policy: it read a state file,
/// asked the shell to lock over `qs ipc`, polled `session state` twenty
/// times at 100 ms grepping for `"locked": true`, then called
/// `blueline-screen-toggle off` itself. That is a path to a dark panel that
/// never routes through `request_blank()` — so LOCK-DPMS-LESSONS §1's
/// "every path routes through it, an invariant not a coincidence" had a hole
/// in it, and the hole was the most-used control on the device. It also made
/// the button the eighth blind actor of DEVICE-STATE-MACHINE §1, with its
/// own copy of the lock-then-blank ordering and its own panel truth.
///
/// Now it reports and stops deciding. Recognition — tap, double, triple,
/// hold — happens in the machine, because a gesture is an accumulation over
/// time and time is the one thing a fire-and-forget script does not have.
Button { button: Button, edge: ButtonEdge },
/// A recognised touch gesture, already named by the compositor.
///
/// Unlike [`Self::Button`], which carries raw edges because recognition is
/// an accumulation over time and the machine owns time, this arrives named:
/// touch contacts exist only inside the compositor, so nothing else *can*
/// recognise them. The line §12 draws still holds — the compositor names
/// what the fingers did and the machine decides what it means. A compositor
/// that both recognised and acted would be the eighth blind actor.
///
/// `target` is the window the gesture landed on, named by the compositor
/// through the same hit test a finger goes through. It travels with the
/// gesture because that mapping exists nowhere else — it accounts for the
/// zone strip and any pose — and a machine re-deriving it from a centroid
/// would eventually disagree with what the hand actually hit. Absent means
/// the gesture landed on the wallpaper, which is a real answer and a
/// different one from "no window exists".
Gesture {
fingers: u8,
gesture: TouchGesture,
#[serde(default)]
target: Option<u64>,
},
/// The DPMS executor reports the panel's real power state. The machine
/// keeps the panel as a field, not a state: "locked with the screen off"
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
/// glance. Reported, never assumed — the executor owns the panel.
Panel { on: bool },
/// Ask for the panel. The agent's verb, and the user's, and the shell's.
///
/// Distinct from [`Request::Panel`], which is the executor *reporting*
/// what the hardware did. This one *asks*, and the distinction is the
/// whole reason both exist: a report must never be able to actuate, or a
/// stale report would drive the panel; and an ask must never be able to
/// silently edit the machine's idea of the hardware.
///
/// Waking is immediate. Blanking goes through the same `request_blank()`
/// every other path takes, so it locks first and waits for the ack —
/// doctrine §13's shape exactly: operation is hers, and the one thing she
/// cannot do is make the glass dark on a session that is not locked.
Screen { on: bool },
/// Query recent forensic log entries. Returns the last N entries
/// from the in-memory forensic buffer for post-hoc analysis.
ForensicLog { count: Option<usize> },
/// Turn this connection into an event stream. After the `ok`, the daemon
/// pushes one trail entry per line, unprompted, for as long as the caller
/// holds the socket open.
///
/// The point is the *filter*, not the transport. `forensic_log` already
/// hands over everything; a consumer that polls it and diffs is doing the
/// machine's job for it, badly and late. Only NOTABLE entries are pushed —
/// state transitions, a source that died or came back, a violated
/// guarantee, sensors that contradict each other. Heartbeats, ticks and
/// ordinary readings never cross.
///
/// That compression IS the product, in the same sense six strain gauges at
/// 100 Hz becoming one `squeeze` bit is the product. An agent that received
/// every reading would be reading drivers, and doctrine's load-bearing rule
/// is that interpretation may consume evidence but never a driver — reading
/// the raw stream is how gait, typing and identity get inferred from data
/// that looks innocent per-field (SECURITY-AUDIT P1). The narrowness here is
/// a security control, not a performance one.
Subscribe,
/// Read the timed policy — the Auto-Lock shaped settings.
GetPolicy,
/// Bearer posture: per-link health, the tunnel's handshake state, and
/// which bearer the machine has settled on.
///
/// A read, deliberately. The corresponding write does not exist and should
/// not: the thing that decides which link carries traffic is `tick()`, and
/// a verb that let a caller set it would be the second writer TASK-49
/// acceptance #6 forbids. What a caller can do is read this and change the
/// *policy* (`set_policy`), which is the difference between steering the
/// machine and reaching around it.
Bearer,
/// Port proprioception. The mechanism is adjacent to souveraine-upower:
/// usb-signaller reports/acts on the data posture, while UPower reports
/// charging posture. sessiond is the one place the two become a snapshot.
Usb,
/// Ask for a gadget posture. This does not let the shell write configfs;
/// it becomes an Action and is executed through usb-signaller's system
/// D-Bus API. Full KVM is admitted because usb-signaller prepares both
/// FunctionFS responders and rolls the composite back as one transaction.
SetUsbMode { mode: UsbMode },
/// Change the timed policy. Every field is optional; omitted fields keep
/// their current value. This is the seam the Settings control center reads
/// and writes, so a control there is a view over the owning daemon rather
/// than a switch that only looks like it did something (TASK-19's rule).
SetPolicy {
/// Seconds; `Some(0)` means never blank.
lock_blank_after_secs: Option<u64>,
lock_blank_after_held_secs: Option<u64>,
dim_grace_secs: Option<u64>,
evidence_ttl_secs: Option<u64>,
dim_warning: Option<bool>,
/// Seconds a pending blank waits for its lock ack before going dark
/// anyway. Never 0 — a blank that waits for nothing is the ordering
/// bug this field exists to close.
lock_ack_budget_secs: Option<u64>,
/// Seconds; `Some(0)` means never blank an unlocked session here.
unlocked_blank_after_secs: Option<u64>,
/// The SSIDs that are the home LAN. Replaces the list wholesale;
/// `Some([])` means "never claim to be home", which is the safe
/// default rather than an erasure.
///
/// Identity, never a prefix. The gate this replaces tested
/// `inet 10.10.` and read a foreign `10.10.30.0/24` as home.
home_ssids: Option<Vec<String>>,
/// Seconds a changed bearer preference must hold before it is acted
/// on. Never 0: a window of zero is the event-speed controller that
/// recycled the tunnel 652 times.
bearer_settle_secs: Option<u64>,
},
/// End the session's power state: off, restart, or asleep.
///
/// The last device verb that was not here. Both menu surfaces called a QML
/// singleton that ran `systemctl poweroff` itself, which is §12's eighth
/// blind actor on the one transition that cannot be undone or observed
/// after the fact — no Action, no executor, no trail entry.
///
/// logind keeps what it already owns. It answers `CanPowerOff` and it
/// carries out the verb; doctrine §4 says suspend goes through logind and
/// never `/sys/power/state`, and that is unchanged. What moves is the
/// decision to ask, so the machine that holds `Suspending` and `Asleep` is
/// the thing that says when to enter them.
Power { verb: PowerVerb },
}
/// The power transitions sessiond will carry out.
///
/// `logout` is deliberately absent: it ends a *session*, not a device power
/// state, and the machine has no cell for it. It stays the shell's, which is
/// also the only actor that knows what it would be tearing down.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PowerVerb {
Poweroff,
Reboot,
Suspend,
Hibernate,
}
impl PowerVerb {
/// systemctl owns these verbs, not loginctl — `loginctl poweroff` exits 1
/// with "Unknown command verb". Preferring loginctl silently broke every
/// power button on the device for a day (2026-07-20).
pub fn as_systemctl(self) -> &'static str {
match self {
Self::Poweroff => "poweroff",
Self::Reboot => "reboot",
Self::Suspend => "suspend",
Self::Hibernate => "hibernate",
}
}
pub fn as_str(self) -> &'static str {
self.as_systemctl()
}
/// The logind capability that gates this verb.
pub fn logind_capability(self) -> &'static str {
match self {
Self::Poweroff => "CanPowerOff",
Self::Reboot => "CanReboot",
Self::Suspend => "CanSuspend",
Self::Hibernate => "CanHibernate",
}
}
}
/// USB postures sessiond is prepared to own today.
///
/// Keep this narrower than usb-signaller's raw vocabulary. MTP and tethering
/// are not power-menu promises; host mode is unsafe until the SMB2/TCPM lane
/// can source VBUS. Full KVM is here because GUD and smoo now share one
/// readiness-gated, rollback-capable lifecycle in usb-signaller.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsbMode {
Developer,
Hid,
Kvm,
ChargingOnly,
}
impl UsbMode {
pub fn as_usb_moded(self) -> &'static str {
match self {
Self::Developer => "developer_mode",
Self::Hid => "hid_mode",
Self::Kvm => "kvm_mode",
Self::ChargingOnly => "charging_only",
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Developer => "developer",
Self::Hid => "hid",
Self::Kvm => "kvm",
Self::ChargingOnly => "charging_only",
}
}
}
/// What produced a user input. Recorded so the forensic trail can tell a
/// deliberate power-button press from an accidental pocket touch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputTrigger {
Touch,
Key,
PowerButton,
DoubleTapToWake,
/// Active Edge — the frame was squeezed. Deliberate intent, like a button,
/// but unlike a button it is a sensor and it can be produced by a pocket:
/// a squeezed chassis is exactly what a phone in a tight pocket is. So it
/// carries the same proximity veto as tap-to-wake (`suppress_wake`), and
/// for the same reason — see DEVICE-STATE-MACHINE §4, "a covered sensor is
/// the right veto for the one wake a pocket can produce by itself".
Squeeze,
Unknown,
}
/// Which hardware button. Volume is here because the recognizer is per-button
/// and costs nothing to reuse; nothing binds them yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Button {
Power,
VolumeUp,
VolumeDown,
}
impl Button {
pub fn as_str(self) -> &'static str {
match self {
Button::Power => "power",
Button::VolumeUp => "volume_up",
Button::VolumeDown => "volume_down",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ButtonEdge {
Down,
Up,
}
/// What a set of fingers did. Named by the compositor, meant by the machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TouchGesture {
/// Down and up without travelling.
Tap,
/// Travelled and let go. The direction is what a binding usually cares
/// about; the distance is the compositor's business.
SwipeUp,
SwipeDown,
SwipeLeft,
SwipeRight,
}
impl TouchGesture {
pub fn as_str(self) -> &'static str {
match self {
TouchGesture::Tap => "tap",
TouchGesture::SwipeUp => "swipe_up",
TouchGesture::SwipeDown => "swipe_down",
TouchGesture::SwipeLeft => "swipe_left",
TouchGesture::SwipeRight => "swipe_right",
}
}
}
/// What the machine made of a run of edges.
///
/// `Hold` fires while the button is still down — a hold you only learn about on
/// release is a hold that cannot light anything up while you are waiting, and
/// waiting with no feedback is how a user decides the device is broken and lets
/// go. Taps resolve on the multi-tap window expiring, which is the opposite
/// trade and the right one: a double-tap must not first fire a single.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ButtonGesture {
Tap,
DoubleTap,
TripleTap,
/// Held past the first threshold, still down.
Hold,
/// Held past the second, still down. The "you may let go now" point for
/// anything destructive — nothing binds it yet.
LongHold,
}
impl ButtonGesture {
pub fn as_str(self) -> &'static str {
match self {
ButtonGesture::Tap => "tap",
ButtonGesture::DoubleTap => "double_tap",
ButtonGesture::TripleTap => "triple_tap",
ButtonGesture::Hold => "hold",
ButtonGesture::LongHold => "long_hold",
}
}
}
/// A sensor reading fed to the device state machine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorInput {
/// Which sensor produced this reading.
pub source: SensorSource,
/// The reading value.
pub value: SensorValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorSource {
Proximity,
Accelerometer,
Light,
Touch,
}
impl SensorSource {
/// The name this source is known by in the forensic trail and in logs.
/// Matches the `evidence_fresh` / `sensor_health` keys so a reader can
/// join them without a translation table.
pub fn as_str(self) -> &'static str {
match self {
SensorSource::Proximity => "proximity",
SensorSource::Accelerometer => "accel",
SensorSource::Light => "light",
SensorSource::Touch => "touch",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorValue {
/// Proximity: near or far.
Near(bool),
/// Accelerometer: moving or stationary.
Moving(bool),
/// Light: changing or stable.
Changing(bool),
/// Touch: active or inactive.
Active(bool),
}
/// What sessiond currently is, as reported by `status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
/// sessiond holds ext-session-lock and renders the fallback surface.
Holding,
/// Lock released to the shell; heartbeat live; ack not yet seen.
AwaitingShellLock,
/// Shell owns steady state (heartbeat live, ack seen).
Released,
/// No shell heartbeat and not holding (initial-lock disabled or the
/// user unlocked at the fallback surface with no shell to hand off to).
Idle,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips() {
let req: Request = serde_json::from_str(r#"{"op":"shell_ready"}"#).unwrap();
assert!(matches!(req, Request::ShellReady));
let req: Request = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
assert!(matches!(req, Request::Status));
}
#[test]
fn every_request_variant_is_in_the_verb_table() {
// The table is the vocabulary an agent composes over (doctrine §13).
// A verb missing from it is as unreachable as one never written, so
// this test is the thing that keeps them from drifting apart.
//
// Checked by round-tripping each op string through the deserializer:
// if `describe` advertises an op the daemon cannot parse, the entry is
// a lie, and if a variant is added without a table entry the count
// below fails.
for v in VERBS {
let parsed: Result<Request, _> = serde_json::from_str(v.example);
assert!(
parsed.is_ok(),
"describe advertises `{}` with example `{}`, which the daemon cannot parse: {:?}",
v.op,
v.example,
parsed.err()
);
}
// And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc.
// 19 since USB posture became a read plus a request (2026-08-07).
assert_eq!(
VERBS.len(),
19,
"a Request variant was added without a VerbDoc"
);
}
#[test]
fn the_envelope_is_backward_compatible() {
// Every line written before intent existed must still parse, or the
// shell and the reporters stop talking to the daemon on upgrade —
// TASK-28's failure mode, caused by the fix for it.
let e: Envelope = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
assert!(matches!(e.request, Request::Status));
assert_eq!(e.intent, None);
let e: Envelope =
serde_json::from_str(r#"{"op":"panel","on":false,"intent":"quiet the room"}"#).unwrap();
assert!(matches!(e.request, Request::Panel { on: false }));
assert_eq!(e.intent.as_deref(), Some("quiet the room"));
}
#[test]
fn a_refusal_code_survives_the_wire() {
// A chain branches on the code, so it has to arrive intact.
let raw = serde_json::to_string(&RefusalCode::RefusedByState).unwrap();
assert_eq!(raw, r#""refused_by_state""#);
let back: RefusalCode = serde_json::from_str(&raw).unwrap();
assert_eq!(back, RefusalCode::RefusedByState);
}
#[test]
fn phase_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&Phase::AwaitingShellLock).unwrap(),
r#""awaiting_shell_lock""#
);
}
#[test]
fn full_kvm_is_a_named_usb_posture() {
let req: Request = serde_json::from_str(r#"{"op":"set_usb_mode","mode":"kvm"}"#).unwrap();
assert!(matches!(req, Request::SetUsbMode { mode: UsbMode::Kvm }));
assert_eq!(UsbMode::Kvm.as_usb_moded(), "kvm_mode");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,521 @@
#!/usr/bin/env bash
# Compose the Souveraine quickshell config (`qs -c souveraine`).
#
# Model: ~/.config/quickshell/souveraine is BUILT by this script —
# - our files (this repo) are symlinked in, repo stays source of truth
# - untouched upstream directories are borrowed as whole-dir symlinks
# into the ii tree (so upstream updates flow through)
# - directories where we override any file are composed file-by-file
# The ii tree itself is DEPLOYED from this repo (ii-base/, the pinned base —
# vendored 2026-07-21 after laptop/phone drifted ~900 files): every run
# rsyncs ii-base -> ~/.config/quickshell/ii, so "borrowed from ii" means
# borrowed from the same pin on every device. On the phone (aarch64) the
# ii-phone/ overlay is applied on top — the declared home for phone-only
# files (Cellular, mobile bar/OSK/wallpaper behavior). Never hand-edit
# ~/.config/quickshell/ii; change ii-base/ (or ii-phone/) and redeploy.
#
# deploy.sh compose ~/.config/quickshell/souveraine
# deploy.sh -u remove the souveraine config dir (ii untouched)
# deploy.sh --legacy-clean remove the OLD overlay symlinks from ii and
# restore its .upstream backups (one-time migration)
# deploy.sh --phone copy this surface to the phone and deploy there.
# NEVER deletes on the device: it snapshots the phone's
# tree first, copies by checksum, and only *reports*
# files that exist solely on the phone.
set -euo pipefail
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
QS="${HOME}/.config/quickshell"
II="${QS}/ii"
SV="${QS}/souveraine"
TARGET_ARCH="${SOUVERAINE_TARGET_ARCH:-$(uname -m)}"
# Manifest: "<repo-relative> <target-relative-to-~/.config/quickshell>"
MANIFEST="
shell.qml souveraine/shell.qml
GlobalStates.qml souveraine/GlobalStates.qml
SettingsWindow.qml souveraine/SettingsWindow.qml
panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml
services/Souveraine.qml souveraine/services/Souveraine.qml
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
services/Gestures.qml souveraine/services/Gestures.qml
modules/souveraine/dial/RadialDial.qml souveraine/modules/souveraine/dial/RadialDial.qml
modules/souveraine/dial/DialHost.qml souveraine/modules/souveraine/dial/DialHost.qml
modules/souveraine/dial/qmldir souveraine/modules/souveraine/dial/qmldir
modules/souveraine/selection/SelectionHost.qml souveraine/modules/souveraine/selection/SelectionHost.qml
modules/souveraine/selection/SelectionChip.qml souveraine/modules/souveraine/selection/SelectionChip.qml
modules/souveraine/selection/SelectionActions.qml souveraine/modules/souveraine/selection/SelectionActions.qml
modules/souveraine/selection/SelectionAction.qml souveraine/modules/souveraine/selection/SelectionAction.qml
modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml
modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir
modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.qml
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/PowerOptionRow.qml souveraine/modules/souveraine/windowSheet/PowerOptionRow.qml
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
services/Face.qml souveraine/services/Face.qml
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
services/ZoneTransition.qml souveraine/services/ZoneTransition.qml
services/Hyprsunset.qml souveraine/services/Hyprsunset.qml
modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml
modules/souveraine/subconscious/qmldir souveraine/modules/souveraine/subconscious/qmldir
services/Network.qml souveraine/services/Network.qml
services/TaskbarApps.qml souveraine/services/TaskbarApps.qml
services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml
services/Idle.qml souveraine/services/Idle.qml
services/IdleCoordinator.qml souveraine/services/IdleCoordinator.qml
services/LockContentPolicy.qml souveraine/services/LockContentPolicy.qml
services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml
services/WallpaperDownload.qml souveraine/services/WallpaperDownload.qml
scripts/wallpaper/download_wallhaven.sh souveraine/scripts/wallpaper/download_wallhaven.sh
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
services/SessionEvents.qml souveraine/services/SessionEvents.qml
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
services/SessiondPolicy.qml souveraine/services/SessiondPolicy.qml
services/StepUpAuth.qml souveraine/services/StepUpAuth.qml
services/SessionAudit.qml souveraine/services/SessionAudit.qml
services/Speech.qml souveraine/services/Speech.qml
services/NotifyEvents.qml souveraine/services/NotifyEvents.qml
services/CrashReporter.qml souveraine/services/CrashReporter.qml
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
modules/ii/sidebarRight/SidebarRightContent.qml souveraine/modules/ii/sidebarRight/SidebarRightContent.qml
modules/ii/sidebarRight/QuickSliders.qml souveraine/modules/ii/sidebarRight/QuickSliders.qml
modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml
modules/ii/bar/UtilButtons.qml souveraine/modules/ii/bar/UtilButtons.qml
modules/common/Config.qml souveraine/modules/common/Config.qml
modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml
modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml
modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml
modules/common/widgets/FullscreenPolkitWindow.qml souveraine/modules/common/widgets/FullscreenPolkitWindow.qml
modules/common/widgets/FloatingActionButton.qml souveraine/modules/common/widgets/FloatingActionButton.qml
modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
modules/settings/NetworkConfig.qml souveraine/modules/settings/NetworkConfig.qml
modules/settings/DisplayConfig.qml souveraine/modules/settings/DisplayConfig.qml
modules/settings/SoundConfig.qml souveraine/modules/settings/SoundConfig.qml
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.qml
modules/settings/OverviewConfig.qml souveraine/modules/settings/OverviewConfig.qml
modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml
modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml
modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.qml
modules/settings/IdleConfig.qml souveraine/modules/settings/IdleConfig.qml
modules/settings/SpeechConfig.qml souveraine/modules/settings/SpeechConfig.qml
modules/ii/polkit/Polkit.qml souveraine/modules/ii/polkit/Polkit.qml
modules/ii/dock/Dock.qml souveraine/modules/ii/dock/Dock.qml
modules/ii/dock/DockManifest.qml souveraine/modules/ii/dock/DockManifest.qml
modules/ii/dock/DockApps.qml souveraine/modules/ii/dock/DockApps.qml
modules/ii/dock/DockAppButton.qml souveraine/modules/ii/dock/DockAppButton.qml
modules/ii/dock/DockButton.qml souveraine/modules/ii/dock/DockButton.qml
modules/ii/dock/DockSeparator.qml souveraine/modules/ii/dock/DockSeparator.qml
modules/ii/dock/DockStack.qml souveraine/modules/ii/dock/DockStack.qml
modules/ii/appInventory/AppInventory.qml souveraine/modules/ii/appInventory/AppInventory.qml
modules/ii/appInventory/AppInventoryScope.qml souveraine/modules/ii/appInventory/AppInventoryScope.qml
modules/ii/overview/Overview.qml souveraine/modules/ii/overview/Overview.qml
modules/ii/overview/AppGrid.qml souveraine/modules/ii/overview/AppGrid.qml
modules/ii/background/widgets/clock/CookieClock.qml souveraine/modules/ii/background/widgets/clock/CookieClock.qml
modules/ii/background/widgets/clock/CookieQuote.qml souveraine/modules/ii/background/widgets/clock/CookieQuote.qml
modules/ii/screenCorners/ScreenCorners.qml souveraine/modules/ii/screenCorners/ScreenCorners.qml
modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml
modules/common/Persistent.qml souveraine/modules/common/Persistent.qml
modules/common/panels/lock/LockScreen.qml souveraine/modules/common/panels/lock/LockScreen.qml
modules/ii/lock/Lock.qml souveraine/modules/ii/lock/Lock.qml
modules/ii/lock/TouchLockSurface.qml souveraine/modules/ii/lock/TouchLockSurface.qml
modules/souveraine/lock/LockMediaCard.qml souveraine/modules/souveraine/lock/LockMediaCard.qml
modules/souveraine/lock/LockAgentCard.qml souveraine/modules/souveraine/lock/LockAgentCard.qml
modules/souveraine/lock/LockNotifyCard.qml souveraine/modules/souveraine/lock/LockNotifyCard.qml
modules/souveraine/lock/LockSurfaceHost.qml souveraine/modules/souveraine/lock/LockSurfaceHost.qml
modules/souveraine/lock/qmldir souveraine/modules/souveraine/lock/qmldir
modules/souveraine/navigation/SystemGestureRail.qml souveraine/modules/souveraine/navigation/SystemGestureRail.qml
modules/souveraine/navigation/WindowOverview.qml souveraine/modules/souveraine/navigation/WindowOverview.qml
modules/souveraine/navigation/ZoneOverview.qml souveraine/modules/souveraine/navigation/ZoneOverview.qml
modules/souveraine/navigation/qmldir souveraine/modules/souveraine/navigation/qmldir
modules/souveraine/boot/BootBloom.qml souveraine/modules/souveraine/boot/BootBloom.qml
modules/souveraine/boot/BootBloom.frag.qsb souveraine/modules/souveraine/boot/BootBloom.frag.qsb
modules/souveraine/boot/souvie.png souveraine/modules/souveraine/boot/souvie.png
modules/souveraine/boot/qmldir souveraine/modules/souveraine/boot/qmldir
modules/ii/sessionScreen/SessionScreen.qml souveraine/modules/ii/sessionScreen/SessionScreen.qml
qmldir souveraine/qmldir
panelFamilies/qmldir souveraine/panelFamilies/qmldir
services/qmldir souveraine/services/qmldir
modules/common/qmldir souveraine/modules/common/qmldir
modules/common/functions/qmldir souveraine/modules/common/functions/qmldir
modules/common/panels/lock/qmldir souveraine/modules/common/panels/lock/qmldir
modules/common/widgets/qmldir souveraine/modules/common/widgets/qmldir
modules/ii/appInventory/qmldir souveraine/modules/ii/appInventory/qmldir
modules/ii/bar/qmldir souveraine/modules/ii/bar/qmldir
modules/ii/dock/qmldir souveraine/modules/ii/dock/qmldir
modules/ii/lock/qmldir souveraine/modules/ii/lock/qmldir
modules/ii/onScreenKeyboard/qmldir souveraine/modules/ii/onScreenKeyboard/qmldir
modules/ii/overview/qmldir souveraine/modules/ii/overview/qmldir
modules/ii/polkit/qmldir souveraine/modules/ii/polkit/qmldir
modules/ii/screenCorners/qmldir souveraine/modules/ii/screenCorners/qmldir
modules/ii/sessionScreen/qmldir souveraine/modules/ii/sessionScreen/qmldir
modules/ii/sidebarLeft/qmldir souveraine/modules/ii/sidebarLeft/qmldir
modules/ii/sidebarRight/qmldir souveraine/modules/ii/sidebarRight/qmldir
modules/ii/sidebarRight/volumeMixer/qmldir souveraine/modules/ii/sidebarRight/volumeMixer/qmldir
modules/settings/qmldir souveraine/modules/settings/qmldir
"
# wifi fallback: PHONE_HOST=casey@10.10.20.234 ./deploy.sh --phone
PHONE_USB="${PHONE_HOST:-casey@172.16.42.1}"
PHONE_DEST="souveraine-surfaces/quickshell"
manifest_lines() { printf '%s\n' "$MANIFEST" | sed '/^[[:space:]]*$/d'; }
# Compile the boot bloom shader to .qsb (Qt RHI) if the source is newer. The
# .qsb is architecture-independent (SPIR-V + reflection), so we build it once
# here and the manifest symlink / phone rsync carries it — no qsb on the phone.
compile_shaders() {
local frag="$SRC/modules/souveraine/boot/BootBloom.frag"
local qsb_out="$SRC/modules/souveraine/boot/BootBloom.frag.qsb"
[[ -f "$frag" ]] || return 0
if [[ ! -f "$qsb_out" || "$frag" -nt "$qsb_out" ]]; then
local qsb
qsb="$(command -v qsb || echo /usr/lib/qt6/bin/qsb)"
if [[ ! -x "$qsb" ]]; then
echo "ERROR: qsb not found (need qt6-shadertools) to build BootBloom.frag.qsb" >&2
exit 1
fi
# --glsl 100es,120,150 + --hlsl/--msl targets so it runs on the Adreno
# GLES backend the phone uses; -O optimizes.
"$qsb" --glsl "100es,120,150" --hlsl 50 --msl 12 -O -o "$qsb_out" "$frag"
echo "compiled BootBloom.frag -> BootBloom.frag.qsb"
fi
}
compile_shaders
if [[ "${1:-}" == "--manifest" ]]; then
manifest_lines
exit 0
fi
if [[ "${1:-}" == "--phone" ]]; then
ssh_i=(ssh -F /dev/null -i "$HOME/.ssh/ani" -o ConnectTimeout=5)
maintenance_active=0
hypridle_was_active=0
phone_lock_state() {
local state last_secure
state="$("${ssh_i[@]}" "$PHONE_USB" \
"qs -c souveraine ipc --any-display call session state 2>/dev/null" || true)"
if grep -Eq '"locked"[[:space:]]*:[[:space:]]*false' <<< "$state" \
&& grep -Eq '"lockRequested"[[:space:]]*:[[:space:]]*false' <<< "$state"; then
printf '%s\n' unlocked
return
fi
if grep -Eq '"locked"[[:space:]]*:[[:space:]]*true|"lockRequested"[[:space:]]*:[[:space:]]*true' <<< "$state"; then
printf '%s\n' locked
return
fi
# Bootstrap for a phone that predates the session IPC handler. Every
# compositor-acknowledged lock edge is journaled by LockScreen.qml.
last_secure="$("${ssh_i[@]}" "$PHONE_USB" \
"journalctl --user -u souveraine-shell.service -b --no-pager | sed -n 's/.*\\[lock\\] session lock secure=\\(true\\|false\\).*/\\1/p' | tail -n 1" || true)"
[[ "$last_secure" == "false" ]] && printf '%s\n' unlocked || printf '%s\n' locked
}
finish_phone_maintenance() {
(( maintenance_active )) || return 0
if (( hypridle_was_active )); then
"${ssh_i[@]}" "$PHONE_USB" \
"systemctl --user restart hypridle.service; systemctl --user stop souveraine-deploy-lease.timer 2>/dev/null || true; systemctl --user reset-failed souveraine-deploy-lease.service 2>/dev/null || true" \
>/dev/null || true
fi
maintenance_active=0
}
if [[ "$(phone_lock_state)" != "unlocked" ]]; then
echo "ERROR: phone session is locked; refusing a live shell deploy" >&2
exit 1
fi
if "${ssh_i[@]}" "$PHONE_USB" "systemctl --user is-active --quiet hypridle.service"; then
hypridle_was_active=1
# Schedule recovery before stopping idle handling. If this deploy or
# its SSH connection dies, systemd restores hypridle within five
# minutes. Manual lock remains available throughout the lease.
"${ssh_i[@]}" "$PHONE_USB" \
"systemctl --user stop souveraine-deploy-lease.timer 2>/dev/null || true; systemd-run --user --unit=souveraine-deploy-lease --on-active=5min --timer-property=AccuracySec=1s --collect /usr/bin/systemctl --user restart hypridle.service >/dev/null; systemctl --user stop hypridle.service"
fi
maintenance_active=1
trap finish_phone_maintenance EXIT
# Close the small check→inhibit race. A manual lock always wins.
if [[ "$(phone_lock_state)" != "unlocked" ]]; then
echo "ERROR: phone locked while acquiring the maintenance lease" >&2
exit 1
fi
deployed_revision="unknown"
if repo_root="$(git -C "$SRC" rev-parse --show-toplevel 2>/dev/null)"; then
deployed_revision="$(git -C "$repo_root" rev-parse HEAD)"
if ! git -C "$repo_root" diff --quiet -- "$SRC" \
|| [[ -n "$(git -C "$repo_root" ls-files --others --exclude-standard -- "$SRC")" ]]; then
deployed_revision="${deployed_revision}-dirty"
fi
fi
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/$PHONE_DEST"
# NEVER --delete into the phone's tree.
#
# `~/souveraine-surfaces` on the device is not a git repo — nothing there has
# history — and files get edited directly on the phone. This path used to be
# `rsync -a --delete` with no snapshot, so an on-device edit was destroyed
# with no record that it had existed. It cost real work more than once, and
# it is the root of both the ~900-file ii drift and the stevia pkgrels that
# only ever existed on the phone.
#
# Note the asymmetry that made it survive: the LOCAL compose path below
# snapshots to .ii-previous/.souveraine-previous before it switches
# anything. Only the phone — the one machine that is a daily driver and has
# no history — was handled without one.
#
# So: snapshot first, copy without deleting, and REPORT what a --delete
# would have taken. Divergence becomes visible instead of being silently
# resolved in the laptop's favour.
PHONE_SNAPSHOT="souveraine-surfaces/.quickshell-previous"
if ! "${ssh_i[@]}" "$PHONE_USB" \
"mkdir -p ~/$PHONE_SNAPSHOT && rsync -a --delete ~/$PHONE_DEST/ ~/$PHONE_SNAPSHOT/"; then
echo "ERROR: could not snapshot the phone's surface tree — refusing to write to it" >&2
exit 1
fi
echo "phone tree snapshotted to ~/$PHONE_SNAPSHOT"
# What a --delete would have destroyed. Listed, never removed.
phone_only="$(rsync -ain --delete -e "ssh -F /dev/null -i $HOME/.ssh/ani" \
"$SRC/" "$PHONE_USB:$PHONE_DEST/" 2>/dev/null | sed -n 's/^deleting //p')"
if [[ -n "$phone_only" ]]; then
echo "phone-only files LEFT IN PLACE (a --delete would have destroyed these):" >&2
printf '%s\n' "$phone_only" | sed 's/^/ /' >&2
echo " → if any of that is work you want, copy it back into the repo now." >&2
fi
# 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/"
printf '%s\n' "$deployed_revision" \
| "${ssh_i[@]}" "$PHONE_USB" "cat > ~/$PHONE_DEST/DEPLOYED-REVISION"
"${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh"
# Phone-only: install mobile launchers into the app grid.
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop && ln -sf ~/$PHONE_DEST/ii-base/modules/common/workspace-selector.desktop ~/.local/share/applications/workspace-selector.desktop"
finish_phone_maintenance
trap - EXIT
echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required."
exit 0
fi
if [[ "${1:-}" == "-u" || "${1:-}" == "--uninstall" ]]; then
rm -rf "$SV"
echo "removed $SV (ii tree untouched)"
exit 0
fi
if [[ "${1:-}" == "--legacy-clean" ]]; then
# One-time migration: strip the old overlay out of ii.
# 1. remove any symlink in ii that points into a souveraine checkout
find "$II" -type l | while read -r l; do
case "$(readlink "$l")" in
*souveraine*) rm -f "$l"; echo "unlinked ${l#$QS/}" ;;
esac
done
# 2. restore upstream backups
find "$II" -name '*.upstream' | while read -r u; do
mv -f "$u" "${u%.upstream}"
echo "restored ${u%.upstream}"
done
exit 0
fi
# --- Sync the pinned ii base --------------------------------------------
# Build both trees away from their live paths, then converge the stable live
# directories with delayed renames. Quickshell's watcher follows the config
# directory inode and does not tolerate replacing that root, even atomically.
# The three-phase order keeps every symlink valid throughout the update:
# add/update ii files, switch the composed surface, then delete stale ii files.
mkdir -p "$QS"
clear_generated_tree() {
local path="$1"
case "$path" in
"$QS/.ii-previous"|"$QS/.souveraine-previous"|"$QS"/.ii-next.*|"$QS"/.souveraine-next.*)
[[ ! -e "$path" && ! -L "$path" ]] || find "$path" -depth -delete
;;
*)
echo "refusing to clear unexpected rollback path: $path" >&2
return 1
;;
esac
}
snapshot_tree() { # live rollback
local live="$1" rollback="$2"
clear_generated_tree "$rollback"
if [[ -e "$live" || -L "$live" ]]; then
mkdir -p "$rollback"
rsync -a --delete --exclude='.git' "$live/" "$rollback/"
fi
}
II_NEXT="$QS/.ii-next.$$"
SV_NEXT="$QS/.souveraine-next.$$"
[[ ! -e "$II_NEXT" && ! -e "$SV_NEXT" ]] || {
echo "staging path already exists; refusing: $II_NEXT or $SV_NEXT" >&2
exit 1
}
cleanup_staging() {
clear_generated_tree "$II_NEXT"
clear_generated_tree "$SV_NEXT"
}
trap cleanup_staging EXIT
mkdir -p "$II_NEXT"
rsync -a --delete --exclude='.git' "$SRC/ii-base/" "$II_NEXT/"
if [[ "$TARGET_ARCH" == "aarch64" ]]; then
rsync -a "$SRC/ii-phone/" "$II_NEXT/"
ii_description="pin + phone overlay"
else
ii_description="pin"
fi
install -m 0644 "$SRC/ii-base.pin" "$II_NEXT/.souveraine-upstream-pin"
snapshot_tree "$II" "$QS/.ii-previous"
mkdir -p "$II"
# Phase 1: make every path the new composition can reference available, but
# retain old extras until their Souveraine override/symlink has switched.
rsync -a --delay-updates "$II_NEXT/" "$II/"
# --- Compose -------------------------------------------------------------
# Targets under souveraine/, relative to $SV
SV_TARGETS="$(manifest_lines | awk '$2 ~ /^souveraine\// {sub(/^souveraine\//, "", $2); print $2}')"
is_replaced() { # exact file override
grep -qxF "$1" <<< "$SV_TARGETS"
}
is_touched() { # dir contains an override somewhere below
grep -q "^$1/" <<< "$SV_TARGETS"
}
compose_dir() { # $1 = path relative to ii root ("" for root)
local rel="$1" entry name erel
mkdir -p "$SV_NEXT${rel:+/$rel}"
for entry in "$II_NEXT${rel:+/$rel}"/*; do
[[ -e "$entry" ]] || continue
name="$(basename "$entry")"
[[ "$name" == *.upstream ]] && continue
erel="${rel:+$rel/}$name"
if [[ -d "$entry" ]]; then
if is_touched "$erel"; then
compose_dir "$erel"
else
ln -sfn "$II/$erel" "$SV_NEXT/$erel"
fi
else
# root files and files inside touched dirs: link individually,
# skipping ones our manifest replaces
is_replaced "$erel" && continue
ln -sf "$II/$erel" "$SV_NEXT/$erel"
fi
done
}
# Build from scratch at the staging path: cheap, with no stale links.
compose_dir ""
# Our files on top (this also creates dirs that exist only in our tree,
# e.g. brand-new settings pages)
mkdir -p "$QS/pill"
while read -r rel target; do
[[ -z "$rel" ]] && continue
src="$SRC/$rel"; t="$SV_NEXT/${target#souveraine/}"
[[ -f "$src" ]] || { echo "missing $src" >&2; exit 1; }
mkdir -p "$(dirname "$t")"
ln -sf "$src" "$t"
done < <(manifest_lines)
snapshot_tree "$SV" "$QS/.souveraine-previous"
mkdir -p "$SV"
# Phase 2: switch the composed tree using delayed per-entry renames while its
# root directory remains stable for Quickshell's recursive watcher.
rsync -a --delete-delay --delay-updates "$SV_NEXT/" "$SV/"
# Phase 3: the composition no longer points at obsolete ii-only paths, so it
# is now safe to remove them and finish exact convergence with the pin.
rsync -a --delete-delay --delay-updates "$II_NEXT/" "$II/"
clear_generated_tree "$II_NEXT"
clear_generated_tree "$SV_NEXT"
trap - EXIT
echo "ii synced from $ii_description (rollback: $QS/.ii-previous)"
n_ours=$(manifest_lines | grep -c .)
n_borrowed_dirs=$(find "$SV" -maxdepth 3 -type l -xtype d | wc -l)
echo "souveraine config composed at $SV ($n_ours files ours, $n_borrowed_dirs dirs borrowed from ii)"
# Every qmldir entry must resolve to a file that exists in the composed tree.
#
# This is the failure that has bitten three times (ReloadPopup 2026-07-22,
# SessiondPolicy 07-25, ChargeRate 07-26). A qmldir naming a file that is not
# there does not fail that one type: Quickshell fails the whole MODULE, and
# `qs.services` failing takes GlobalStates with it, which takes shell.qml.
# The shell then refuses every reload and keeps serving the last scene that
# loaded — silently, for days. On 2026-07-27 the laptop was found running a
# 49-hour-old scene for exactly this reason.
#
# Loud, not fatal: the tree is already switched by this point, and the old
# tree is no better. Say it clearly and exit non-zero so a caller notices.
qmldir_broken=0
while IFS= read -r qd; do
dir=$(dirname "$qd")
# Field 2 of `singleton Name 1.0 File.qml` / `Name 1.0 File.qml` is the
# version for singletons and the file for plain types; take the last
# field, which is the filename in both shapes.
while read -r file; do
[[ -n "$file" ]] || continue
[[ -e "$dir/$file" ]] && continue
echo "BROKEN qmldir: $qd names '$file' which is not in the composed tree" >&2
qmldir_broken=1
done < <(awk '/\.qml[[:space:]]*$/ { print $NF }' "$qd")
done < <(find "$SV" -name qmldir -type f -o -name qmldir -type l)
if (( qmldir_broken )); then
echo "REFUSING TO CALL THIS DEPLOYED: the shell will fail every reload and" >&2
echo "keep serving its last good scene. Add the missing file to the deploy" >&2
echo "manifest above, or remove its qmldir line." >&2
exit 1
fi
# The phone's quick-toggle chooser is a WHOLE-FILE override of the base one, so
# a choice added to the base never reaches the phone unless it is added there
# too. That drift is silent by construction: the panel derives what it can build
# from the chooser, so a missing choice is not an error, it is a toggle that
# quietly stops existing on one device. Base must be a subset of phone.
#
# This is the same class as the qmldir check above and it has already cost a
# session: five configured toggles rendered nothing for a day because a second
# enumeration had drifted from this one.
chooser_rel="modules/ii/sidebarRight/quickToggles/androidStyle/AndroidToggleDelegateChooser.qml"
base_chooser="$SRC/ii-base/$chooser_rel"
phone_chooser="$SRC/ii-phone/$chooser_rel"
if [[ -f "$base_chooser" && -f "$phone_chooser" ]]; then
role_values() { grep -o 'roleValue: "[A-Za-z]*"' "$1" | sed 's/.*"\(.*\)"/\1/' | sort -u; }
missing_on_phone="$(comm -23 <(role_values "$base_chooser") <(role_values "$phone_chooser") | tr '\n' ' ')"
if [[ -n "${missing_on_phone// /}" ]]; then
echo "BROKEN toggle chooser overlay: ii-phone is missing base choices: $missing_on_phone" >&2
echo "The phone chooser overrides the base file wholesale — add the same" >&2
echo "DelegateChoice blocks to ii-phone/$chooser_rel or those toggles will" >&2
echo "silently not exist on the phone." >&2
exit 1
fi
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
echo "Run with: qs -c souveraine (restart: pkill -f 'qs -c souveraine')"

View file

@ -0,0 +1,563 @@
// 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.
//
// 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
// 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
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."
// 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;
host.running = true;
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;
}
function toggle() {
if (root.joined)
root.leave();
else
root.join();
}
// 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
// 2026-08-06: 640 to cut the ~100px of dead space under her feet, and it
// came back "a tiny version that's scaled odd" because the whole figure
// 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: "540x1000"
Process {
id: host
command: ["souveraine-web",
"--rig", root.rigDir,
"--ipc", root.socketPath,
"--transparent",
"--size", root.faceSize,
"--app-id", "org.souveraine.face",
"--title", "Ani"]
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");
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;
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":
HidController.sendText(message.text ?? "");
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
// ## Speaking through her
//
// 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()`.
//
// The recorder is `pw-record` at 16k mono s16 and the transcription is
// `souveraine-stt --file`, deliberately: that script already owns the
// endpoint from Settings Speech and the whole error vocabulary
// (unreachable / 5xx / rejected), and a second copy of that here would be
// the second answer to "where does dictation go". 16k mono s16 is not a
// preference either the comment in that script records that the server
// 500s on anything else.
//
// Written as one `sh -c` rather than a helper on PATH because the shell
// tree deploys as a unit and a new file on the device would need a package
// to reach it (CLAUDE.md's rule, and the trap that left sessiond five days
// stale). Two commands, one place.
function _talk(phase) {
if (phase === "start") {
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"];
recorder.running = true;
return;
}
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
// was captured before an abandoned gesture would put words she never
// finished into the conversation.
if (phase !== "end") {
stopper.command = ["sh", "-c", `kill -INT $(cat "$P" 2>/dev/null) 2>/dev/null; rm -f "$P" "$W"`];
stopper.running = true;
root._eval(`window.face.posture("idle")`);
return;
}
// The 0.3s is not padding: pw-record finalises the WAV header on the
// way out, and reading it sooner gets a file the server rejects.
// `souveraine-stt` learned this the same way and its comment says so.
// The `tr`/`sed` is not tidying. Whisper wraps its output with
// embedded newlines, and the transcript arrives here through a
// `SplitParser` on "\n" so four wrapped lines would be **four
// separate messages** sent to her, one turn each, instead of one
// utterance. `souveraine-stt` collapses them on its own typing leg
// and says why in a comment; `--file` prints them raw, so the same
// trap arrives by the other door and has to be closed on this side.
transcriber.command = ["sh", "-c",
`kill -INT $(cat "$P" 2>/dev/null) 2>/dev/null; rm -f "$P"; sleep 0.4; ` +
`[ -s "$W" ] || exit 0; souveraine-stt --file "$W" ` +
`| tr '\\n\\r' ' ' | sed 's/ */ /g; s/^ //; s/ $//'; echo; rm -f "$W"`];
transcriber.running = true;
root._eval(`window.face.posture("thinking")`);
}
readonly property string _wav: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-face-talk.wav"
readonly property string _pid: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-face-talk.pid"
Process {
id: recorder
environment: ({ W: root._wav, P: root._pid })
}
Process {
id: stopper
environment: ({ W: root._wav, P: root._pid })
}
Process {
id: transcriber
environment: ({ W: root._wav, P: root._pid })
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
const said = line.trim();
if (said.length === 0)
return;
// Straight into the one transport, so the sidebar logs it and
// the reply streams back to the bubble through the same
// `onStreamEvent` her own speech already uses.
Souveraine.send(said);
}
}
onExited: root._eval(`window.face.posture("idle")`)
}
// What the page says, where someone can see it.
//
// The host forwards console and errors on the same line protocol. Dropped
// here, a rig that fails to draw is silent in every direction which it
// was, and it cost 2026-08-06 an afternoon: a missing `#live_talk` element
// threw on the runtime's first update, after the model and all four
// textures had loaded, so every other signal read healthy.
function _pageSaid(level, text) {
if (level === "error")
console.warn("[face] page error:", text);
else
console.log("[face]", text);
}
// 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 joinHands(): void {
root.joinHands();
}
function leave(): void {
root.leave();
}
function status(): string {
return JSON.stringify({
joined: root.joined,
rig: root.rigDir
});
}
}
Socket {
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) {
if (sock.connected)
sock.write(JSON.stringify(msg) + "\n");
}
// ## She is the user's, so she leaves when the user does
//
// Casey, 2026-08-06: "if I lock the screen, she should probably assume to
// turn off... she's not for everyone, just the user." Explicitly *not* the
// same as switching to an app she persists across app use; only the lock
// takes her away.
//
// Gated on `screenLockSecure` the compositor's acknowledgement and NOT
// on `screenLocked`, which is only the request.
//
// The request drifts. Measured on the phone 2026-08-06: `session lock`
// answered `already-locked` while logind reported `LockedHint=no` and the
// phone was in use, so `screenLocked` had been stuck true for some time.
// That is CLAUDE.md's rule 2 exactly a shadow copy of state the protocol
// owns and a face gated on it would have been permanently dismissed with
// nothing on screen to explain why. `screenLockSecure` is the one
// GlobalStates itself calls "the real 'session is locked' signal".
//
// Nothing is lost by waiting for the ack: the compositor composites lock
// surfaces and nothing else while locked, so she is already off the glass
// before this runs. This is about not holding 283MB of webview through a
// locked night, not about disclosure.
// She does not come back on unlock, deliberately. Casey, 2026-08-06: "I
// want it recognized it locked, and going back to clock, and being clock
// until we retrigger it." Unlocking returns you to the clock, and summoning
// her is a double tap away so the state you find is the plain one, and
// the face is something you choose each time rather than something that
// was left on.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure && root.joined)
root.leave();
}
}
// Where fingers are, straight from the compositor, so she can look at them.
//
// Only open while she is up, because the compositor throttles but does not
// stop: a feed nobody is reading is a socket buffer filling behind a face
// that is not on screen.
//
// Screen coordinates come in; her window's own coordinates go out. The
// compositor reports in logical panel pixels and the page thinks in CSS
// pixels inside her window, so the origin has to be subtracted or she
// looks at a point offset by however far down the panel she is standing.
Socket {
id: gaze
path: (Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000") + "/souveraine/viewtop.sock"
connected: root.joined
// `onConnectionStateChanged`, not `onConnectedChanged` Quickshell's
// Socket emits the former, so the latter is a handler for a signal
// that does not exist and never runs. The subscribe was therefore
// never sent, the compositor never pushed, and she never followed a
// finger. `ViewtopControl`'s feed had the right idiom the whole time.
onConnectionStateChanged: {
console.log("[face] gaze socket connected=" + gaze.connected);
if (gaze.connected)
gaze.write('{"op":"gaze"}\n');
}
parser: SplitParser {
splitMarker: "\n"
onRead: line => {
let m;
try {
m = JSON.parse(line);
} catch (e) {
return;
}
if (root._gazeSeen === undefined) root._gazeSeen = 0;
if (root._gazeSeen++ < 4)
console.log("[face] gaze push: " + line);
if (m.ok !== undefined && m.down === undefined)
return;
if (!m.down) {
root._eval("window.face.lookAway()");
return;
}
root._eval(`window.face.lookAt(${m.x - root.originX}, ${m.y - root.originY})`);
}
}
}
// Where her window sits on the panel. Read from the compositor's own
// furniture report rather than assumed, because the layout decides it and
// it moves with the zone.
property var _gazeSeen: undefined
property real originX: 0
property real originY: 0
Socket {
id: whereAmI
path: (Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000") + "/souveraine/viewtop.sock"
onConnectionStateChanged: {
if (whereAmI.connected)
whereAmI.write('{"op":"state"}\n');
}
parser: SplitParser {
splitMarker: "\n"
onRead: line => {
try {
const s = JSON.parse(line);
const f = (s.furniture ?? [])[0];
if (f?.at) {
root.originX = f.at.x;
root.originY = f.at.y;
}
} catch (e) {}
whereAmI.connected = false;
}
}
}
// Asked once she is up, and again a moment later: the first answer can
// land before the compositor has stood her up, and then her origin is
// whatever the last window left there.
Timer {
running: root.joined
interval: 2000
repeat: true
triggeredOnStart: true
onTriggered: whereAmI.connected = true
}
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 = "";
root._syncHands();
}
function onConversationResumed(agentId, conversationId, messages) {
root._syncHands();
}
function onCurrentAgentIdChanged() {
root._syncHands();
}
function onConversationIdChanged() {
root._syncHands();
}
}
Connections {
target: HidController
function onControllerChanged() {
root._syncHands();
}
}
}

View file

@ -0,0 +1,307 @@
/*
* 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: ""
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() {
if (root.ready) {
root._flushPointer();
injector.write("release\n");
}
root.active = false;
root.ready = false;
root._pendingX = 0;
root._pendingY = 0;
root._pendingWheel = 0;
flushTimer.stop();
root._reconcile();
root.controllerChanged();
}
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 retry() {
root.lastError = "";
if (injector.running)
injector.running = false;
root._reconcile();
}
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 lines = String(value ?? "").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 => {
if (line.trim() !== "ready")
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, exitStatus) => {
root.ready = false;
if (root.permitted && root.modeReady && root.lastError.length === 0)
root.lastError = "HID bridge exited (" + exitCode + ")";
root.controllerChanged();
}
}
Connections {
target: UsbState
function onRefreshed() {
root._reconcile();
root.controllerChanged();
}
function onChangeFailed(reason) {
root.lastError = reason;
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) ? "sent" : root._notReadyReason();
}
function key(keyName: string, modifiers: string): string {
return root.key(keyName, modifiers) ? "sent" : root._notReadyReason();
}
function tap(keyName: string): string {
return root.key(keyName) ? "sent" : root._notReadyReason();
}
function click(button: string): string {
return root.click(button) ? "sent" : root._notReadyReason();
}
function pointer(x: int, y: int, wheel: int): string {
return root.movePointer(x, y, wheel) ? "sent" : root._notReadyReason();
}
}
}

View file

@ -0,0 +1,519 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions as CF
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
/**
* Souveraine the substrate singleton for every shell module.
*
* This is the one connection to the Souveraine server. The chat sidebar,
* presence widget, cockpit pane, agent manager and settings module all hang
* off this service; none of them open their own transport. Ai.qml is the
* ii-compat adapter over this for the existing sidebar UI.
*
* Responsibilities:
* - agent inventory (GET /v1/agents)
* - conversation lifecycle (create and server-derived resume)
* - the SSE turn stream raw events re-emitted via streamEvent(var)
* - the backchannel: cancelTurn() and interject(text)
* - the desktop sensorium: every send carries ambient context (active
* window, open apps, cursor position) so she perceives the room she is
* being spoken to in. Extension point for device sensors (SouveraineOS).
*/
Singleton {
id: root
property string serverBase: Config.options?.ai?.souveraineUrl ?? "http://127.0.0.1:8484"
property bool serverUp: false
// Ambient perception on by default; ai.ambient=false in ii config disables.
property bool ambientEnabled: Config.options?.ai?.ambient ?? true
// Start `souveraine server` ourselves when it isn't running. The surface
// is the OS frontend opening it means summoning her, not staring at a
// connection error. ai.souveraineAutostart=false disables; a manual
// start affordance can call startServer() directly.
property bool autostartEnabled: Config.options?.ai?.souveraineAutostart ?? true
property string serverBin: Config.options?.ai?.souveraineBin ?? "souveraine"
property bool _autostartTried: false
// id -> { name, description }
property var agents: ({})
property var agentList: Object.keys(agents)
property string currentAgentId: ""
// Auto-resume: whenever the active agent is (re)established shell start,
// reboot, agent switch re-attach her latest server-persisted conversation
// so the next message continues the thread instead of minting a fresh one.
// Without this every shell reload starts amnesiac, and the shell reloads
// often (a deploy, a lock, a crash). The server is the source of truth here;
// the surface deliberately keeps no agent conversation map of its own.
// Gated so it never clobbers a live turn or an already-attached thread.
onCurrentAgentIdChanged: {
if (root.currentAgentId.length > 0 && root.serverUp
&& root.conversationId.length === 0 && !root.turnActive) {
root.resumeLatestConversation();
}
}
property string conversationId: ""
property bool turnActive: false
// Turn clock
// Wall-clock for the request in flight. Lives here because turnActive
// does; the chat surface and the pill both read it. `turnStartedAt` is
// when we handed the request to curl (ms epoch, 0 = nothing sent yet),
// `turnElapsedMs` ticks while the turn runs and freezes at the total.
property double turnStartedAt: 0
property int turnElapsedMs: 0
Timer {
running: root.turnActive
interval: 100
repeat: true
onTriggered: root.turnElapsedMs = Date.now() - root.turnStartedAt
}
/* Raw wire events (message_type-tagged objects from the SSE stream). */
signal streamEvent(var event)
/* Stream closed (process exit). exitCode 0 = clean. */
signal streamClosed(int exitCode)
signal agentsRefreshed()
signal serverUnreachable()
// Emitted after a server-owned conversation has been selected and its
// persisted transcript loaded for the active surface.
signal conversationResumed(string agentId, string conversationId, var messages)
// Emitted when a send is blocked by step-up auth. The UI should call
// StepUpAuth.requestAuth("send", callback) and retry on success.
signal stepUpRequired(string actionFamily, string queuedText)
// Agent inventory
Process {
id: getAgents
running: true
command: ["curl", "-sf", "--max-time", "3", `${root.serverBase}/v1/agents`]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
try {
const list = JSON.parse(text);
const map = {};
// voice_id comes from the agent's `_souveraine` block via
// the public list endpoint. None/absent = use the system
// voice. Speech reads this per active agent.
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "", "voice_id": a.voice_id ?? "" }; });
root.agents = map;
root.agentList = Object.keys(map);
root.serverUp = true;
if (!root.agents[root.currentAgentId] && root.agentList.length > 0) {
root.currentAgentId = root.agentList[0];
}
root.agentsRefreshed();
} catch (e) {
console.log("[Souveraine] Could not parse agent list:", e);
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
root.serverUp = false;
if (root.autostartEnabled && !root._autostartTried) {
root.startServer();
} else {
root.serverUnreachable();
}
}
}
}
function refreshAgents() {
getAgents.running = true;
}
// The inventory used to be fetched exactly once, at shell start, so an
// agent created afterwards stayed invisible until the whole shell was
// reloaded. One curl a minute is cheaper than that surprise. Skipped
// while a turn is in flight so a slow local model isn't competing with
// polling for the server's attention.
Timer {
interval: 60000
repeat: true
running: true
onTriggered: if (!root.turnActive) root.refreshAgents()
}
// Server autostart
// systemd user unit first (survives shell restarts, journald logging);
// bare nohup fallback for systems without it. One attempt per shell
// session a broken install shouldn't spawn-loop.
Process {
id: serverStarter
command: ["bash", "-c",
`if command -v systemctl >/dev/null && systemctl --user list-unit-files souveraine.service &>/dev/null; then
systemctl --user start souveraine.service
else
nohup ${root.serverBin} server >/dev/null 2>&1 &
fi`]
onExited: {
serverRetryTimer.start();
}
}
Timer {
id: serverRetryTimer
interval: 2500
repeat: false
onTriggered: root.refreshAgents()
}
function startServer() {
if (root._autostartTried) return;
root._autostartTried = true;
console.log("[Souveraine] server not reachable — starting it");
serverStarter.running = true;
}
function selectAgent(agentId) {
if (!root.agents[agentId]) return false;
// Re-affirming the agent that is already active is a no-op, and it has
// to be. Ai.qml re-selects the persisted agent on every agentsRefreshed,
// and the inventory poll above fires that once a minute, forever. While
// this function cleared conversationId unconditionally, every message
// sent more than a minute after the previous one opened a NEW
// conversation and therefore arrived with no history at all.
//
// Measured on the phone 2026-07-31: 27 conversations for one agent in a
// day, all but two exactly four messages long system prompt, ambient,
// user, reply. One exchange each. That is the whole of the "she doesn't
// remember what I just said" report, and it is not the turn loop: the
// server assembles history from session.messages correctly, and there
// was simply never more than one exchange in a session to assemble.
//
// Switching agents is a decision. Polling is not.
if (agentId === root.currentAgentId) return true;
// A live turn belongs to the current conversation. Switching beneath
// it would render one agent's response in another agent's surface.
if (root.turnActive) return false;
// Clear before the id changes, so onCurrentAgentIdChanged observes an
// empty conversation and re-attaches the incoming agent's own latest
// thread rather than leaving her on a blank one.
root.conversationId = "";
root.currentAgentId = agentId;
return true;
}
function newConversation() {
root.conversationId = "";
}
// Server-derived resume
// The GUI keeps no per-agent conversation map. The server is the source
// of truth: it persists conversations under each agent and this query
// hydrates them after a server restart before returning the latest one.
Process {
id: listConversations
property string agentId: ""
stdout: StdioCollector {
onStreamFinished: {
if (listConversations.agentId !== root.currentAgentId) return;
// Empty stdout is a FAILED request, not an empty agent: curl -sf
// writes nothing on 4xx/5xx. Conflating the two clears
// conversationId and hands the surface an empty transcript to
// render, so one transient hiccup wipes the visible thread and
// orphans the live one. Only a parsed response is authoritative.
if (text.length === 0) {
console.log("[Souveraine] empty conversation list response — leaving current conversation in place");
return;
}
let conversations = [];
try {
conversations = JSON.parse(text);
} catch (e) {
console.log("[Souveraine] Could not parse conversation list:", e);
return;
}
if (conversations.length === 0) {
// The server answered, and the answer is "none yet".
root.conversationId = "";
root.conversationResumed(root.currentAgentId, "", []);
return;
}
// Server sorts by updated_at descending [0] is her latest.
root._loadConversation(listConversations.agentId, conversations[0].id);
}
}
onExited: exitCode => {
// A failed list fetch is transient (server busy, network blip).
// Do NOT clear conversationId that orphans the live thread and
// forces the next send to mint a fresh conversation. Log and leave
// state alone; the next refresh or /resume retries.
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
console.log("[Souveraine] conversation list fetch failed (exit " + exitCode + ") — leaving current conversation in place");
}
}
}
Process {
id: loadConversation
property string agentId: ""
property string requestedConversationId: ""
stdout: StdioCollector {
onStreamFinished: {
if (loadConversation.agentId !== root.currentAgentId) return;
// Same rule as the list above: no body means the fetch failed.
// Attaching to the id anyway and announcing an empty transcript
// would blank the surface while claiming the thread is loaded.
if (text.length === 0) {
console.log("[Souveraine] empty transcript response — leaving current conversation in place");
return;
}
try {
const messages = JSON.parse(text);
root.conversationId = loadConversation.requestedConversationId;
root.conversationResumed(root.currentAgentId, root.conversationId, messages);
} catch (e) {
console.log("[Souveraine] Could not parse conversation transcript:", e);
}
}
}
onExited: exitCode => {
// Transient failure (e.g. a 5xx on GET /messages). Don't clear
// see listConversations.onExited. Leaving conversationId alone keeps
// an already-attached thread reachable instead of forcing a new one.
if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
console.log("[Souveraine] conversation transcript fetch failed (exit " + exitCode + ") — leaving current conversation in place");
}
}
}
function resumeLatestConversation() {
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive) return false;
listConversations.agentId = root.currentAgentId;
listConversations.command = [
"curl", "-sf", "--max-time", "5",
`${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}`
];
listConversations.running = true;
return true;
}
function _loadConversation(agentId, conversationId) {
loadConversation.agentId = agentId;
loadConversation.requestedConversationId = conversationId;
loadConversation.command = ["bash", "-c",
root._tokenReadLine(agentId)
+ `curl -sf --max-time 10 "${root.serverBase}/v1/conversations/${conversationId}/messages"`
+ ` -H "Authorization: Bearer $TOKEN"`
];
loadConversation.running = true;
}
// Ambient sensorium
// What the desktop feels like at the moment of speaking. Cheap,
// synchronous reads here; the cursor needs a hyprctl round-trip and is
// collected in the send chain. Device sensors (SouveraineOS positional
// data) extend collectAmbient().
property string _cursorPos: ""
function collectAmbient() {
if (!root.ambientEnabled) return "";
const lines = [];
const active = ToplevelManager.activeToplevel;
if (active) {
lines.push(`active window: ${active.appId ?? "?"} "${active.title ?? ""}"`);
}
const tops = ToplevelManager.toplevels?.values ?? [];
if (tops.length > 0) {
const apps = tops.map(t => t.appId).filter(Boolean);
const counts = {};
apps.forEach(a => counts[a] = (counts[a] ?? 0) + 1);
const summary = Object.entries(counts)
.map(([app, n]) => n > 1 ? `${app} (${n})` : app)
.join(", ");
lines.push(`open: ${summary}`);
}
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);
}
if (typeof HidController !== "undefined" && HidController.active) {
lines.push(HidController.skill);
}
return lines.join("\n");
}
// Compositor-specific cursor read. hyprctl on Hyprland, kdotool on KDE;
// anything else just skips the cursor line ambient degrades gracefully,
// it never blocks the send.
Process {
id: cursorProc
command: ["bash", "-c",
`if command -v hyprctl >/dev/null; then hyprctl cursorpos;
elif command -v kdotool >/dev/null; then kdotool getmouselocation 2>/dev/null;
fi`]
stdout: StdioCollector {
onStreamFinished: {
root._cursorPos = text.trim();
}
}
onExited: {
root._ensureConversationThenRequest();
}
}
// Send chain: cursor conversation stream
property string _queuedText: ""
/* Send a user message with ambient context. Returns false if the
server is down or no agent is selected. Returns "step-up" if
step-up auth is required but no valid grant exists the caller
should trigger StepUpAuth.requestAuth("send") and retry. */
function send(text) {
if (!root.serverUp || root.currentAgentId.length === 0) return false;
if (text.length === 0) 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,
// short-lived, and journaled.
if (Config.options?.lock?.stepUp?.enabled
&& typeof StepUpAuth !== "undefined"
&& !StepUpAuth.isGranted("send")
&& !StepUpAuth.isBreakGlass("send")) {
root.stepUpRequired("send", text);
return "step-up";
}
root._queuedText = text;
if (root.ambientEnabled) {
cursorProc.running = true; // chain continues in onExited
} else {
root._ensureConversationThenRequest();
}
return true;
}
Process {
id: createConversation
stdout: StdioCollector {
onStreamFinished: {
try {
const conv = JSON.parse(text);
root.conversationId = conv.id;
root._makeRequest();
} catch (e) {
console.log("[Souveraine] conversation create failed:", text);
root.streamClosed(1);
}
}
}
}
function _ensureConversationThenRequest() {
if (root.conversationId.length > 0) {
root._makeRequest();
return;
}
createConversation.command = [
"curl", "-sf", "-X", "POST",
`${root.serverBase}/v1/conversations`,
"-H", "Content-Type: application/json",
"--data", JSON.stringify({ "agent_id": root.currentAgentId })
];
createConversation.running = true;
}
property string requestScriptFilePath: "/tmp/quickshell/ai/souveraine-request.sh"
FileView {
id: requesterScriptFile
}
function _tokenReadLine(agentId) {
// Bearer token read at request time so rotation works.
return `TOKEN=$(cat "$HOME/.souveraine/server/agents/${agentId}/api_token" 2>/dev/null)\n`;
}
function _makeRequest() {
const data = {
"messages": [{ "role": "user", "content": root._queuedText }],
"stream": true
};
const ambient = root.collectAmbient();
if (ambient.length > 0) data["ambient"] = ambient;
root._queuedText = "";
const scriptContent = "#!/usr/bin/env bash\n"
+ root._tokenReadLine(root.currentAgentId)
+ `curl --no-buffer -sS -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/messages"`
+ ` -H 'Content-Type: application/json'`
+ ` -H "Authorization: Bearer $TOKEN"`
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
+ "\n";
const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath);
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath);
requesterScriptFile.setText(scriptContent);
requester.command = ["bash", shellScriptPath];
root.turnStartedAt = Date.now();
root.turnElapsedMs = 0;
root.turnActive = true;
requester.running = true;
}
Process {
id: requester
stdout: SplitParser {
onRead: data => {
if (data.length === 0 || !data.startsWith("data:")) return;
let event;
try {
event = JSON.parse(data.slice(5).trim());
} catch (e) {
console.log("[Souveraine] Unparseable SSE line:", data);
return;
}
root.streamEvent(event);
}
}
onExited: (exitCode, exitStatus) => {
// Freeze the clock on the real total the 100ms tick can be up to
// one interval behind when the process exits.
if (root.turnStartedAt > 0)
root.turnElapsedMs = Date.now() - root.turnStartedAt;
root.turnActive = false;
root.streamClosed(exitCode);
}
}
// Backchannel
Process {
id: backchannelProc
property string script: ""
command: ["bash", "-c", script]
}
function cancelTurn() {
if (root.conversationId.length === 0) return;
backchannelProc.script = root._tokenReadLine(root.currentAgentId)
+ `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/cancel"`
+ ` -H "Authorization: Bearer $TOKEN"`;
backchannelProc.running = true;
}
function interject(text) {
if (root.conversationId.length === 0 || text.length === 0) return;
const body = JSON.stringify({ "text": text });
backchannelProc.script = root._tokenReadLine(root.currentAgentId)
+ `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/interject"`
+ ` -H 'Content-Type: application/json'`
+ ` -H "Authorization: Bearer $TOKEN"`
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(body)}'`;
backchannelProc.running = true;
}
}

File diff suppressed because it is too large Load diff