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..ae11d50 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`) |
| `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,33 +299,46 @@ 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:
- **the first line is the age of what you are reading.** Not a status light: "updated 4m ago, in
1.2s", and after a failure "! not updated: , showing state from 12m ago". It is computed
when the menu is drawn, from the wall clock, so a Mac that slept for two hours says two hours.
+ The store's own numbers refresh every minute; the jobs — a local, cheap read on either platform
+ — refresh every few seconds, faster still for a little while after a button is pressed, so
+ pressing one does not mean waiting out the idle cadence to see whether it worked.
- **the volumes** — memories active of total, knowledge pages, documents and chunks, how many
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. Pressing one runs it — through launchd on macOS, through `systemctl --user start
+ --no-block` on Linux — and reports what the service manager then did, not that the request was
+ accepted: a program that is not there fails immediately, and that is read back rather than taken
+ on faith.
+- **Schedule** — the common intervals and times, per job. It writes the unit, validates it, and
+ reloads the job, because both launchd and systemd keep their own copy from the moment they
+ loaded it: writing the file without the reload would show a new schedule while the old one is
+ still in force. A backup goes down next to the file first, and on any slip along the way it is
+ restored — what actually landed is read back and compared to what was asked before the tray
+ calls it done.
+- **Timers** (Linux only) — arm or disarm a job's schedule without removing it. systemd tracks
+ "loaded but not armed" as a state of its own, which the read side of this tray could already
+ show as a fault; this is what fixes it. launchd has no such state — a job it has loaded runs on
+ its schedule, full stop — so there is nothing here to switch on macOS.
- **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.
+`hypermnesia --install` puts it in autostart to start at login, restarted if it crashes and not if
+you quit it — launchd on macOS, a systemd user unit on Linux. `--uninstall` takes it back out.
**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..4c25b34 100644
--- a/console/src/bin/jobs.rs
+++ b/console/src/bin/jobs.rs
@@ -1,8 +1,12 @@
-//! The pipeline's scheduled jobs: show them and run them.
+//! The pipeline's scheduled jobs: show them, run them, and change when they run.
//!
//! hypermnesia-jobs what is configured and when it last worked
//! hypermnesia-jobs run run now, without touching the schedule
//! hypermnesia-jobs log the last lines of the log
+//! hypermnesia-jobs every 4h an interval schedule
+//! hypermnesia-jobs at 05:30 a calendar schedule
+//! hypermnesia-jobs enable arm the schedule (systemd only)
+//! hypermnesia-jobs disable disarm it without removing it (systemd only)
//!
//! The short name is the tail of the label: extract, consolidate, reflect, freshness, rerank.
@@ -11,15 +15,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) {
@@ -69,7 +73,21 @@ fn main() {
let Some((hour, minute)) = parse_hhmm(time) else { fail("a time is written as HH:MM") };
apply(find(&all, name), &jobs::Schedule::at(hour, minute, day));
}
- Some("-h") | Some("--help") => print!("{HELP}"),
+ Some("enable") => {
+ let Some(name) = args.get(1) else { fail("give the job's short name") };
+ match jobs::set_enabled(find(&all, name), true) {
+ Ok(msg) => println!("{msg}"),
+ Err(e) => fail(&e),
+ }
+ }
+ Some("disable") => {
+ let Some(name) = args.get(1) else { fail("give the job's short name") };
+ match jobs::set_enabled(find(&all, name), false) {
+ Ok(msg) => println!("{msg}"),
+ Err(e) => fail(&e),
+ }
+ }
+ Some("-h") | Some("--help") => print!("{}", help_text()),
Some(other) => fail(&format!("unknown command: {other}")),
}
}
@@ -116,9 +134,12 @@ fn apply(j: &Job, sched: &jobs::Schedule) {
Ok(msg) => {
println!("{msg}");
println!("was: {was}");
- // launchd keeps its own copy of the schedule from the moment it loaded the job, so the
- // job is reloaded. That resets the run counter -- which has to be said out loud, or
- // the next look at the list will be alarming: a zero where everything is fine.
+ // The service manager keeps its own copy of the schedule from the moment it loaded
+ // the job, so the job is reloaded. That resets whatever run counter it keeps -- which
+ // has to be said out loud on the platform that has one, or the next look at the list
+ // will be alarming: a zero where everything is fine. systemd keeps no such counter
+ // (`Job::runs` is always `None` there), so there is nothing to warn about on Linux.
+ #[cfg(target_os = "macos")]
println!("the job was reloaded into launchd; the run counter started over");
}
Err(e) => fail(&e),
@@ -159,10 +180,11 @@ 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())),
+ Some(j) if j.unwritable() => fail(&format!(
+ "{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 +200,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 +221,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 +236,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 +250,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"
};
@@ -237,6 +260,9 @@ fn list(all: &[Job]) {
println!();
println!("run — run now; log [N] — the tail of the log; \
every/at … — change the schedule (--help)");
+ if jobs::SUPPORTS_ENABLE {
+ println!("enable/disable — arm or disarm the schedule without removing it");
+ }
}
fn runs_note(j: &Job) -> String {
@@ -254,22 +280,46 @@ fn ago(d: std::time::Duration) -> String {
else { format!("{} d ago", s / 86400) }
}
-const HELP: &str = "\
-hypermnesia-jobs — the memory pipeline's scheduled jobs (launchd).
+// One text, not two: both backends now write, validate, reload and read back a schedule, and
+// differ only in the nouns `jobs::UNIT_NOUN`/`BACKEND_NAME`/`UNITS_LOCATION` already carry.
+// `enable`/`disable` is the one command that is not portable -- launchd has no separate "loaded
+// but not armed" state, so it is documented as systemd-only rather than interpolated away.
+#[cfg(target_os = "macos")]
+const RUN_NOW: &str = "run immediately (launchctl kickstart -k)";
+#[cfg(target_os = "linux")]
+const RUN_NOW: &str = "run immediately (systemctl --user start --no-block)";
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+const RUN_NOW: &str = "run immediately";
+
+fn help_text() -> String {
+ // launchd has no separate "loaded but not armed" state -- a job it has loaded is armed, full
+ // stop -- so `enable`/`disable` is documented only where `jobs::SUPPORTS_ENABLE` says it does
+ // something, rather than interpolating a noun into a paragraph that would be false on macOS.
+ let enable = if jobs::SUPPORTS_ENABLE {
+ format!(" hypermnesia-jobs enable arm the schedule\n\
+ \x20 hypermnesia-jobs disable disarm it without removing it\n")
+ } else {
+ String::new()
+ };
+ format!("\
+hypermnesia-jobs — the memory pipeline's scheduled jobs ({backend}).
hypermnesia-jobs what is configured, when it worked, the last exit code
- hypermnesia-jobs run run immediately (launchctl kickstart -k)
- hypermnesia-jobs log [N] the last N lines of the log (40 by default)
+ hypermnesia-jobs run {run_now}
+ hypermnesia-jobs log [N] the last N lines of the log (40 by default), where one \
+is configured
hypermnesia-jobs every 4h an interval schedule (30s, 15m, 4h, 2d)
hypermnesia-jobs at 05:30 daily at that time
hypermnesia-jobs at mon 06:10 weekly on that day
-
-Editing a schedule writes the plist, validates it and reloads the job into launchd: without the
-reload launchd keeps working from its own copy, and the new schedule would be displayed where the
-old one is in force. Before the edit a .bak is put down next to the file, and on any slip the
+{enable}
+Editing a schedule writes the {noun}, validates it and reloads the job into {backend}: without
+the reload {backend} keeps working from its own copy, and the new schedule would be shown where
+the old one is in force. Before the edit a .bak is put down next to the file, and on any slip the
file is restored.
The name is the tail of the label: extract, consolidate, reflect, freshness, rerank.
The label prefix comes from HM_JOB_PREFIX, then the console config file, then the
-default com.hypermnesia.
-";
+default {prefix}.
+", backend = jobs::BACKEND_NAME, run_now = RUN_NOW, noun = jobs::UNIT_NOUN,
+ prefix = jobs::DEFAULT_JOB_PREFIX)
+}
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..cd94844 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,764 @@
//! 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 the store's own numbers on its own. A minute: they move slowly and
+ /// every reading is a round trip over whatever transport the store is configured with.
+ pub const REFRESH: Duration = Duration::from_secs(60);
+
+ /// How often to re-read the jobs on their own, apart from the store. A local `systemctl`/
+ /// `launchctl` call is cheap -- cheap enough that a person watching a job they just started
+ /// should not be stuck behind the store's minute-long cadence to see it finish.
+ const JOBS_REFRESH: Duration = Duration::from_secs(5);
+
+ /// How long after a write command (run, schedule, enable/disable) to keep polling the jobs at
+ /// the faster `BURST_REFRESH` cadence, so the result of pressing a button shows up in a
+ /// couple of seconds rather than waiting out `JOBS_REFRESH`.
+ const BURST_WINDOW: Duration = Duration::from_secs(10);
+ const BURST_REFRESH: Duration = Duration::from_secs(1);
+
+ /// 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, on every platform this tray runs on now.
+ 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 --install add to autostart
hypermnesia --uninstall remove from autostart
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. The store's own
+numbers refresh every minute; the jobs -- a local, cheap read -- refresh every few seconds, faster
+still for a little while after a button is pressed.
";
-/// 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),
+ /// A jobs-only reading: local and cheap, refreshed on its own cadence from the store's.
+ Jobs(Vec, Option),
+ }
-/// 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,
+ /// 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 {
+ /// Both the store and the jobs.
+ Refresh,
+ /// The jobs alone -- local, fast, and asked for far more often than `Refresh`. Does not
+ /// touch `awaiting`: this is a background heartbeat, not a question the watchdog should
+ /// hold the tray to.
+ RefreshJobs,
+ RunJob(String),
+ SetSchedule(String, jobs::Schedule),
+ SetEnabled(String, bool),
+ }
+
+ fn read_jobs() -> (Vec, Option) {
+ // Through jobs::job_prefix rather than a cached value: the tray runs for weeks, and a
+ // prefix fixed in the config file in the meantime has to reach it without a restart.
+ match jobs::list(&jobs::job_prefix()) {
+ Ok(j) => (j, None),
+ Err(e) => (Vec::new(), Some(e)),
+ }
+ }
+
+ /// 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_store = |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.
+ match fetch(&Target::default()) {
+ Ok(stats) => {
+ let _ = tx.send(Update::Data(Box::new(Snapshot { stats, 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) {
+ };
+ let read_jobs_into = |tx: &mpsc::Sender| {
+ let (jobs, err) = read_jobs();
+ let _ = tx.send(Update::Jobs(jobs, err));
+ };
+ while let Ok(cmd) = rx.recv() {
+ match cmd {
+ Command::Refresh => {
+ // Jobs first: they are local and fast, and worth showing even if the
+ // store -- reached over whatever transport is configured, possibly slow
+ // -- does not answer.
+ read_jobs_into(&tx);
+ read_store(&tx);
+ }
+ Command::RefreshJobs => read_jobs_into(&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_jobs_into(&tx);
+ }
+ Command::SetSchedule(label, sched) => {
+ // Look the job up again: the list the menu was drawn from may be several
+ // seconds old, and editing a schedule from a stale record means editing
+ // something other than what the person saw.
+ let msg = match read_jobs().0.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_jobs_into(&tx);
+ }
+ Command::SetEnabled(label, enabled) => {
+ let msg = match read_jobs().0.into_iter().find(|j| j.label == label) {
+ Some(j) => match jobs::set_enabled(&j, enabled) {
+ Ok(m) => m,
+ Err(e) => format!("! {e}"),
+ },
+ None => format!("! the job {label} is gone"),
+ };
+ let _ = tx.send(Update::Note(msg));
+ read_jobs_into(&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