The agent's status is the CLI's word, not whether a process runs
Desktop said "running" as soon as `lembas service run` was started, while the CLI was still dialling, waiting for a terminal that holds the link, or refused by the server -- the reason went only to agent.log. Now the supervisor reads what the CLI itself writes: `service.json` in its state directory (connecting, linked, waiting + why, stopped + why) for the pid it started, the line it printed before that, and its last line when it ends. A service the app did not start is described the same way. A run the CLI ends with 0 because trying again will not help (a refused token, no login) is no longer restarted every second: it waits five minutes, or until linking again starts it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+322
-19
No files matched your search
+228
-12
@@ -4,6 +4,12 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! What it shows is the CLI's own word: a process that runs is not an agent that is linked. While
|
||||
//! it runs, the state is what the service writes to its `service.json` (connecting, linked,
|
||||
//! waiting and why, stopped and why); before that, the line it printed; when it ends, its last
|
||||
//! line. A service the CLI says cannot go on (a refused token, no login) is not restarted every
|
||||
//! second: it waits until linking again starts it.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::PathBuf;
|
||||
@@ -20,6 +26,8 @@ use crate::cli;
|
||||
const RESTART_FOR_UPDATE: i32 = 75;
|
||||
const BACKOFF: Duration = Duration::from_secs(10);
|
||||
const STOP_GRACE: Duration = Duration::from_secs(15);
|
||||
/// How long to wait before trying again after the CLI said trying again will not help.
|
||||
const REFUSED_WAIT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
|
||||
@@ -28,25 +36,83 @@ pub enum State {
|
||||
Off,
|
||||
/// This computer is not signed in to the server yet.
|
||||
NotLinked,
|
||||
/// The process is started; the CLI has not said anything of its link yet.
|
||||
Starting,
|
||||
/// The CLI is dialling the server.
|
||||
Connecting,
|
||||
/// The CLI says it is linked: agent chats can use this computer.
|
||||
Running,
|
||||
/// Another `lembas service` on this computer holds the link (its pid).
|
||||
Elsewhere(u32),
|
||||
/// It ended with an error; trying again shortly.
|
||||
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
||||
/// the server cannot be reached, the link dropped).
|
||||
Waiting(String),
|
||||
/// The CLI will not try again by itself: the server refused this computer's token, or the
|
||||
/// login is missing. Linking again is the way out.
|
||||
Refused(String),
|
||||
/// Another `lembas service` on this computer holds the link.
|
||||
Elsewhere(Elsewhere),
|
||||
/// It ended with an error; trying again shortly. The CLI's last words, where it said any.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A `lembas service` this app did not start, and what it says of its link.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Elsewhere {
|
||||
pub pid: u32,
|
||||
/// It says it is linked.
|
||||
pub linked: bool,
|
||||
/// Why not, when it is not (its own words; empty while it has said nothing).
|
||||
pub why: 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::Connecting => "Agent: connecting…".into(),
|
||||
State::Running => "Agent: running".into(),
|
||||
State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"),
|
||||
State::Waiting(why) => format!("Agent: waiting — {why}"),
|
||||
State::Refused(why) => format!("Agent: refused — {why}"),
|
||||
State::Elsewhere(e) if e.linked => {
|
||||
format!("Agent: running as a service (pid {})", e.pid)
|
||||
}
|
||||
State::Elsewhere(e) => format!("Agent: a service (pid {}) is not linked", e.pid),
|
||||
State::Failed(why) => format!("Agent: stopped — {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a state the CLI's own status file decides, while the process runs.
|
||||
fn from_cli(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
State::Starting | State::Connecting | State::Running | State::Waiting(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the CLI's status says, as a state of the app's. `before`: the state shown now, so that a
|
||||
/// new dial after a failed one keeps the reason it is waiting (the CLI writes `connecting` at
|
||||
/// every try, `waiting` between them) instead of flickering between the two.
|
||||
pub fn from_link(link: &cli::Link, before: &State) -> State {
|
||||
let why = link.detail.clone().unwrap_or_default();
|
||||
match link.state.as_str() {
|
||||
"linked" => State::Running,
|
||||
"connecting" => match before {
|
||||
State::Waiting(w) if link.attempts > 1 => State::Waiting(w.clone()),
|
||||
_ => State::Connecting,
|
||||
},
|
||||
"waiting" => State::Waiting(why),
|
||||
"stopped" => State::Refused(why),
|
||||
_ => State::Starting,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a running process has said before its status file says anything: the one line the
|
||||
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
|
||||
fn from_output(line: &str) -> Option<State> {
|
||||
line.contains("holds the link")
|
||||
.then(|| State::Waiting(line.trim().to_string()))
|
||||
}
|
||||
|
||||
type Notify = Arc<dyn Fn(&State) + Send + Sync>;
|
||||
@@ -124,15 +190,33 @@ impl Agent {
|
||||
continue;
|
||||
}
|
||||
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
|
||||
if !self.set(generation, State::Elsewhere(pid)) {
|
||||
// Not ours, but this computer's agent all the same: say what it says.
|
||||
let link = cli::link_of(pid);
|
||||
let linked = link.as_ref().is_some_and(|l| l.state == "linked");
|
||||
let why = link
|
||||
.map(|l| match l.detail {
|
||||
Some(d) if !d.is_empty() => d,
|
||||
_ => l.state,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !self.set(
|
||||
generation,
|
||||
State::Elsewhere(Elsewhere {
|
||||
pid,
|
||||
linked,
|
||||
why: if linked { String::new() } else { why },
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, BACKOFF);
|
||||
self.pause(generation, Duration::from_secs(3));
|
||||
continue;
|
||||
}
|
||||
if !self.set(generation, State::Starting) {
|
||||
return;
|
||||
}
|
||||
// Where this run's output starts in the log: its last line is its reason.
|
||||
let from = std::fs::metadata(&self.log).map(|m| m.len()).unwrap_or(0);
|
||||
let child = match self.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -146,6 +230,7 @@ impl Agent {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pid = child.id();
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
@@ -155,23 +240,39 @@ impl Agent {
|
||||
}
|
||||
g.child = Some(child);
|
||||
}
|
||||
self.set(generation, State::Running);
|
||||
let code = self.wait_child(generation);
|
||||
let code = self.wait_child(generation, pid, from);
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.generation != generation {
|
||||
return;
|
||||
}
|
||||
g.child = None;
|
||||
drop(g);
|
||||
let said = last_line(&self.log, from);
|
||||
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));
|
||||
// It ended by itself without an error: the CLI's word for "starting again
|
||||
// will not help" -- the server refused the token, or there is no login.
|
||||
// Waiting a long while (linking again starts it at once).
|
||||
let why = cli::link_of(pid)
|
||||
.filter(|l| l.state == "stopped")
|
||||
.and_then(|l| l.detail)
|
||||
.or_else(|| said.map(|s| strip_prefix(&s)));
|
||||
match why {
|
||||
Some(why) => {
|
||||
if !self.set(generation, State::Refused(why)) {
|
||||
return;
|
||||
}
|
||||
self.pause(generation, REFUSED_WAIT);
|
||||
}
|
||||
None => self.pause(generation, Duration::from_secs(1)),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let why = other.map_or("ended by a signal".to_string(), |c| {
|
||||
let why = said.map(|s| strip_prefix(&s)).unwrap_or_else(|| {
|
||||
other.map_or("ended by a signal".to_string(), |c| {
|
||||
format!("exit status {c}")
|
||||
})
|
||||
});
|
||||
if !self.set(generation, State::Failed(why)) {
|
||||
return;
|
||||
@@ -217,7 +318,11 @@ impl Agent {
|
||||
}
|
||||
|
||||
/// Poll the child until it exits (its exit code), or until this supervisor is replaced.
|
||||
fn wait_child(&self, generation: u64) -> Option<i32> {
|
||||
/// Meanwhile the state shown is what the CLI says of its link: its status file once it has
|
||||
/// written one, before that the line it printed (a terminal holding the link).
|
||||
fn wait_child(&self, generation: u64, pid: u32, from: u64) -> Option<i32> {
|
||||
// Every other poll (once a second) the status is read again.
|
||||
let mut read = true;
|
||||
loop {
|
||||
{
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
@@ -230,6 +335,21 @@ impl Agent {
|
||||
Some(Err(_)) | None => return None,
|
||||
}
|
||||
}
|
||||
if read {
|
||||
let now = self.state();
|
||||
if now.from_cli() {
|
||||
let next = match cli::link_of(pid) {
|
||||
Some(link) => from_link(&link, &now),
|
||||
None => last_line(&self.log, from)
|
||||
.and_then(|l| from_output(&l))
|
||||
.unwrap_or(State::Starting),
|
||||
};
|
||||
if !self.set(generation, next) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
read = !read;
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
@@ -283,3 +403,99 @@ fn terminate(mut child: Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// The last line the log holds after `from` (this run's output), if it wrote any. Only the end of
|
||||
/// the file is read.
|
||||
fn last_line(log: &std::path::Path, from: u64) -> Option<String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = File::open(log).ok()?;
|
||||
let len = f.metadata().ok()?.len();
|
||||
let start = from.max(len.saturating_sub(4096));
|
||||
if start >= len {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(start)).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
f.take(len - start).read_to_end(&mut buf).ok()?;
|
||||
String::from_utf8_lossy(&buf)
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// `lembas service: not logged in` → `not logged in`: the CLI's error line without its name.
|
||||
fn strip_prefix(line: &str) -> String {
|
||||
line.strip_prefix("lembas service: ")
|
||||
.unwrap_or(line)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
|
||||
cli::Link {
|
||||
state: state.into(),
|
||||
detail: detail.map(String::from),
|
||||
pid: 1,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_shown_is_the_clis_word_for_its_link() {
|
||||
let s = State::Starting;
|
||||
assert_eq!(from_link(&link("linked", None, 1), &s), State::Running);
|
||||
assert_eq!(
|
||||
from_link(&link("connecting", None, 1), &s),
|
||||
State::Connecting
|
||||
);
|
||||
let waiting = from_link(
|
||||
&link("waiting", Some("could not reach the instance"), 1),
|
||||
&s,
|
||||
);
|
||||
assert_eq!(
|
||||
waiting,
|
||||
State::Waiting("could not reach the instance".into())
|
||||
);
|
||||
// The next dial keeps the reason it is waiting, rather than flicker.
|
||||
assert_eq!(from_link(&link("connecting", None, 2), &waiting), waiting);
|
||||
assert_eq!(
|
||||
from_link(&link("stopped", Some("refused"), 2), &waiting),
|
||||
State::Refused("refused".into())
|
||||
);
|
||||
// Not running is never shown as running.
|
||||
for state in ["connecting", "waiting", "stopped", "?"] {
|
||||
assert_ne!(from_link(&link(state, None, 1), &s), State::Running);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_holding_the_link_is_said() {
|
||||
assert_eq!(
|
||||
from_output("a terminal here holds the link (/remote); waiting for it to close"),
|
||||
Some(State::Waiting(
|
||||
"a terminal here holds the link (/remote); waiting for it to close".into()
|
||||
))
|
||||
);
|
||||
assert_eq!(from_output("linked"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_line_is_this_runs_own() {
|
||||
let p = std::env::temp_dir().join(format!("llembas-agent-log-{}", std::process::id()));
|
||||
std::fs::write(&p, "old run\nlinked\n").unwrap();
|
||||
let from = std::fs::metadata(&p).unwrap().len();
|
||||
assert_eq!(last_line(&p, from), None);
|
||||
let mut f = OpenOptions::new().append(true).open(&p).unwrap();
|
||||
std::io::Write::write_all(&mut f, b"connecting\nlembas service: not logged in\n\n")
|
||||
.unwrap();
|
||||
let said = last_line(&p, from).unwrap();
|
||||
assert_eq!(said, "lembas service: not logged in");
|
||||
assert_eq!(strip_prefix(&said), "not logged in");
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
|
||||
@@ -108,6 +110,32 @@ fn running_in(state: &Path) -> Option<u32> {
|
||||
(pid != 0 && alive(pid)).then_some(pid)
|
||||
}
|
||||
|
||||
/// What a running `lembas service run` says of its link to the server, as it writes it to
|
||||
/// `service.json` in its state directory at every change: `connecting`, `linked`, `waiting`
|
||||
/// (not linked now, trying again; `detail` says why) or `stopped` (it will not try again;
|
||||
/// `detail` says why -- the server refused the token, or speaks another protocol). `pid` is the
|
||||
/// process that wrote it: a file left by an earlier run is not this one's word.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Link {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pid: u32,
|
||||
#[serde(default)]
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
/// The link status the CLI's service wrote last, if it was `pid` that wrote it.
|
||||
pub fn link_of(pid: u32) -> Option<Link> {
|
||||
link_in(&state_dir()).filter(|l| l.pid == pid)
|
||||
}
|
||||
|
||||
fn link_in(state: &Path) -> Option<Link> {
|
||||
let text = std::fs::read_to_string(state.join("service.json")).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -166,6 +194,33 @@ mod tests {
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_status_is_read_as_the_service_writes_it() {
|
||||
let d = scratch("link");
|
||||
assert_eq!(link_in(&d), None);
|
||||
// As `lembas service run` writes it (src/service.ts): LinkStatus and its pid.
|
||||
fs::write(
|
||||
d.join("service.json"),
|
||||
r#"{"state":"waiting","instance":"https://ai.example","since":"2026-01-01T00:00:00.000Z","detail":"could not reach the instance; trying again in 4s","attempts":3,"pid":4242}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let l = link_in(&d).unwrap();
|
||||
assert_eq!(l.state, "waiting");
|
||||
assert_eq!(l.pid, 4242);
|
||||
assert_eq!(l.attempts, 3);
|
||||
assert!(l.detail.unwrap().starts_with("could not reach"));
|
||||
// `linked` carries no detail.
|
||||
fs::write(
|
||||
d.join("service.json"),
|
||||
r#"{"state":"linked","instance":"x","since":"y","attempts":1,"pid":7}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(link_in(&d).unwrap().detail, None);
|
||||
fs::write(d.join("service.json"), "not json").unwrap();
|
||||
assert_eq!(link_in(&d), None);
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_counts_only_while_its_process_lives() {
|
||||
|
||||
+13
-3
@@ -9,8 +9,10 @@ const AGENT = {
|
||||
off: () => [t("The agent is off."), ""],
|
||||
not_linked: () => [t("This computer is not linked to the server yet."), ""],
|
||||
starting: () => [t("The agent is starting…"), ""],
|
||||
connecting: () => [t("The agent is connecting to the server…"), ""],
|
||||
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
|
||||
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"],
|
||||
waiting: (why) => [t("The agent is not connected: %(why)s. It keeps trying.", { why }), "is-error"],
|
||||
refused: (why) => [t("The agent will not connect: %(why)s. Link this computer again.", { why }), "is-error"],
|
||||
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
|
||||
}
|
||||
|
||||
@@ -18,10 +20,18 @@ let folderError = ""
|
||||
let linking = null // { user_code, uri } while the computer is being linked
|
||||
let linkError = ""
|
||||
|
||||
/** The agent's state in a sentence, and its class: the CLI's word for its link. */
|
||||
export function agentWords(s) {
|
||||
const a = s.agent
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
||||
return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
|
||||
if (a.state === "elsewhere") {
|
||||
const pid = a.detail?.pid
|
||||
if (!a.detail?.linked) return [t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }), "is-error"]
|
||||
if (noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
||||
return [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"]
|
||||
}
|
||||
if (a.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
||||
return (AGENT[a.state] ?? AGENT.off)(a.detail || "…")
|
||||
}
|
||||
|
||||
function section(id, title, ...body) {
|
||||
|
||||
+26
-4
@@ -9,12 +9,35 @@ const WORDS = {
|
||||
off: () => t("agent off"),
|
||||
not_linked: () => t("not linked"),
|
||||
starting: () => t("agent starting…"),
|
||||
connecting: () => t("agent connecting…"),
|
||||
running: () => t("agent running"),
|
||||
waiting: () => t("agent waiting"),
|
||||
refused: () => t("agent refused"),
|
||||
elsewhere: () => t("agent running (service)"),
|
||||
elsewhere_waiting: () => t("agent waiting"),
|
||||
failed: () => t("agent stopped"),
|
||||
no_folders: () => t("agent: no folders"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The pill's state: what the CLI says of its link, not whether a process runs. Linked but allowed
|
||||
* into no folder is said too -- a green light would say it works when it cannot.
|
||||
*/
|
||||
export function pillState(s) {
|
||||
const a = s.agent
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
|
||||
if (linked && noFolders) return "no_folders"
|
||||
if (a.state === "elsewhere" && !a.detail?.linked) return "elsewhere_waiting"
|
||||
return a.state
|
||||
}
|
||||
|
||||
/** Why the agent is not working, in the CLI's words, when it said. */
|
||||
export function agentWhy(a) {
|
||||
if (a.state === "elsewhere") return a.detail?.why || ""
|
||||
return typeof a.detail === "string" ? a.detail : ""
|
||||
}
|
||||
|
||||
export function renderBar(s) {
|
||||
let host = ""
|
||||
try {
|
||||
@@ -22,12 +45,11 @@ export function renderBar(s) {
|
||||
} catch {}
|
||||
$("host").textContent = host
|
||||
$("host").title = s.server || ""
|
||||
// Connected but allowed into no folder: a green light would say it works when it cannot.
|
||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
||||
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
|
||||
const state = pillState(s)
|
||||
const reason = agentWhy(s.agent)
|
||||
$("agent").dataset.state = state
|
||||
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
|
||||
$("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent")
|
||||
$("agent").title = reason ? t("Agent: %(why)s", { why: reason }) : t("Agent")
|
||||
$("update").hidden = !s.update
|
||||
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
|
||||
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
|
||||
|
||||
Reference in new issue
Block a user