Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## [Unreleased]

### The app shows which version it is

The version was only in a startup log line, so someone filing a report could not say which build
they had without opening the log.

- The main window's header reads `Lumen <version>` under the tagline, and an About section at the
bottom of the same window lists it under `Version`. Lumen has no separate settings window, so
that is where its settings are and where About went.
- The tray menu starts with the same line, `Lumen <version>`, greyed out. It is disabled, so no
platform delivers a click on it, and the menu's handler would ignore it if one did.
- Both read the app's own `PackageInfo`, which Tauri fills from `tauri.conf.json` when the app is
built (the window through `getVersion()`, the tray menu directly). There is no version string of
Lumen's own in the frontend or in Rust to keep in step.
- A test now fails when the version the app was built as differs from `tauri.conf.json`, the
Tauri crate's `Cargo.toml` or any crate's, which covers the sidecars the app bundles. The app
e2e (Linux and Windows) and the Issue 5 launch check (macOS) read the version from those files
and assert the window's text.

### Repeat reads follow the conversation, not the process (#32)

`/clear` and `/resume` keep `lumen-mcp` running, and it read `CLAUDE_CODE_SESSION_ID` once at
Expand Down
56 changes: 56 additions & 0 deletions lumenator/e2e/app.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,39 @@ const digits = s => (s ?? '').replace(/\D/g, '');
/** The text of the first element matching the selector, once it has some. */
const TEXT = `return document.querySelector(arguments[0])?.textContent.trim() || null;`;

/**
* The version the app was built as, from the two files that declare it: tauri.conf.json, which
* Tauri compiles into the app and answers `getVersion()` from, and the Tauri crate's Cargo.toml.
* Read when the test runs, so a release changes nothing here. Two files that disagree stop the
* test before it looks at the window, which would otherwise have to pick one to be right.
*/
function builtVersion() {
const dir = path.join(ROOT, 'lumenator', 'src-tauri');
const conf = JSON.parse(fs.readFileSync(path.join(dir, 'tauri.conf.json'), 'utf8')).version;
// The first version line is [package]'s; every dependency table comes after it.
const cargo = /^version = "([^"]+)"/m.exec(fs.readFileSync(path.join(dir, 'Cargo.toml'), 'utf8'))?.[1];
assert.ok(conf && cargo, `no version found: tauri.conf.json ${conf}, Cargo.toml ${cargo}`);
assert.equal(conf, cargo, 'tauri.conf.json and the Tauri crate\'s Cargo.toml declare different versions');
return conf;
}

// How the About section is built: a region named by its heading, the version as the value of a
// "Version" term, and both places the version is shown actually laid out.
const ABOUT = `
const section = document.querySelector('section.about');
const heading = document.getElementById(section?.getAttribute('aria-labelledby') ?? '');
const laidOut = sel => {
const r = document.querySelector(sel)?.getBoundingClientRect();
return !!r && r.width > 0 && r.height > 0;
};
return {
heading: heading?.textContent.trim() ?? null,
term: section?.querySelector('dl > dt')?.textContent.trim() ?? null,
valueFollowsTerm: section?.querySelector('dl > dt + dd')?.classList.contains('about__version') ?? false,
headerLaidOut: laidOut('.home__head .home__version'),
aboutLaidOut: laidOut('section.about .about__version'),
};`;

// Each banner text against what is behind it, as WCAG computes contrast: the colours as the
// page resolved them, with every translucent background between the text and the page
// composited in. The theme is whatever the platform reports, and is returned with the ratios.
Expand Down Expand Up @@ -316,6 +349,29 @@ test('an empty ledger shows the empty states rather than zeros dressed as data',
});
});

test('the window shows the version the app was built as, in its header and in About', async t => {
const want = builtVersion();
const sb = sandbox();
// Simulated so the window is shown; the version is the subject. The page asks the Tauri runtime
// for it (`getVersion()`), so what is read here went through the built app, not a test double.
await withApp(t, sb, { LUMEN_SIMULATE_TRAY: 'err' }, async s => {
const head = await s.until('the version in the header', TEXT, 15_000, '.home__version');
const about = await s.until('the version in About', TEXT, 15_000, '.about__version');
t.diagnostic(`built as ${want}; header "${head}", About "${about}"`);
assert.equal(head, `Lumen ${want}`);
assert.equal(about, want);

const a = await s.run(ABOUT);
assert.deepEqual(a, {
heading: 'About',
term: 'Version',
valueFollowsTerm: true,
headerLaidOut: true,
aboutLaidOut: true,
});
});
});

