src/lua/client.rs
//! The client half of the API: talks to a running daemon over its Mach
//! service. [`module`] builds the table `require("paneru")` returns.
//!
//! Service name defaults to `com.karinushka.paneru`, overridable via the
//! `PANERU_MACH_SERVICE` environment variable or `paneru.set_service_name`.
//!
//! Every call here blocks: Lua's C API is synchronous, so a callback cannot
//! yield into an executor.
use std::rc::Rc;
use std::sync::{LazyLock, Mutex};
use crate::types::commands::Command;
use crate::types::script_state::ScriptStateWrite;
use crate::types::script_value::ScriptValue;
use crate::types::state::{StateEvent, StateQueryKind};
use crate::types::windowset_lua::returned_ops;
use crate::types::wire::{
self as wire, Error as WireError, RecvPort, Request, Response, ScriptStateRequest,
ScriptStateResponse, SendPort, Sender, WriteOutcome,
};
use mlua::prelude::*;
/// The active service name, seeded from the shared default and mutable via
/// `set_service_name`.
static SERVICE: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(wire::service_name()));
fn service_name() -> String {
SERVICE
.lock()
.map_or_else(|_| wire::SERVICE_NAME.to_string(), |guard| guard.clone())
}
fn connect() -> LuaResult<Sender<Request>> {
wire::connect(&service_name()).map_err(|err| match err {
WireError::NotRunning => LuaError::RuntimeError("paneru is not running".to_string()),
other => LuaError::external(other),
})
}
/// Sends a request and waits for the answer. A daemon-reported failure is
/// raised as a Lua error rather than returned, so a failed call is an error,
/// not a silent no-op.
fn call(request: &Request) -> LuaResult<Response> {
let sender = connect()?;
let response: Response = sender.call_blocking(request).map_err(LuaError::external)?;
match response {
Response::Error(message) => Err(LuaError::RuntimeError(message)),
other => Ok(other),
}
}
fn send(request: &Request) -> LuaResult<()> {
let sender = connect()?;
sender.send_blocking(request).map_err(LuaError::external)
}
fn dispatch(_: &Lua, command: Command) -> LuaResult<bool> {
send(&Request::Command(command))?;
Ok(true)
}
fn query_payload(kind: StateQueryKind) -> LuaResult<wire::QueryPayload> {
match call(&Request::Query(kind))? {
Response::Query(payload) => Ok(payload),
other => Err(unexpected(&other)),
}
}
fn read_kind(kind: Option<String>) -> LuaResult<StateQueryKind> {
let token = kind.unwrap_or_else(|| "state".to_string());
StateQueryKind::parse(&token).ok_or_else(|| {
LuaError::RuntimeError(format!(
"unknown query '{token}', expected one of {}",
StateQueryKind::tokens()
))
})
}
/// `paneru.query(kind)` — run a state query and return the raw JSON string.
fn query(_: &Lua, kind: Option<String>) -> LuaResult<String> {
Ok(query_payload(read_kind(kind)?)?
.to_json()
.map_err(LuaError::external)?
.to_string())
}
/// `paneru.query_json(kind)` — like `query` but decoded into a Lua value.
fn query_json(lua: &Lua, kind: Option<String>) -> LuaResult<LuaValue> {
let json = query_payload(read_kind(kind)?)?
.to_json()
.map_err(LuaError::external)?;
lua.to_value(&json)
}
fn script_state(request: ScriptStateRequest) -> LuaResult<ScriptStateResponse> {
match call(&Request::ScriptState(request))? {
Response::ScriptState(answer) => Ok(answer),
other => Err(unexpected(&other)),
}
}
/// A stored `Null` and an absent key both read as `nil` in Lua — it has no way
/// to hold "present but empty".
fn state_get(key: &str) -> LuaResult<Option<ScriptValue>> {
match script_state(ScriptStateRequest::Get {
key: key.to_string(),
})? {
ScriptStateResponse::Value(value) => Ok(value.filter(|value| !value.is_null())),
ScriptStateResponse::Write(_) => Err(LuaError::RuntimeError(
"paneru.state.get: the daemon answered a read with a write outcome".to_string(),
)),
}
}
fn state_write(write: ScriptStateWrite) -> LuaResult<WriteOutcome> {
match script_state(ScriptStateRequest::Write(write))? {
ScriptStateResponse::Write(outcome) => Ok(outcome),
ScriptStateResponse::Value(_) => Err(LuaError::RuntimeError(
"paneru.state: the daemon answered a write with a value".to_string(),
)),
}
}
/// Builds the `paneru.state` table: the same store the embedded runtime
/// exposes, but each call round-trips to the daemon instead of reading a local
/// copy. `mutate`'s compare-and-set guarantee holds either way, since the
/// daemon is the one deciding whether a write still matches.
fn state_table(lua: &Lua) -> LuaResult<LuaTable> {
let state = lua.create_table()?;
state.set(
"get",
lua.create_function(|lua, key: String| match state_get(&key)? {
Some(value) => value.into_lua(lua),
None => Ok(LuaValue::Nil),
})?,
)?;
// `set(key, nil)` removes, matching the embedded spelling.
state.set(
"set",
lua.create_function(|lua, (key, value): (String, LuaValue)| {
let write = if value.is_nil() {
ScriptStateWrite::remove(key)
} else {
ScriptStateWrite::set(key, ScriptValue::from_lua(value, lua)?)
};
state_write(write)?;
Ok(())
})?,
)?;
// Read, transform, write — retried against the current value whenever the
// daemon reports the key moved in between.
state.set(
"mutate",
lua.create_function(|lua, (key, transform): (String, LuaFunction)| {
/// How many times to lose the race before giving up, matching the
/// embedded runtime.
const ATTEMPTS: usize = 8;
let mut current = state_get(&key)?;
for _ in 0..ATTEMPTS {
let old = match current.clone() {
Some(value) => value.into_lua(lua)?,
None => LuaValue::Nil,
};
let returned: LuaValue = transform.call(old)?;
let next = if returned.is_nil() {
None
} else {
Some(ScriptValue::from_lua(returned, lua)?)
};
let outcome = state_write(ScriptStateWrite::compare_and_set(
key.clone(),
current.clone(),
next.clone(),
))?;
match outcome {
WriteOutcome::Applied { .. } => {
return match next {
Some(value) => value.into_lua(lua),
None => Ok(LuaValue::Nil),
};
}
// Somebody else wrote the key in between; re-run the
// transform against what is actually there now.
WriteOutcome::Conflict { current: found } => current = found,
}
}
Err(LuaError::RuntimeError(format!(
"paneru.state.mutate: '{key}' kept changing under it after {ATTEMPTS} attempts"
)))
})?,
)?;
Ok(state)
}
/// `paneru.windows(fn)` — xmonad's `windows`: hand the window set to `fn` and
/// commit whatever it returns. Costs two round trips (fetch, then commit)
/// rather than the embedded runtime's shared read; a transform returning
/// nothing skips the commit.
fn windows(lua: &Lua, transform: LuaFunction) -> LuaResult<bool> {
let set = match call(&Request::WindowSet)? {
Response::WindowSet(set) => *set,
other => return Err(unexpected(&other)),
};
let returned: LuaValue = transform.call(lua.create_userdata(set)?)?;
let ops = returned_ops(&returned)?;
if ops.is_empty() {
return Ok(false);
}
send(&Request::WindowSetApply(ops))?;
Ok(true)
}
fn read_events(value: &LuaValue) -> LuaResult<Option<Vec<String>>> {
match value {
LuaValue::Nil => Ok(None),
LuaValue::String(name) => Ok(Some(vec![name.to_str()?.to_string()])),
LuaValue::Table(names) => Ok(Some(
names
.clone()
.sequence_values::<String>()
.collect::<LuaResult<_>>()?,
)),
other => Err(LuaError::RuntimeError(format!(
"event must be a string, a table of strings, or nil, got {}",
other.type_name()
))),
}
}
fn event_name(event: &StateEvent) -> Option<String> {
event
.to_json()
.ok()?
.get("event")?
.as_str()
.map(str::to_string)
}
/// `paneru.subscribe(event, callback[, opts])` — stream events matching `event`
/// to `callback`. Blocks until the daemon exits, so run it in a dedicated
/// process. `event` is the event name to filter on (e.g. `"window_focused"`), a
/// table of several names, or `nil` for every event. Each event is a decoded Lua
/// table unless `opts.decode == false` (then the raw JSON line string).
fn subscribe(
lua: &Lua,
(event, callback, opts): (LuaValue, LuaFunction, Option<LuaTable>),
) -> LuaResult<bool> {
let events = read_events(&event)?;
let decode = opts
.as_ref()
.and_then(|opts| opts.get::<Option<bool>>("decode").ok().flatten())
.unwrap_or(true);
let sender = connect()?;
let stream = sender
.subscribe_blocking::<StateEvent>(&Request::Subscribe)
.map_err(LuaError::external)?;
loop {
let event = match stream.recv_blocking() {
Ok(delivery) => delivery.value,
// The daemon is gone; the subscription ends.
Err(WireError::PeerGone) => break,
Err(err) => return Err(LuaError::external(err)),
};
if let Some(wanted) = &events {
let name = event_name(&event);
if !name.is_some_and(|name| wanted.contains(&name)) {
continue;
}
}
let json = event.to_json().map_err(LuaError::external)?;
if decode {
callback.call::<()>(lua.to_value(&json)?)?;
} else {
callback.call::<()>(json.to_string())?;
}
}
Ok(true)
}
/// `paneru.set_service_name(name)` — override the daemon's Mach service name.
#[allow(clippy::unnecessary_wraps)]
fn set_service_name(_: &Lua, name: String) -> LuaResult<()> {
if let Ok(mut guard) = SERVICE.lock() {
*guard = name;
}
Ok(())
}
/// `paneru.service_name()` — the service name currently in use.
#[allow(clippy::unnecessary_wraps)]
fn service_name_fn(_: &Lua, (): ()) -> LuaResult<String> {
Ok(service_name())
}
fn unexpected(response: &Response) -> LuaError {
LuaError::RuntimeError(format!("unexpected response from paneru: {response:?}"))
}
/// Builds the module table `require("paneru")` hands back.
///
/// # Errors
///
/// Returns an error if any Lua table/function creation or assignment fails.
pub fn module(lua: &Lua, version: &str) -> LuaResult<LuaTable> {
let exports = lua.create_table()?;
super::shared::install(
lua,
&exports,
&(Rc::new(dispatch) as super::shared::Dispatch),
)?;
exports.set("query", lua.create_function(query)?)?;
exports.set("query_json", lua.create_function(query_json)?)?;
for (name, kind) in StateQueryKind::SHORTHANDS {
exports.set(
name,
lua.create_function(move |lua, ()| query_json(lua, Some(kind.token().to_string())))?,
)?;
}
exports.set("state", state_table(lua)?)?;
exports.set("windows", lua.create_function(windows)?)?;
exports.set("subscribe", lua.create_function(subscribe)?)?;
exports.set("set_service_name", lua.create_function(set_service_name)?)?;
exports.set("service_name", lua.create_function(service_name_fn)?)?;
exports.set("_VERSION", version)?;
Ok(exports)
}