From 5c4831c6701c7c563aee24b292064f3398ba8a6d Mon Sep 17 00:00:00 2001
From: Vladimir nett00n Budylnikov
Date: Tue, 22 Sep 2026 17:31:06 +0400
Subject: [PATCH 1/2] read only system tray for linux
---
.github/workflows/ci.yml | 24 +-
.gitignore | 3 +
README.md | 37 +-
console/Cargo.lock | 2 +
console/Cargo.toml | 13 +
console/src/bin/jobs.rs | 51 +-
console/src/bin/setup.rs | 8 +-
console/src/bin/tray.rs | 1052 +++++++++++-----------
console/src/{jobs.rs => jobs/launchd.rs} | 474 +---------
console/src/jobs/mod.rs | 632 +++++++++++++
console/src/jobs/systemd.rs | 618 +++++++++++++
console/src/jobs/unsupported.rs | 31 +
console/src/lib.rs | 1 +
console/src/view.rs | 159 ++++
docs/DIAGNOSTICS.md | 32 +-
docs/INSTALL.md | 38 +-
16 files changed, 2132 insertions(+), 1043 deletions(-)
rename console/src/{jobs.rs => jobs/launchd.rs} (58%)
create mode 100644 console/src/jobs/mod.rs
create mode 100644 console/src/jobs/systemd.rs
create mode 100644 console/src/jobs/unsupported.rs
create mode 100644 console/src/view.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1e1a291..57c5016 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,15 +69,31 @@ jobs:
# because it shares the toolchain, and a console that does not compile is a console nobody
# notices is broken until they try to open it.
#
- # On this runner that means the data layer and the four command-line tools -- the tray and
- # its two dependencies are declared for macOS only, since the schedules it reads are
- # launchd's. Everything the tests cover is in the part that builds here.
- - name: Build the console (data layer + CLI tools; the tray is macOS-only)
+ # On this runner that means the data layer and the four command-line tools with the default
+ # feature set: the tray's GUI dependencies are declared for macOS unconditionally, and for
+ # Linux only behind the `tray` feature (off by default), specifically so this step needs no
+ # GUI library at all. Everything the tests cover here is in the part that builds without it.
+ - name: Build the console (data layer + CLI tools; no GUI libraries needed)
working-directory: console
run: cargo build --release --locked
- name: Console unit tests
working-directory: console
run: cargo test --locked
+ # The Linux tray, behind its feature: exercises the `tray-icon` + GTK path this runner does
+ # not otherwise touch. Its own dependencies need real headers, hence the apt step -- and
+ # that step is confined to here so the two steps above stay proof that nothing else in this
+ # crate needs them.
+ - name: Build the Linux tray (GTK + appindicator)
+ working-directory: console
+ run: |
+ sudo apt-get update
+ # libxdo-dev is easy to miss: tray-icon's menu-accelerator handling links -lxdo, and the
+ # link fails without it even though nothing above mentions xdo by name.
+ sudo apt-get install -y libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
+ cargo build --release --locked --features tray
+ - name: Linux tray unit tests
+ working-directory: console
+ run: cargo test --locked --features tray
schema:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index cee52d6..0831501 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,8 @@
# Rust build artifacts
mcp-server/target/
+# CARGO_TARGET_DIR for the console crate, when set to the repo root's build/ (keeps target output
+# out of an immutable-OS host's toolbox checkout and in one place regardless of which crate built).
+/build/
# Python
__pycache__/
diff --git a/README.md b/README.md
index 463ec7b..0220b06 100644
--- a/README.md
+++ b/README.md
@@ -270,10 +270,14 @@ holds, whether the scheduled passes are running, and what the tunables are set t
writing a query.
```bash
-cd console && cargo build --release # two dependencies, both only for the tray
+cd console && cargo build --release # the CLI tools; zero dependencies
./target/release/hypermnesia-setup # the walkthrough: from nothing to a menu-bar icon
```
+The tray itself needs a GUI toolkit, so it is not part of that plain build: unconditional on
+macOS, and on Linux behind `cargo build --release --features tray` (GTK3 +
+libayatana-appindicator, see [docs/INSTALL.md](docs/INSTALL.md)).
+
It reaches the database exactly one way: a command that receives SQL on stdin. Direct psql,
`docker exec`, `kubectl exec`, ssh to a machine that has kubectl — all of them are one string with
different contents, which is why there is one setting and not five. The wizard tries the command
@@ -282,7 +286,7 @@ cannot be told from an empty store.
| Command | What |
|---------|------|
-| `hypermnesia` | the menu-bar tray (macOS) |
+| `hypermnesia` | the menu-bar tray (macOS) / system tray (Linux, `--features tray`; jobs are read-only there so far) |
| `hypermnesia-stats` | the same numbers on stdout |
| `hypermnesia-jobs` | scheduled passes: what is configured, when each last worked, run one now, change a schedule |
| `hypermnesia-settings` | the tunables, each with the value in force and where that value came from |
@@ -295,9 +299,9 @@ cannot be told from an empty store.
A menu, not a window. Everything the console has to show is a dozen lines and a dozen buttons; a
-window would mean a GUI framework for the same result. Two dependencies, both macOS-only, and the
-data layer under them has none at all — a console that takes a minute to build is a console nobody
-rebuilds.
+window would mean a GUI framework for the same result. The tray's GUI dependencies are declared
+per platform and behind a feature on Linux, and the data layer under them has none at all — a
+console that takes a minute to build is a console nobody rebuilds.
What is in the menu:
@@ -308,20 +312,23 @@ What is in the menu:
chunks have no embedding, how many embedding models are in the store, the review queue, the
stale count, the database size.
- **Run now** — every scheduled pass, with its schedule, when it last wrote to its log, and its
- last exit code. Pressing one runs it through launchd and reports what launchd then did, not that
- the request was accepted.
-- **Schedule** — the common intervals and times, per job. It edits the plist, validates it, and
- reloads the job, because launchd keeps its own copy from the moment it loaded it: writing the
- file without the reload would show a new schedule while the old one is in force.
+ last exit code. On macOS, pressing one runs it through launchd and reports what launchd then
+ did, not that the request was accepted. On Linux this is read-only so far: the row shows the
+ same information, read from `systemctl --user show`, but pressing it reports "not implemented".
+- **Schedule** — the common intervals and times, per job. On macOS it edits the plist, validates
+ it, and reloads the job, because launchd keeps its own copy from the moment it loaded it: writing
+ the file without the reload would show a new schedule while the old one is in force. Not
+ implemented on Linux yet.
- **Refresh now** and **Quit**.
-The menu-bar title carries one mark, `HM !`, and it is derived from the lines below rather than
-computed beside them: anything the menu would show with a `!` puts the mark in the title. That is
-the whole design in one detail — the icon is where a problem is noticed, so the icon must not be
-able to disagree with the menu.
+The tray carries one mark for "something here needs a look", derived from the lines below rather
+than computed beside them: anything the menu would show with a `!` raises it. That is the whole
+design in one detail — the mark is where a problem is noticed, so it must not be able to disagree
+with the menu. On macOS the mark is in the menu-bar title, `HM !`; a system tray icon has no text
+of its own, so on Linux it is the icon itself, calm green or warned red.
`hypermnesia --install` puts it in launchd to start at login, restarted if it crashes and not if
-you quit it. `--uninstall` takes it back out.
+you quit it, on macOS. `--uninstall` takes it back out. Not implemented on Linux yet.
**Try it without a database.** The console's only connection setting is a command that prints the
query's answer, so a file works:
diff --git a/console/Cargo.lock b/console/Cargo.lock
index 50d2bdd..de79410 100644
--- a/console/Cargo.lock
+++ b/console/Cargo.lock
@@ -808,6 +808,8 @@ checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
name = "hypermnesia-console"
version = "0.1.0"
dependencies = [
+ "glib",
+ "gtk",
"tray-icon",
"winit",
]
diff --git a/console/Cargo.toml b/console/Cargo.toml
index e765a9a..9dcd29d 100644
--- a/console/Cargo.toml
+++ b/console/Cargo.toml
@@ -19,6 +19,19 @@ license = "MIT"
tray-icon = "0.25"
winit = "0.30"
+# The Linux tray, behind a feature and off by default -- for the same reason the macOS deps are
+# per-target: the data layer and the four command-line tools must keep building on a machine with
+# no GUI libraries at all, which is what CI's bare runner is. `tray-icon` drives the same crate as
+# macOS, through `libayatana-appindicator`; on Linux it needs a GTK main loop rather than winit's,
+# so `gtk` is a dependency here and `winit` is not.
+[target.'cfg(target_os = "linux")'.dependencies]
+tray-icon = { version = "0.25", optional = true }
+gtk = { version = "0.18", optional = true }
+glib = { version = "0.18", optional = true }
+
+[features]
+tray = ["dep:tray-icon", "dep:gtk", "dep:glib"]
+
[[bin]]
name = "hypermnesia-stats"
path = "src/bin/stats.rs"
diff --git a/console/src/bin/jobs.rs b/console/src/bin/jobs.rs
index 629d5c5..108a65b 100644
--- a/console/src/bin/jobs.rs
+++ b/console/src/bin/jobs.rs
@@ -11,15 +11,15 @@ use hypermnesia_console::jobs::{self, Job};
fn main() {
let args: Vec = std::env::args().skip(1).collect();
// Through jobs::job_prefix rather than the variable: only that path also consults the
- // config file, and the tray -- started by launchd with a minimal environment -- has
- // nothing else to read.
+ // config file, and the tray -- started by the service manager with a minimal environment --
+ // has nothing else to read.
let prefix = jobs::job_prefix();
let all = match jobs::list(&prefix) {
Ok(j) => j,
Err(e) => fail(&e),
};
if all.is_empty() {
- fail(&format!("no job with the prefix {prefix} found in ~/Library/LaunchAgents"));
+ fail(&format!("no job with the prefix {prefix} found in {}", jobs::UNITS_LOCATION));
}
match args.first().map(String::as_str) {
@@ -160,9 +160,10 @@ fn parse_weekday(s: &str) -> Option {
fn find<'a>(all: &'a [Job], name: &str) -> &'a Job {
match all.iter().find(|j| j.short() == name || j.label == name) {
Some(j) if j.broken() => fail(&format!(
- "{name}: this plist cannot be read ({}). launchd may still be running the copy it \
- loaded before the file broke -- what it will do at the next login is the question. \
- Fix the file first.", j.fault.clone().unwrap_or_default())),
+ "{name}: this {} cannot be read ({}). {} may still be running the copy it loaded \
+ before the file broke -- what it will do at the next login is the question. Fix \
+ the file first.", jobs::UNIT_NOUN, j.fault.clone().unwrap_or_default(),
+ jobs::BACKEND_NAME)),
Some(j) => j,
None => fail(&format!("no such job: {name}. There is: {}",
all.iter().map(Job::short).collect::>().join(", "))),
@@ -178,10 +179,11 @@ fn list(all: &[Job]) {
println!("{:<13} {:<22} {:<14} {}", "JOB", "SCHEDULE", "LAST OUTPUT", "STATE");
for j in all {
if let Some(why) = &j.fault {
- // An unreadable plist is one launchd also refused at login: the job is not running.
- // It used to be dropped from the list entirely, which is the one case the console had
- // nothing at all to say about.
- println!("{:<13} {:<22} {:<14} ! unreadable plist: {why}", j.short(), "—", "—");
+ // An unreadable unit is one the service manager also refused: the job is not
+ // running. It used to be dropped from the list entirely, which is the one case the
+ // console had nothing at all to say about.
+ println!("{:<13} {:<22} {:<14} ! unreadable {}: {why}", j.short(), "—", "—",
+ jobs::UNIT_NOUN);
continue;
}
let when = match j.since_last_output() {
@@ -198,7 +200,7 @@ fn list(all: &[Job]) {
None => "never ran".to_string(),
}
} else if j.last_exit.is_none() {
- "not loaded into launchd".to_string()
+ format!("not loaded into {}", jobs::BACKEND_NAME)
} else if j.runs == Some(0) {
// Loaded, and launchd has not started it since this login. Its exit column says 0,
// which it prints both for "finished successfully" and for "never finished at all" --
@@ -213,7 +215,7 @@ fn list(all: &[Job]) {
// has nothing to tell them apart by.
Some(0) => format!("last run ok{}", runs_note(j)),
Some(c) => format!("last run: code {c}{}", runs_note(j)),
- None => "not loaded into launchd".to_string(),
+ None => format!("not loaded into {}", jobs::BACKEND_NAME),
}
};
println!("{:<13} {:<22} {:<14} {}", j.short(), j.schedule.human(), when, state);
@@ -227,7 +229,7 @@ fn list(all: &[Job]) {
// job installed later than its own last slot honestly shows a zero and is not a
// complaint: a false alarm here would teach one to scroll past this line.
let what = if j.never_ran() {
- "the period has already passed and launchd still never ran it"
+ "the period has already passed and it still never ran"
} else {
"it ran at some point, but the log has not moved for more than a period"
};
@@ -254,6 +256,12 @@ fn ago(d: std::time::Duration) -> String {
else { format!("{} d ago", s / 86400) }
}
+// The schedule-editing mechanism described below is real on macOS -- a plist is written, linted
+// and reloaded into launchd, with a `.bak` for safety -- and is simply not implemented yet on
+// Linux (`jobs::set_schedule` returns "not implemented" there). Two texts, not one interpolated
+// with `jobs::BACKEND_NAME`, because the difference is not a noun, it is a paragraph that is
+// false on one of the two platforms.
+#[cfg(target_os = "macos")]
const HELP: &str = "\
hypermnesia-jobs — the memory pipeline's scheduled jobs (launchd).
@@ -273,3 +281,20 @@ The name is the tail of the label: extract, consolidate, reflect, freshness, rer
The label prefix comes from HM_JOB_PREFIX, then the console config file, then the
default com.hypermnesia.
";
+
+#[cfg(not(target_os = "macos"))]
+const HELP: &str = "\
+hypermnesia-jobs — the memory pipeline's scheduled jobs (systemd user units).
+
+ hypermnesia-jobs what is configured, when it worked, the last exit code
+ hypermnesia-jobs log [N] the last N lines of the log (40 by default), where one is \
+configured
+
+`run`, `every` and `at` are read on this platform, but not yet implemented: this build can list
+systemd user timers and services, not edit or trigger them. Use `systemctl --user start
+.service` and `systemctl --user edit .timer` directly for now.
+
+The name is the tail of the unit's file stem: extract, consolidate, reflect, freshness, rerank.
+The unit prefix comes from HM_JOB_PREFIX, then the console config file, then the
+default hypermnesia-.
+";
diff --git a/console/src/bin/setup.rs b/console/src/bin/setup.rs
index 4626817..5e912d7 100644
--- a/console/src/bin/setup.rs
+++ b/console/src/bin/setup.rs
@@ -113,12 +113,14 @@ fn walkthrough() {
step(6, "The menu bar");
if yes("Add the tray to autostart?", true) {
- // launchd starts the tray with a minimal environment: no login shell, none of your
- // exports. A command that reads $DATABASE_URL was verified HERE, where you have it.
+ // The service manager starts the tray with a minimal environment: no login shell, none
+ // of your exports. A command that reads $DATABASE_URL was verified HERE, where you have
+ // it.
let cmd = config().get("HM_PSQL_CMD").cloned().unwrap_or_default();
if let Some(var) = shell_variable_in(&cmd) {
println!("! The command you configured uses ${var}, which this shell supplies and");
- println!(" launchd does not: it starts jobs with a minimal environment. In the tray");
+ println!(" {} does not: it starts jobs with a minimal environment. In the tray",
+ jobs::BACKEND_NAME);
println!(" that command will fail where it works here. Either write the value into");
println!(" the command (hypermnesia-setup --connect) or keep using the CLI tools.");
if !yes("Install it anyway?", false) {
diff --git a/console/src/bin/tray.rs b/console/src/bin/tray.rs
index e66f435..6164c2e 100644
--- a/console/src/bin/tray.rs
+++ b/console/src/bin/tray.rs
@@ -1,4 +1,4 @@
-//! The memory console in the menu bar.
+//! The memory console in the menu bar / system tray.
//!
//! A menu, not a window. Everything this console has to show is a dozen lines of state and a
//! dozen buttons; a window would mean a GUI framework for the same result.
@@ -6,611 +6,609 @@
//! Two rules everything else follows from:
//!
//! 1. The main thread never waits. Reading the store can take seconds (a connection, a query);
-//! doing that on the menu thread freezes the menu bar, and on macOS the whole NSApplication
-//! run loop with it. Everything slow lives on a worker thread and sends its result back.
+//! doing that on the menu thread freezes the menu, and on macOS the whole NSApplication run
+//! loop with it. Everything slow lives on a worker thread and sends its result back.
//! 2. A failure is visible. The menu-bar title and the first menu line say when the data did not
//! arrive, instead of letting yesterday's numbers look current. A console whose stale state
//! is indistinguishable from its fresh state is worse than no console.
+//!
+//! `mod app` is everything above the event loop: state, the worker thread, and how the menu is
+//! rendered from `hypermnesia_console::view`. It is shared, unchanged, by every platform this
+//! binary supports, because none of it is platform-specific -- reading the store and reading the
+//! job list are already portable, and `tray_icon`'s `Menu`/`MenuItem`/`TrayIcon` API is the same
+//! crate on macOS and on Linux. Only *driving* that API differs: macOS needs a winit
+//! `ApplicationHandler` and its NSApplication run loop; Linux needs a GTK main loop, since that is
+//! what `tray-icon`'s Linux backend (`libayatana-appindicator`) is built on. `mod mac` and
+//! `mod linux` hold exactly that seam and nothing else.
+
+#[cfg(any(target_os = "macos", all(target_os = "linux", feature = "tray")))]
+mod app {
+ use std::sync::mpsc;
+ use std::time::{Duration, Instant, SystemTime};
+
+ use hypermnesia_console::jobs::{self, Job};
+ use hypermnesia_console::view;
+ use hypermnesia_console::{fetch, Stats, Target};
+
+ use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
+ use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
+
+ /// How often to refresh on its own. A minute: the numbers move slowly and every reading is a
+ /// round trip to the store.
+ pub const REFRESH: Duration = Duration::from_secs(60);
+
+ /// How long the worker may be silent after being asked something before the menu says so. The
+ /// store's own timeout is 30 s by default, so this is well past any normal answer: the point
+ /// is to notice a worker that will never answer at all, not to hurry a slow one.
+ const WORKER_PATIENCE: Duration = Duration::from_secs(90);
+
+ /// Ready-made schedules offered in the menu. Exactly what is usually wanted and nothing more:
+ /// the rare case belongs on the command line -- where, on Linux today, it is the only place:
+ /// `jobs::set_schedule` refuses with "not implemented on Linux yet", and picking one of these
+ /// items just shows that refusal in the note line rather than silently doing nothing.
+ const PRESETS: &[(&str, jobs::Schedule)] = &[
+ ("hourly", jobs::Schedule::Every(3600)),
+ ("every 4 hours", jobs::Schedule::Every(14_400)),
+ ("every 12 hours", jobs::Schedule::Every(43_200)),
+ ("daily at 05:30", jobs::Schedule::Calendar(jobs::Cal { hour: Some(5), minute: Some(30), day: None, weekday: None, month: None })),
+ ("weekly, Mon 06:10", jobs::Schedule::Calendar(jobs::Cal { hour: Some(6), minute: Some(10), day: None, weekday: Some(1), month: None })),
+ ];
-// The tray is macOS-only, and not by accident: the schedules it shows and edits are launchd's,
-// and the icon lives in the system menu bar. The four command-line tools work anywhere psql
-// does, so the crate still builds without a single GUI library present.
-#[cfg(target_os = "macos")]
-mod mac {
-use std::sync::mpsc;
-use std::time::{Duration, Instant, SystemTime};
-
-use hypermnesia_console::jobs::{self, Job};
-use hypermnesia_console::{fetch, Stats, Target};
-
-use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
-use tray_icon::{TrayIcon, TrayIconBuilder};
-use winit::application::ApplicationHandler;
-use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
-
-/// How often to refresh on its own. A minute: the numbers move slowly and every reading is a
-/// round trip to the store.
-const REFRESH: Duration = Duration::from_secs(60);
-
-/// How long the worker may be silent after being asked something before the menu says so. The
-/// store's own timeout is 30 s by default, so this is well past any normal answer: the point is
-/// to notice a worker that will never answer at all, not to hurry a slow one.
-const WORKER_PATIENCE: Duration = Duration::from_secs(90);
-
-/// Ready-made schedules offered in the menu. Exactly what is usually wanted and nothing more:
-/// the rare case belongs on the command line.
-const PRESETS: &[(&str, jobs::Schedule)] = &[
- ("hourly", jobs::Schedule::Every(3600)),
- ("every 4 hours", jobs::Schedule::Every(14_400)),
- ("every 12 hours", jobs::Schedule::Every(43_200)),
- ("daily at 05:30", jobs::Schedule::Calendar(jobs::Cal { hour: Some(5), minute: Some(30), day: None, weekday: None, month: None })),
- ("weekly, Mon 06:10", jobs::Schedule::Calendar(jobs::Cal { hour: Some(6), minute: Some(10), day: None, weekday: Some(1), month: None })),
-];
-
-pub fn main() {
- // Install and uninstall run before the event loop exists: both finish immediately and ask
- // for no window.
- let args: Vec = std::env::args().skip(1).collect();
- match args.first().map(String::as_str) {
- Some("--install") => return finish(jobs::install_self(jobs::DEFAULT_TRAY_LABEL)),
- Some("--uninstall") => return finish(jobs::uninstall_self(jobs::DEFAULT_TRAY_LABEL)),
- Some("-h") | Some("--help") => {
- print!("{HELP}");
- return;
- }
- Some(other) => {
- eprintln!("hypermnesia: unknown argument: {other}");
- std::process::exit(1);
- }
- None => {}
- }
-
- let event_loop = EventLoop::builder().build().expect("event loop");
- event_loop.set_control_flow(ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(200)));
-
- let (tx, rx) = mpsc::channel::();
- let (cmd_tx, cmd_rx) = mpsc::channel::();
- spawn_worker(tx, cmd_rx);
- // Read immediately: an empty menu at startup looks like a broken console.
- let _ = cmd_tx.send(Command::Refresh);
-
- let mut app = App {
- tray: None,
- items: Items::default(),
- rx,
- cmd: cmd_tx,
- last: None,
- status: "loading…".to_string(),
- note: None,
- awaiting: Some(("the first reading".into(), Instant::now())),
- worker_dead: false,
- last_refresh: Instant::now(),
- };
- if let Err(e) = event_loop.run_app(&mut app) {
- eprintln!("hypermnesia: the event loop ended: {e}");
- }
-}
-
-fn finish(r: Result) {
- match r {
- Ok(msg) => println!("{msg}"),
- Err(e) => {
- eprintln!("hypermnesia: {e}");
- std::process::exit(1);
- }
- }
-}
-
-const HELP: &str = "\
-hypermnesia — the memory console in the menu bar.
+ pub const HELP: &str = "\
+hypermnesia — the memory console in the menu bar / system tray.
hypermnesia run the tray
- hypermnesia --install add to autostart (launchd: at login, restarted if it crashes)
- hypermnesia --uninstall remove from autostart
+ hypermnesia --install add to autostart, where supported
+ hypermnesia --uninstall remove from autostart, where supported
The numbers and the buttons are the same ones hypermnesia-stats and hypermnesia-jobs give: the
-tray calls the same functions. Connection settings come from hypermnesia-setup.
+tray calls the same functions. Connection settings come from hypermnesia-setup. Autostart and
+schedule editing are launchd-only for now; on Linux those actions say so rather than doing
+nothing silently.
";
-/// What the worker thread sends back.
-enum Update {
- Data(Box),
- Failed(String),
- /// The outcome of a button press: a line to show at the top of the menu.
- Note(String),
-}
+ /// Handle `--install` / `--uninstall` / `--help` before any window or GTK loop exists: all
+ /// three finish immediately and ask for no window. Returns `true` if one of them was handled
+ /// (the caller should return without building the tray), `false` to proceed.
+ pub fn handle_early_args(args: &[String]) -> bool {
+ match args.first().map(String::as_str) {
+ Some("--install") => { finish(jobs::install_self(jobs::DEFAULT_TRAY_LABEL)); true }
+ Some("--uninstall") => { finish(jobs::uninstall_self(jobs::DEFAULT_TRAY_LABEL)); true }
+ Some("-h") | Some("--help") => { print!("{HELP}"); true }
+ Some(other) => {
+ eprintln!("hypermnesia: unknown argument: {other}");
+ std::process::exit(1);
+ }
+ None => false,
+ }
+ }
-struct Snapshot {
- stats: Stats,
- jobs: Vec,
- /// Why the job list is missing, if it is. `unwrap_or_default()` used to turn "could not read
- /// LaunchAgents" into an empty list, which rendered as empty Run-now and Schedule submenus
- /// and a calm title -- "no jobs readable" and "every job healthy" looked identical.
- jobs_error: Option,
- /// Wall clock, not `Instant`: the age of the data has to survive the Mac going to sleep, and
- /// a monotonic clock stops while it does. That made a two-hour nap look like a fresh reading.
- at: SystemTime,
-}
+ fn finish(r: Result) {
+ match r {
+ Ok(msg) => println!("{msg}"),
+ Err(e) => {
+ eprintln!("hypermnesia: {e}");
+ std::process::exit(1);
+ }
+ }
+ }
-enum Command {
- Refresh,
- RunJob(String),
- SetSchedule(String, jobs::Schedule),
-}
+ /// What the worker thread sends back.
+ pub enum Update {
+ Data(Box),
+ Failed(String),
+ /// The outcome of a button press: a line to show at the top of the menu.
+ Note(String),
+ }
-/// The worker: every slow thing lives here. The main thread only posts commands to it.
-fn spawn_worker(tx: mpsc::Sender, rx: mpsc::Receiver) {
- std::thread::spawn(move || {
- let read_all = |tx: &mpsc::Sender| {
- // Re-read the settings on every pass rather than once at startup. The tray runs for
- // weeks; a connection fixed with the setup wizard in the meantime has to reach the
- // running tray, or the fix looks like it did not work.
- let target = Target::default();
- let prefix = jobs::job_prefix();
- // Jobs are read locally and fast; the store is read over whatever transport was
- // configured and may be slow. If the store does not answer, the jobs are still worth
- // showing.
- let (job_list, jobs_error) = match jobs::list(&prefix) {
- Ok(j) => (j, None),
- Err(e) => (Vec::new(), Some(e)),
- };
- match fetch(&target) {
- Ok(stats) => {
- let _ = tx.send(Update::Data(Box::new(Snapshot {
- stats, jobs: job_list, jobs_error, at: SystemTime::now(),
- })));
- }
- Err(e) => { let _ = tx.send(Update::Failed(e)); }
- }
- };
- while let Ok(cmd) = rx.recv() {
- match cmd {
- Command::Refresh => read_all(&tx),
- Command::RunJob(label) => {
- let msg = match jobs::run_now(&label) {
- Ok(m) => m,
- // Marked, like every other failure: the title reads the leading "!".
- Err(e) => format!("! did not start: {e}"),
- };
- let _ = tx.send(Update::Note(msg));
- read_all(&tx);
+ pub struct Snapshot {
+ stats: Stats,
+ jobs: Vec,
+ /// Why the job list is missing, if it is. `unwrap_or_default()` used to turn "could not
+ /// read the job directory" into an empty list, which rendered as empty Run-now and
+ /// Schedule submenus and a calm title -- "no jobs readable" and "every job healthy"
+ /// looked identical.
+ jobs_error: Option,
+ /// Wall clock, not `Instant`: the age of the data has to survive the machine sleeping,
+ /// and a monotonic clock stops while it does. That made a two-hour nap look like a fresh
+ /// reading.
+ at: SystemTime,
+ }
+
+ pub enum Command {
+ Refresh,
+ RunJob(String),
+ SetSchedule(String, jobs::Schedule),
+ }
+
+ /// The worker: every slow thing lives here. The main thread only posts commands to it.
+ pub fn spawn_worker(tx: mpsc::Sender, rx: mpsc::Receiver) {
+ std::thread::spawn(move || {
+ let read_all = |tx: &mpsc::Sender| {
+ // Re-read the settings on every pass rather than once at startup. The tray runs
+ // for weeks; a connection fixed with the setup wizard in the meantime has to
+ // reach the running tray, or the fix looks like it did not work.
+ let target = Target::default();
+ let prefix = jobs::job_prefix();
+ // Jobs are read locally and fast; the store is read over whatever transport was
+ // configured and may be slow. If the store does not answer, the jobs are still
+ // worth showing.
+ let (job_list, jobs_error) = match jobs::list(&prefix) {
+ Ok(j) => (j, None),
+ Err(e) => (Vec::new(), Some(e)),
+ };
+ match fetch(&target) {
+ Ok(stats) => {
+ let _ = tx.send(Update::Data(Box::new(Snapshot {
+ stats, jobs: job_list, jobs_error, at: SystemTime::now(),
+ })));
+ }
+ Err(e) => { let _ = tx.send(Update::Failed(e)); }
}
- Command::SetSchedule(label, sched) => {
- // Look the job up again: the list the menu was drawn from may be a minute
- // old, and editing a schedule from a stale record means editing something
- // other than what the person saw.
- let msg = match jobs::list(&jobs::job_prefix()).ok()
- .and_then(|all| all.into_iter().find(|j| j.label == label)) {
- Some(j) => match jobs::set_schedule(&j, &sched) {
+ };
+ while let Ok(cmd) = rx.recv() {
+ match cmd {
+ Command::Refresh => read_all(&tx),
+ Command::RunJob(label) => {
+ let msg = match jobs::run_now(&label) {
Ok(m) => m,
- Err(e) => format!("! {e}"),
- },
- None => format!("! the job {label} is gone"),
- };
- let _ = tx.send(Update::Note(msg));
- read_all(&tx);
+ // Marked, like every other failure: the title reads the leading "!".
+ Err(e) => format!("! did not start: {e}"),
+ };
+ let _ = tx.send(Update::Note(msg));
+ read_all(&tx);
+ }
+ Command::SetSchedule(label, sched) => {
+ // Look the job up again: the list the menu was drawn from may be a
+ // minute old, and editing a schedule from a stale record means editing
+ // something other than what the person saw.
+ let msg = match jobs::list(&jobs::job_prefix()).ok()
+ .and_then(|all| all.into_iter().find(|j| j.label == label)) {
+ Some(j) => match jobs::set_schedule(&j, &sched) {
+ Ok(m) => m,
+ Err(e) => format!("! {e}"),
+ },
+ None => format!("! the job {label} is gone"),
+ };
+ let _ = tx.send(Update::Note(msg));
+ read_all(&tx);
+ }
}
}
- }
- });
-}
+ });
+ }
-/// The menu items we later act on. The menu is rebuilt whole on every change: there are dozens
-/// of items, not thousands, and rebuilding wholesale rules out a label disagreeing with its data
-/// -- a partly updated menu is the same class of quiet lie as everything else this fixes.
-#[derive(Default)]
-struct Items {
- refresh: Option