test('a healthy launch keeps its window closed past the tray checks', async t => {
const sb = sandbox();
await withApp(t, sb, {}, async s => {
Expand Down
220 changes: 216 additions & 4 deletions lumenator/src-tauri/src/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,82 @@ pub fn clear_hidden_status_item_prefs() -> Vec<String> {
/// and the fallback added in 1.5.1 wrapped only the *builder* result, so it covered none of
/// them.
pub fn build_tray_menu_items(app: &tauri::App) -> tauri::Result<tauri::menu::Menu<tauri::Wry>> {
use tauri::menu::{Menu, MenuItem};
let quit = MenuItem::with_id(app, "quit", "Quit Lumen", true, None::<&str>)?;
let show = MenuItem::with_id(app, "show", "Open Lumen", true, None::<&str>)?;
Menu::with_items(app, &[&show, &quit])
use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem};
let mut built: Vec<Box<dyn IsMenuItem<tauri::Wry>>> = Vec::new();
for entry in tray_menu_entries(&ui_version(app.package_info())) {
built.push(match entry {
TrayEntry::Item { id, text, enabled } => {
Box::new(MenuItem::with_id(app, id, text, enabled, None::<&str>)?)
}
TrayEntry::Separator => Box::new(PredefinedMenuItem::separator(app)?),
});
}
let items: Vec<&dyn IsMenuItem<tauri::Wry>> = built.iter().map(Box::as_ref).collect();
Menu::with_items(app, &items)
}

/// The tray menu item that opens the main window. Matched by the menu-event handler in `lib.rs`.
pub const TRAY_SHOW_ID: &str = "show";
/// The tray menu item that quits. Matched by the menu-event handler in `lib.rs`.
pub const TRAY_QUIT_ID: &str = "quit";
/// The tray menu's version line. Neither id above, so the menu-event handler ignores it, and it
/// is built disabled, so no platform delivers a click on it in the first place.
pub const TRAY_VERSION_ID: &str = "version";

/// The version the app tells the user it is, wherever it says so.
///
/// This is `PackageInfo`, which Tauri fills from `tauri.conf.json` when the app is built (and
/// from the crate's `Cargo.toml` when that file has no version). The tray menu reads it here.
/// The windows read it through `getVersion()` from `@tauri-apps/api/app`, which Tauri answers
/// with `app.package_info().version` too, so there is one source and no version string of
/// Lumen's own to keep in step with it.
pub fn ui_version(info: &tauri::PackageInfo) -> String {
info.version.to_string()
}

/// What the tray menu's version line says.
pub fn tray_version_label(version: &str) -> String {
format!("Lumen {version}")
}

/// One line of the tray menu, as data.
///
/// The menu itself cannot be built in a test: muda creates menu items on the main thread only
/// on macOS, and a test runs on its own. What the lines say, and which can be clicked, is the
/// part worth checking, so it is decided here and `build_tray_menu_items` only turns it into
/// items.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrayEntry {
Item {
id: &'static str,
text: String,
enabled: bool,
},
Separator,
}

/// The tray menu, top to bottom. The version leads, disabled: it is information, and a
/// disabled item is how every platform's tray (the macOS menu bar, the Windows notification
/// area, the Linux indicator) shows a line that does nothing.
pub fn tray_menu_entries(version: &str) -> Vec<TrayEntry> {
vec![
TrayEntry::Item {
id: TRAY_VERSION_ID,
text: tray_version_label(version),
enabled: false,
},
TrayEntry::Separator,
TrayEntry::Item {
id: TRAY_SHOW_ID,
text: "Open Lumen".to_string(),
enabled: true,
},
TrayEntry::Item {
id: TRAY_QUIT_ID,
text: "Quit Lumen".to_string(),
enabled: true,
},
]
}

#[cfg(test)]
Expand Down Expand Up @@ -677,4 +749,144 @@ mod tests {
assert!(presence_is_checkable(false, SimulatedTray::Absent));
assert!(presence_is_checkable(false, SimulatedTray::OffScreen));
}

// ── the version the user is shown ─────────────────────────────────────────────────

use std::path::Path;

/// The first `version = "…"` of a manifest, which is `[package]`'s: it precedes every
/// dependency table.
fn manifest_version(manifest: &Path) -> String {
let text = std::fs::read_to_string(manifest)
.unwrap_or_else(|e| panic!("{}: {e}", manifest.display()));
text.lines()
.find_map(|l| {
l.trim()
.strip_prefix("version = \"")?
.split('"')
.next()
.map(str::to_string)
})
.unwrap_or_else(|| panic!("{} declares no version", manifest.display()))
}

#[test]
fn the_tray_menu_leads_with_a_version_line_that_cannot_be_clicked() {
let entries = tray_menu_entries("9.8.7");
assert_eq!(
entries[0],
TrayEntry::Item {
id: TRAY_VERSION_ID,
text: "Lumen 9.8.7".to_string(),
enabled: false,
},
"the version line is first, built from the version it was given, and disabled"
);
assert_eq!(entries[1], TrayEntry::Separator);

// Only the version line is disabled: Open and Quit still work.
let items: Vec<(&str, &str, bool)> = entries
.iter()
.filter_map(|e| match e {
TrayEntry::Item { id, text, enabled } => Some((*id, text.as_str(), *enabled)),
TrayEntry::Separator => None,
})
.collect();
assert_eq!(
items,
[
(TRAY_VERSION_ID, "Lumen 9.8.7", false),
(TRAY_SHOW_ID, "Open Lumen", true),
(TRAY_QUIT_ID, "Quit Lumen", true),
]
);
// The handler in lib.rs acts on the show and quit ids. A version line that shared one
// would do that thing when activated.
assert_ne!(TRAY_VERSION_ID, TRAY_SHOW_ID);
assert_ne!(TRAY_VERSION_ID, TRAY_QUIT_ID);
}

#[test]
fn the_tray_version_line_follows_the_version_it_is_given() {
// A label with a version written into it would pass the test above and not this one.
for v in ["0.0.1", "1.8.0", "12.34.56-rc.1"] {
let TrayEntry::Item { text, .. } = &tray_menu_entries(v)[0] else {
panic!("the first entry is the version line");
};
assert_eq!(text, &format!("Lumen {v}"));
}
}

/// The version the app was built as is the one `tauri.conf.json` declares, the one the Tauri
/// crate's `Cargo.toml` declares, and the one every other crate declares, which includes the
/// ones bundled beside the app as sidecars. The tray menu and the windows both show
/// `ui_version`, so a release that bumps some of those files and not the others shows the
/// user a version that is not the one they installed.
#[test]
fn the_version_the_ui_shows_is_the_one_tauri_conf_and_every_cargo_toml_declare() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
// The context the app itself runs with: `PackageInfo` as the build made it.
let ctx = crate::app_context();
let ui = ui_version(ctx.package_info());

let conf: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(manifest_dir.join("tauri.conf.json"))
.expect("tauri.conf.json is readable"),
)
.expect("tauri.conf.json is JSON");
let conf_version = conf["version"]
.as_str()
.expect("tauri.conf.json declares a version");

let mut declared = vec![
(
"lumenator/src-tauri/tauri.conf.json".to_string(),
conf_version.to_string(),
),
(
"lumenator/src-tauri/Cargo.toml".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
),
];
let mut names = Vec::new();
for entry in
std::fs::read_dir(manifest_dir.join("../../crates")).expect("crates/ is readable")
{
let dir = entry.expect("a readable entry").path();
let manifest = dir.join("Cargo.toml");
if manifest.is_file() {
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
declared.push((
format!("crates/{name}/Cargo.toml"),
manifest_version(&manifest),
));
names.push(name);
}
}
// Not vacuous: the crates whose binaries ship inside the app were found.
for sidecar in ["lumen-daemon", "lumen-mcp", "lumen-cli"] {
assert!(
names.iter().any(|n| n == sidecar),
"{sidecar} not found in {names:?}"
);
}

let wrong: Vec<String> = declared
.iter()
.filter(|(_, v)| *v != ui)
.map(|(f, v)| format!(" {f}: {v}"))
.collect();
assert!(
wrong.is_empty(),
"the app shows {ui} (PackageInfo), and these declare something else:\n{}",
wrong.join("\n")
);

// And the tray line is made from that same value.
let TrayEntry::Item { text, enabled, .. } = &tray_menu_entries(&ui)[0] else {
panic!("the first entry is the version line");
};
assert_eq!(text, &format!("Lumen {conf_version}"));
assert!(!enabled);
}
}
17 changes: 14 additions & 3 deletions lumenator/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ fn render_tray_icon(_percent: u8, status: &str) -> tauri::image::Image<'static>
tauri::image::Image::new_owned(rgba, size, size)
}

/// The context the app runs with: `tauri.conf.json` and the assets, embedded at compile time.
///
/// `tauri::generate_context!()` may be expanded once per binary (it defines a symbol), and the
/// unit-test binary contains `run()` too. So this is the one place it is expanded, and the test
/// that the version the UI shows is the one the manifests declare reads the context from here.
pub(crate) fn app_context() -> tauri::Context<tauri::Wry> {
tauri::generate_context!()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
Expand Down Expand Up @@ -410,14 +419,16 @@ pub fn run() {
.menu(menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"quit" => app.exit(0),
"show" => {
health::TRAY_QUIT_ID => app.exit(0),
health::TRAY_SHOW_ID => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
// The version line is disabled, so no click on it arrives here; any
// other id is not one this menu has.
_ => {}
})
.on_tray_icon_event(|tray, event| {
Expand Down Expand Up @@ -524,7 +535,7 @@ pub fn run() {
setup::lumen_set_autostart,
setup::lumen_artifact_health
])
.build(tauri::generate_context!())
.build(app_context())
.expect("error while building tauri application")
.run(|app, event| match event {
// Kill the daemon on the way out. The daemon's own stdin watchdog covers
Expand Down
Loading
Loading