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,285 @@
|
||||
//! The agent: `lembas service run` as a child of the app, kept running while it is wanted.
|
||||
//!
|
||||
//! That one process is what makes this computer a device of the server — agent chats started in
|
||||
//! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged;
|
||||
//! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in
|
||||
//! progress ends cleanly) when the agent is switched off or the app quits.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli;
|
||||
|
||||
/// The CLI's "updated on disk, start me again" exit status.
|
||||
const RESTART_FOR_UPDATE: i32 = 75;
|
||||
const BACKOFF: Duration = Duration::from_secs(10);
|
||||
const STOP_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
|
||||
pub enum State {
|
||||
/// Not wanted.
|
||||
Off,
|
||||
/// This computer is not signed in to the server yet.
|
||||
NotLinked,
|
||||
Starting,
|
||||
Running,
|
||||
/// Another `lembas service` on this computer holds the link (its pid).
|
||||
Elsewhere(u32),
|
||||
/// It ended with an error; trying again shortly.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
State::Off => "Agent: off".into(),
|
||||
State::NotLinked => "Agent: this computer is not linked".into(),
|
||||
State::Starting => "Agent: starting…".into(),
|
||||
State::Running => "Agent: running".into(),
|
||||
State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"),
|
||||
State::Failed(why) => format!("Agent: stopped — {why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Notify = Arc<dyn Fn(&State) + Send + Sync>;
|
||||
|
||||
struct Inner {
|
||||
wanted: bool,
|
||||
/// Bumped by every start and stop, so an old supervisor notices it is no longer the one.
|
||||
generation: u64,
|
||||
child: Option<Child>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Agent {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
notify: Notify,
|
||||
log: PathBuf,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub fn new(log: PathBuf, notify: impl Fn(&State) + Send + Sync + 'static) -> Self {
|
||||
Agent {
|
||||
inner: Arc::new(Mutex::new(Inner {
|
||||
wanted: false,
|
||||
generation: 0,
|
||||
child: None,
|
||||
state: State::Off,
|
||||
})),
|
||||
notify: Arc::new(notify),
|
||||
log,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> State {
|
||||
self.inner.lock().unwrap().state.clone()
|
||||
}
|
||||
|
||||
pub fn wanted(&self) -> bool {
|
||||
self.inner.lock().unwrap().wanted
|
||||
}
|
||||
|
||||
fn set(&self, generation: u64, state: State) -> bool {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
return false;
|
||||
}
|
||||
if g.state != state {
|
||||
g.state = state.clone();
|
||||
drop(g);
|
||||
(self.notify)(&state);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Run the agent for `server`, and keep it running until `stop`.
|
||||
pub fn start(&self, server: String) {
|
||||
self.stop();
|
||||
let generation = {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
g.wanted = true;
|
||||
g.generation += 1;
|
||||
g.generation
|
||||
};
|
||||
let me = self.clone();
|
||||
thread::spawn(move || me.supervise(generation, server));
|
||||
}
|
||||
|
||||
fn supervise(&self, generation: u64, server: String) {
|
||||
loop {
|
||||
if !cli::linked_to(&server) {
|
||||
if !self.set(generation, State::NotLinked) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, BACKOFF);
|
||||
continue;
|
||||
}
|
||||
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
|
||||
if !self.set(generation, State::Elsewhere(pid)) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, BACKOFF);
|
||||
continue;
|
||||
}
|
||||
if !self.set(generation, State::Starting) {
|
||||
return;
|
||||
}
|
||||
let child = match self.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if !self.set(
|
||||
generation,
|
||||
State::Failed(format!("could not start {}: {e}", cli::binary().display())),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, BACKOFF);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
drop(g);
|
||||
terminate(child);
|
||||
return;
|
||||
}
|
||||
g.child = Some(child);
|
||||
}
|
||||
self.set(generation, State::Running);
|
||||
let code = self.wait_child(generation);
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
return;
|
||||
}
|
||||
g.child = None;
|
||||
drop(g);
|
||||
match code {
|
||||
Some(RESTART_FOR_UPDATE) => continue,
|
||||
Some(0) => {
|
||||
// It ended by itself without an error: nothing to do but start it again.
|
||||
self.pause(generation, Duration::from_secs(1));
|
||||
}
|
||||
other => {
|
||||
let why = other.map_or("ended by a signal".to_string(), |c| {
|
||||
format!("exit status {c}")
|
||||
});
|
||||
if !self.set(generation, State::Failed(why)) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, BACKOFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn(&self) -> std::io::Result<Child> {
|
||||
let (out, err) = self.log_handles();
|
||||
Command::new(cli::binary())
|
||||
.args(["service", "run"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(out)
|
||||
.stderr(err)
|
||||
.spawn()
|
||||
}
|
||||
|
||||
fn log_handles(&self) -> (Stdio, Stdio) {
|
||||
let open = || -> Option<File> {
|
||||
if let Some(d) = self.log.parent() {
|
||||
std::fs::create_dir_all(d).ok()?;
|
||||
}
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.log)
|
||||
.ok()
|
||||
};
|
||||
match open() {
|
||||
Some(f) => match f.try_clone() {
|
||||
Ok(f2) => (Stdio::from(f), Stdio::from(f2)),
|
||||
Err(_) => (Stdio::from(f), Stdio::null()),
|
||||
},
|
||||
None => (Stdio::null(), Stdio::null()),
|
||||
}
|
||||
}
|
||||
|
||||
fn child_pid(&self) -> Option<u32> {
|
||||
self.inner.lock().unwrap().child.as_ref().map(|c| c.id())
|
||||
}
|
||||
|
||||
/// Poll the child until it exits (its exit code), or until this supervisor is replaced.
|
||||
fn wait_child(&self, generation: u64) -> Option<i32> {
|
||||
loop {
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
return None;
|
||||
}
|
||||
match g.child.as_mut().map(|c| c.try_wait()) {
|
||||
Some(Ok(Some(status))) => return status.code(),
|
||||
Some(Ok(None)) => {}
|
||||
Some(Err(_)) | None => return None,
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep, waking early when this supervisor is replaced.
|
||||
fn pause(&self, generation: u64, d: Duration) {
|
||||
let until = Instant::now() + d;
|
||||
while Instant::now() < until {
|
||||
if self.inner.lock().unwrap().generation != generation {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the agent, waiting for it to end its work (at most `STOP_GRACE`).
|
||||
pub fn stop(&self) {
|
||||
let child = {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
g.wanted = false;
|
||||
g.generation += 1;
|
||||
g.child.take()
|
||||
};
|
||||
if let Some(c) = child {
|
||||
terminate(c);
|
||||
}
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.state != State::Off {
|
||||
g.state = State::Off;
|
||||
drop(g);
|
||||
(self.notify)(&State::Off);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SIGTERM, then SIGKILL after the grace period. The service ends its own work in seconds.
|
||||
fn terminate(mut child: Child) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as libc::pid_t, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = child.kill();
|
||||
let until = Instant::now() + STOP_GRACE;
|
||||
while Instant::now() < until {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent
|
||||
//! running on this computer, so agent chats can work in its projects without a terminal.
|
||||
//!
|
||||
//! - **One window, `main`.** Before a server is chosen it shows the app's own first-run page
|
||||
//! (`ui/`); after, the server itself. Closing it hides it; the tray brings it back.
|
||||
//! - **The server's pages get no IPC.** They are remote content, and the one capability lists no
|
||||
//! remote URL. Links that leave the server open in the system browser.
|
||||
//! - **The agent is the CLI's**: `lembas login --json` links this computer, and
|
||||
//! `lembas service run` is the device (see `agent.rs`).
|
||||
|
||||
mod agent;
|
||||
mod cli;
|
||||
mod settings;
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||
};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use agent::{Agent, State as AgentState};
|
||||
use settings::Settings;
|
||||
|
||||
const MAIN: &str = "main";
|
||||
|
||||
struct App {
|
||||
config_dir: PathBuf,
|
||||
settings: Mutex<Settings>,
|
||||
agent: Agent,
|
||||
tray: Mutex<Option<TrayItems>>,
|
||||
linking: Mutex<bool>,
|
||||
}
|
||||
|
||||
struct TrayItems {
|
||||
status: MenuItem<Wry>,
|
||||
toggle: MenuItem<Wry>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn settings(&self) -> Settings {
|
||||
self.settings.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn update(&self, f: impl FnOnce(&mut Settings)) -> Result<Settings, String> {
|
||||
let mut s = self.settings.lock().unwrap();
|
||||
f(&mut s);
|
||||
settings::save(&self.config_dir, &s)
|
||||
.map_err(|e| format!("could not save the settings: {e}"))?;
|
||||
Ok(s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// What the first-run page shows.
|
||||
#[derive(Serialize)]
|
||||
struct Status {
|
||||
version: &'static str,
|
||||
server: Option<String>,
|
||||
linked: bool,
|
||||
agent: AgentState,
|
||||
agent_wanted: bool,
|
||||
}
|
||||
|
||||
fn status(app: &App) -> Status {
|
||||
let s = app.settings();
|
||||
Status {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
linked: s.server.as_deref().is_some_and(cli::linked_to),
|
||||
server: s.server,
|
||||
agent: app.agent.state(),
|
||||
agent_wanted: app.agent.wanted(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_status(app: tauri::State<'_, App>) -> Status {
|
||||
status(&app)
|
||||
}
|
||||
|
||||
/// Check that `address` is a LLeMbas server, remember it, and open it.
|
||||
#[tauri::command]
|
||||
async fn set_server(handle: AppHandle, address: String) -> Result<String, String> {
|
||||
let base = settings::normalise(&address)?;
|
||||
let probe = base.clone();
|
||||
let name = tauri::async_runtime::spawn_blocking(move || discover(&probe))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
let app = handle.state::<App>();
|
||||
let before = app.settings().server;
|
||||
app.update(|s| s.server = Some(base.clone()))?;
|
||||
if before.as_deref() != Some(base.as_str()) {
|
||||
app.agent.stop();
|
||||
}
|
||||
open_main(&handle, Some(&base))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// `/.well-known/lembas.json`: the same check `lembas login` makes first.
|
||||
fn discover(base: &str) -> Result<String, String> {
|
||||
let url = format!("{base}/.well-known/lembas.json");
|
||||
let res = ureq::get(&url)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.call()
|
||||
.map_err(|e| match e {
|
||||
ureq::Error::Status(404, _) => format!("{base} answers, but is not a LLeMbas server."),
|
||||
ureq::Error::Status(code, _) => format!("{base} answered {code}."),
|
||||
ureq::Error::Transport(t) => format!("Could not reach {base}: {t}"),
|
||||
})?;
|
||||
let body: serde_json::Value = res
|
||||
.into_json()
|
||||
.map_err(|_| format!("{base} is not a LLeMbas server."))?;
|
||||
if body.get("service").and_then(|s| s.as_str()) != Some("lembas") {
|
||||
return Err(format!("{base} is not a LLeMbas server."));
|
||||
}
|
||||
Ok(body
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("LLeMbas")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_agent(handle: AppHandle, on: bool) -> Result<(), String> {
|
||||
set_agent_wanted(&handle, on)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn link_computer(handle: AppHandle) -> Result<(), String> {
|
||||
start_link(&handle)
|
||||
}
|
||||
|
||||
fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
|
||||
let app = handle.state::<App>();
|
||||
let s = app.update(|s| s.agent = on)?;
|
||||
match (on, s.server) {
|
||||
(true, Some(server)) => app.agent.start(server),
|
||||
_ => app.agent.stop(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app's own first-run page, as the webview addresses it on this platform.
|
||||
fn local_page() -> Url {
|
||||
let base = if cfg!(windows) {
|
||||
"http://tauri.localhost/index.html"
|
||||
} else {
|
||||
"tauri://localhost/index.html"
|
||||
};
|
||||
Url::parse(base).expect("a fixed URL")
|
||||
}
|
||||
|
||||
/// Show the main window on `server`, or on the first-run page; made when it does not exist.
|
||||
fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||
let target = match server {
|
||||
Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?),
|
||||
None => None,
|
||||
};
|
||||
if let Some(w) = handle.get_webview_window(MAIN) {
|
||||
w.navigate(target.unwrap_or_else(local_page))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
return Ok(());
|
||||
}
|
||||
let url = match target {
|
||||
Some(u) => WebviewUrl::External(u),
|
||||
None => WebviewUrl::App("index.html".into()),
|
||||
};
|
||||
let nav = handle.clone();
|
||||
WebviewWindowBuilder::new(handle, MAIN, url)
|
||||
.title("LLeMbas")
|
||||
.inner_size(1280.0, 840.0)
|
||||
.min_inner_size(420.0, 560.0)
|
||||
.on_navigation(move |url| allowed(&nav, url))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window may show the app's own pages and the server; anything else opens in the browser.
|
||||
fn allowed(handle: &AppHandle, url: &Url) -> bool {
|
||||
let local = matches!(url.scheme(), "tauri" | "asset")
|
||||
|| url.host_str() == Some("tauri.localhost")
|
||||
|| url.scheme() == "about"
|
||||
|| url.scheme() == "data";
|
||||
if local {
|
||||
return true;
|
||||
}
|
||||
let server = handle.state::<App>().settings().server;
|
||||
let same = server
|
||||
.as_deref()
|
||||
.and_then(|s| Url::parse(s).ok())
|
||||
.is_some_and(|s| s.origin() == url.origin());
|
||||
if !same && matches!(url.scheme(), "http" | "https" | "mailto") {
|
||||
let _ = handle.opener().open_url(url.as_str(), None::<&str>);
|
||||
}
|
||||
same
|
||||
}
|
||||
|
||||
/// Link this computer: `lembas login <server> --json`, with the approval shown in the window
|
||||
/// (where the user is already signed in to the server), then the agent started.
|
||||
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||
let app = handle.state::<App>();
|
||||
let Some(server) = app.settings().server else {
|
||||
return Err("Choose a server first.".into());
|
||||
};
|
||||
{
|
||||
let mut busy = app.linking.lock().unwrap();
|
||||
if *busy {
|
||||
return Err("Linking is already in progress.".into());
|
||||
}
|
||||
*busy = true;
|
||||
}
|
||||
let mut child = match Command::new(cli::binary())
|
||||
.arg("login")
|
||||
.arg(&server)
|
||||
.arg("--json")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
*app.linking.lock().unwrap() = false;
|
||||
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
||||
}
|
||||
};
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut failure: Option<String> = None;
|
||||
let mut done = false;
|
||||
if let Some(out) = child.stdout.take() {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
let Ok(ev) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
match ev.get("event").and_then(|e| e.as_str()) {
|
||||
Some("code") => {
|
||||
let link = ev
|
||||
.get("verification_uri_complete")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if let Ok(url) = Url::parse(link) {
|
||||
if let Some(w) = handle.get_webview_window(MAIN) {
|
||||
let _ = w.navigate(url);
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("done") => done = true,
|
||||
Some("error") => {
|
||||
failure = ev.get("message").and_then(|m| m.as_str()).map(String::from)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let status = child.wait();
|
||||
if !done && failure.is_none() {
|
||||
let mut err = String::new();
|
||||
if let Some(mut e) = child.stderr.take() {
|
||||
let _ = std::io::Read::read_to_string(&mut e, &mut err);
|
||||
}
|
||||
failure = Some(match status {
|
||||
Ok(s) if !err.trim().is_empty() => format!("{} ({s})", err.trim()),
|
||||
Ok(s) => format!("lembas login ended without linking ({s})"),
|
||||
Err(e) => e.to_string(),
|
||||
});
|
||||
}
|
||||
let app = handle.state::<App>();
|
||||
*app.linking.lock().unwrap() = false;
|
||||
match failure {
|
||||
None => {
|
||||
let _ = open_main(&handle, Some(&server));
|
||||
let _ = set_agent_wanted(&handle, true);
|
||||
}
|
||||
Some(why) => {
|
||||
handle
|
||||
.dialog()
|
||||
.message(why)
|
||||
.title("LLeMbas: linking failed")
|
||||
.kind(MessageDialogKind::Error)
|
||||
.show(|_| {});
|
||||
}
|
||||
}
|
||||
let _ = handle.emit("status", status_of(&handle));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_of(handle: &AppHandle) -> Status {
|
||||
status(&handle.state::<App>())
|
||||
}
|
||||
|
||||
fn show(handle: &AppHandle) {
|
||||
let server = handle.state::<App>().settings().server;
|
||||
if let Some(w) = handle.get_webview_window(MAIN) {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
} else {
|
||||
let _ = open_main(handle, server.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
|
||||
let open = MenuItem::with_id(handle, "open", "Open LLeMbas", true, None::<&str>)?;
|
||||
let status = MenuItem::with_id(
|
||||
handle,
|
||||
"status",
|
||||
AgentState::Off.label(),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle = MenuItem::with_id(handle, "toggle", "Start the agent", true, None::<&str>)?;
|
||||
let link = MenuItem::with_id(handle, "link", "Link this computer…", true, None::<&str>)?;
|
||||
let server = MenuItem::with_id(handle, "server", "Change server…", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(
|
||||
handle,
|
||||
&[
|
||||
&open,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&status,
|
||||
&toggle,
|
||||
&link,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&server,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
let mut tray = TrayIconBuilder::with_id("tray")
|
||||
.tooltip("LLeMbas")
|
||||
.menu(&menu)
|
||||
.on_menu_event(|h, ev| match ev.id().as_ref() {
|
||||
"open" => show(h),
|
||||
"toggle" => {
|
||||
let on = !h.state::<App>().agent.wanted();
|
||||
if let Err(e) = set_agent_wanted(h, on) {
|
||||
h.dialog()
|
||||
.message(e)
|
||||
.kind(MessageDialogKind::Error)
|
||||
.show(|_| {});
|
||||
}
|
||||
}
|
||||
"link" => {
|
||||
if let Err(e) = start_link(h) {
|
||||
h.dialog()
|
||||
.message(e)
|
||||
.kind(MessageDialogKind::Warning)
|
||||
.show(|_| {});
|
||||
}
|
||||
}
|
||||
"server" => {
|
||||
let _ = open_main(h, None);
|
||||
}
|
||||
"quit" => {
|
||||
h.state::<App>().agent.stop();
|
||||
h.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if let Some(icon) = handle.default_window_icon() {
|
||||
tray = tray.icon(icon.clone());
|
||||
}
|
||||
tray.build(handle)?;
|
||||
Ok(TrayItems { status, toggle })
|
||||
}
|
||||
|
||||
fn on_agent_state(handle: &AppHandle, state: &AgentState) {
|
||||
let app = handle.state::<App>();
|
||||
if let Some(t) = app.tray.lock().unwrap().as_ref() {
|
||||
let _ = t.status.set_text(state.label());
|
||||
let _ = t.toggle.set_text(if app.agent.wanted() {
|
||||
"Stop the agent"
|
||||
} else {
|
||||
"Start the agent"
|
||||
});
|
||||
}
|
||||
let _ = handle.emit("status", status_of(handle));
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h)))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_status,
|
||||
set_server,
|
||||
set_agent,
|
||||
link_computer
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
let config_dir = app.path().app_config_dir()?;
|
||||
let log = app.path().app_log_dir()?.join("agent.log");
|
||||
let notify_handle = handle.clone();
|
||||
let agent = Agent::new(log, move |s| {
|
||||
// The notifier runs on the agent's thread; the tray is touched on the main one.
|
||||
let h = notify_handle.clone();
|
||||
let s = s.clone();
|
||||
let _ = notify_handle.run_on_main_thread(move || on_agent_state(&h, &s));
|
||||
});
|
||||
let loaded = settings::load(&config_dir);
|
||||
app.manage(App {
|
||||
config_dir,
|
||||
settings: Mutex::new(loaded.clone()),
|
||||
agent,
|
||||
tray: Mutex::new(None),
|
||||
linking: Mutex::new(false),
|
||||
});
|
||||
let items = build_tray(&handle)?;
|
||||
*handle.state::<App>().tray.lock().unwrap() = Some(items);
|
||||
open_main(&handle, loaded.server.as_deref())?;
|
||||
if let (true, Some(server)) = (loaded.agent, loaded.server) {
|
||||
handle.state::<App>().agent.start(server);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Closing the window keeps the app (and the agent) in the tray.
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == MAIN {
|
||||
let _ = window.hide();
|
||||
api.prevent_close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("LLeMbas Desktop could not start")
|
||||
.run(|handle, event| {
|
||||
if let RunEvent::Exit = event {
|
||||
handle.state::<App>().agent.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// No console window behind the app on Windows, in a release build.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
llembas_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! What the app remembers: which server it opens, and whether the agent should run. One small
|
||||
//! JSON file in the app's config directory; nothing secret is in it (the device's key is the
|
||||
//! LLeMbas CLI's, in its own config).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// The server's base address, `https://host[:port]`, with no trailing slash.
|
||||
pub server: Option<String>,
|
||||
/// Run the agent while the app runs (once this computer is linked to the server).
|
||||
pub agent: bool,
|
||||
}
|
||||
|
||||
pub fn file(config_dir: &Path) -> PathBuf {
|
||||
config_dir.join("settings.json")
|
||||
}
|
||||
|
||||
pub fn load(config_dir: &Path) -> Settings {
|
||||
fs::read_to_string(file(config_dir))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(config_dir: &Path, s: &Settings) -> std::io::Result<()> {
|
||||
fs::create_dir_all(config_dir)?;
|
||||
let tmp = config_dir.join("settings.json.tmp");
|
||||
fs::write(&tmp, serde_json::to_string_pretty(s)? + "\n")?;
|
||||
fs::rename(tmp, file(config_dir))
|
||||
}
|
||||
|
||||
/// An address as typed (`ai.example`, `https://ai.example/`, `http://10.1.2.3:8080/chat`)
|
||||
/// as a base: https unless it says http, scheme and host and port only. The same rule as
|
||||
/// `lembas login`, so both name the instance alike.
|
||||
pub fn normalise(address: &str) -> Result<String, String> {
|
||||
let a = address.trim();
|
||||
if a.is_empty() {
|
||||
return Err("Enter the address of your LLeMbas server.".into());
|
||||
}
|
||||
let with_scheme = if a.contains("://") {
|
||||
a.to_string()
|
||||
} else {
|
||||
format!("https://{a}")
|
||||
};
|
||||
let url = tauri::Url::parse(&with_scheme).map_err(|e| format!("Not an address: {e}"))?;
|
||||
if url.scheme() != "https" && url.scheme() != "http" {
|
||||
return Err("The address must start with https:// or http://.".into());
|
||||
}
|
||||
let host = url.host_str().ok_or("The address has no host.")?;
|
||||
Ok(match url.port() {
|
||||
Some(p) => format!("{}://{}:{}", url.scheme(), host, p),
|
||||
None => format!("{}://{}", url.scheme(), host),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_address_becomes_a_base() {
|
||||
assert_eq!(normalise("ai.example").unwrap(), "https://ai.example");
|
||||
assert_eq!(
|
||||
normalise(" https://ai.example/ ").unwrap(),
|
||||
"https://ai.example"
|
||||
);
|
||||
assert_eq!(
|
||||
normalise("http://10.1.2.3:8080/chat?x=1").unwrap(),
|
||||
"http://10.1.2.3:8080"
|
||||
);
|
||||
assert!(normalise("").is_err());
|
||||
assert!(normalise("ftp://ai.example").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_and_survive_a_missing_file() {
|
||||
let dir = std::env::temp_dir().join(format!("llembas-desktop-test-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
assert_eq!(load(&dir), Settings::default());
|
||||
let s = Settings {
|
||||
server: Some("https://ai.example".into()),
|
||||
agent: true,
|
||||
};
|
||||
save(&dir, &s).unwrap();
|
||||
assert_eq!(load(&dir), s);
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user