Files
LLeMbas-Desktop/src-tauri/src/cli.rs
T
HomerandClaude Opus 5.5 1e2fa39193
ci / check (push) Failing after 3s
Project folders for the agent, Local, the menu under its button
- Settings… lists the folders the agent may work in, with the system's
  folder dialog, remove, max mode and terminal, all through
  'lembas remote … --json' (CLI 1.2.0). Turning the agent on with no
  folder opens it; the title bar says 'agent: no folders' instead of green.
- The content webview's User-Agent carries LLeMbasDesktop/<v> (host=…),
  so the server can show this computer's device as Local.
- The title bar menu pops up at the button (popup_menu_at): Wayland gave
  the pointer-less popup the middle of the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:52:25 +00:00

182 lines
6.0 KiB
Rust

//! The LLeMbas CLI this app carries, and what it keeps on disk. The app never writes the CLI's
//! files: it runs `lembas login --json` and `lembas service run`, and reads two files to know
//! where things stand — the logins (`lembas.json`) and the service's lock (`service.lock`).
//!
//! The directories are the CLI's own (XDG, `lembas` inside each), so the app and a `lembas`
//! installed by hand on the same computer are one device to the server, not two.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
/// the .deb, inside the AppImage otherwise) — the app's own copy, never on PATH, so it neither
/// shadows nor is shadowed by a `lembas` installed by hand. Set once at start.
pub fn set_bundled(resource_dir: &Path) {
let _ = BUNDLED.set(resource_dir.join(exe_name()));
}
fn exe_name() -> &'static str {
if cfg!(windows) {
"lembas.exe"
} else {
"lembas"
}
}
/// The CLI to run: `LLEMBAS_DESKTOP_CLI` (development), else the bundled one, else one beside
/// the app's executable (`cargo tauri dev`).
pub fn binary() -> PathBuf {
if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") {
return PathBuf::from(p);
}
if let Some(p) = BUNDLED.get().filter(|p| p.exists()) {
return p.clone();
}
std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(|d| d.join(exe_name())))
.unwrap_or_else(|| PathBuf::from(exe_name()))
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_default()
}
/// `$XDG_X_HOME/lembas`, or `~/<fallback>/lembas`: the CLI's `paths` (a relative XDG value
/// is ignored, as the spec says). `LEMBAS_HOME` roots all three, as it does for the CLI.
fn dir(var: &str, fallback: &str) -> PathBuf {
if let Some(root) = std::env::var_os("LEMBAS_HOME") {
let last = fallback.rsplit('/').next().unwrap_or(fallback);
return PathBuf::from(root).join(last).join("lembas");
}
match std::env::var_os(var).map(PathBuf::from) {
Some(p) if p.is_absolute() => p.join("lembas"),
_ => home().join(fallback).join("lembas"),
}
}
pub fn config_dir() -> PathBuf {
dir("XDG_CONFIG_HOME", ".config")
}
pub fn state_dir() -> PathBuf {
dir("XDG_STATE_HOME", ".local/state")
}
/// Whether this computer is signed in to `server` (a base from `settings::normalise`).
pub fn linked_to(server: &str) -> bool {
linked_in(&config_dir(), server)
}
fn linked_in(config: &Path, server: &str) -> bool {
let Ok(text) = std::fs::read_to_string(config.join("lembas.json")) else {
return false;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
let want = server.trim_end_matches('/');
v.get("instances")
.and_then(|i| i.as_object())
.map(|all| {
all.values().any(|i| {
i.get("base_url")
.and_then(|b| b.as_str())
.is_some_and(|b| b.trim_end_matches('/') == want)
})
})
.unwrap_or(false)
}
/// The pid of a `lembas service run` already going on this computer (installed as a service,
/// or started by hand), if any: then the app does not start another — one link per device.
pub fn running_service() -> Option<u32> {
running_in(&state_dir())
}
fn running_in(state: &Path) -> Option<u32> {
let pid: u32 = std::fs::read_to_string(state.join("service.lock"))
.ok()?
.trim()
.parse()
.ok()?;
(pid != 0 && alive(pid)).then_some(pid)
}
/// This computer's host name: what the CLI sent the server as `device_host` at login
/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one.
#[cfg(unix)]
pub fn hostname() -> Option<String> {
let mut buf = [0u8; 256];
let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if r != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
(!name.is_empty()).then_some(name)
}
#[cfg(not(unix))]
pub fn hostname() -> Option<String> {
std::env::var("COMPUTERNAME").ok().filter(|s| !s.is_empty())
}
#[cfg(unix)]
pub fn alive(pid: u32) -> bool {
// Signal 0 checks without sending; EPERM means it exists but is someone else's.
let r = unsafe { libc::kill(pid as libc::pid_t, 0) };
r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
pub fn alive(_pid: u32) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("llembas-cli-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn linked_reads_the_logins_the_cli_wrote() {
let d = scratch("linked");
assert!(!linked_in(&d, "https://ai.example"));
fs::write(
d.join("lembas.json"),
r#"{"instances":{"example":{"base_url":"https://ai.example","connection":"example"}}}"#,
)
.unwrap();
assert!(linked_in(&d, "https://ai.example"));
assert!(linked_in(&d, "https://ai.example/"));
assert!(!linked_in(&d, "https://other.example"));
fs::remove_dir_all(&d).unwrap();
}
#[cfg(unix)]
#[test]
fn a_lock_counts_only_while_its_process_lives() {
let d = scratch("lock");
assert_eq!(running_in(&d), None);
fs::write(d.join("service.lock"), std::process::id().to_string()).unwrap();
assert_eq!(running_in(&d), Some(std::process::id()));
// A pid far above any pid_max: no such process.
fs::write(d.join("service.lock"), "2147483000").unwrap();
assert_eq!(running_in(&d), None);
fs::remove_dir_all(&d).unwrap();
}
}