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:
parent
517d8c1927
commit
c367a4dbd9
11 changed files with 11672 additions and 0 deletions
31
rescue/archdev-unversioned-copy-2026-08-09/files/scripts/install-desktop.sh
Executable file
31
rescue/archdev-unversioned-copy-2026-08-09/files/scripts/install-desktop.sh
Executable 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"
|
||||
|
|
@ -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(>k::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(®ion));
|
||||
}
|
||||
|
||||
#[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
|
|
@ -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
521
rescue/archdev-unversioned-copy-2026-08-09/files/surfaces/quickshell/deploy.sh
Executable file
521
rescue/archdev-unversioned-copy-2026-08-09/files/surfaces/quickshell/deploy.sh
Executable 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')"
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue