LLeMbas Desktop: the server in a window, the CLI's agent on this computer
ci / check (push) Failing after 24s
ci / check (push) Failing after 24s
Tauri 2, no Node. A first-run page chooses the server; then the window is the server's web UI, whose pages get no IPC. The bundled LLeMbas CLI (fetched and signature-checked by scripts/fetch-cli.sh) links the computer with 'lembas login --json' and runs as 'lembas service' while the app runs. A tray menu starts and stops it. Linux packages from GitHub Actions on a tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
28 files changed
+6801
No files matched your search
@@ -0,0 +1,146 @@
|
||||
//! 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};
|
||||
|
||||
/// The bundled binary: Tauri puts an `externalBin` beside the app's own executable.
|
||||
/// `LLEMBAS_DESKTOP_CLI` points elsewhere, for development.
|
||||
pub fn binary() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let name = if cfg!(windows) {
|
||||
"lembas.exe"
|
||||
} else {
|
||||
"lembas"
|
||||
};
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|e| e.parent().map(|d| d.join(name)))
|
||||
.unwrap_or_else(|| PathBuf::from(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)
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user