//! macOS application-bundle metadata.
//!
//! The managed install and the desktop app are the same `.app`, so the
//! bundle identity here must agree with `crates/vak-desktop/tauri.conf.json`.
//! `scripts/check-version.sh` enforces that they do.
use std::path::Path;
use super::atomic;
use super::layout::InstallRoot;
/// Bundle identifier, matching `tauri.conf.json`.
pub const IDENTIFIER: &str = "dev.vak.desktop";
/// The bundle launches `vak-desktop`, the actual application.
///
/// It used to launch `vak-tray` with `LSUIElement`, which made the
/// whole `.app` a background menu-bar agent. That had a fatal
/// consequence: `com.vak.tray` runs the same binary as a launchd
/// service, so macOS considered the app already running and answered a
/// double-click in Finder by sending an activate event to that existing
/// process rather than launching anything. No new process meant no code
/// of ours ran at all -- double-clicking Vak did nothing visible,
/// and no amount of logic inside the tray's startup could have fixed it,
/// because startup never happened.
///
/// `vak-desktop` is what a user double-clicking the app is asking for,
/// and it already handles being launched twice: `tauri_plugin_single_instance`
/// refocuses the open window instead of starting a rival instance. The
/// tray keeps its menu-bar-only presence by setting
/// `ActivationPolicy::Accessory` on its own event loop, so it no longer
/// depends on a bundle-wide `LSUIElement` that would also have hidden
/// the app this bundle now launches.
pub fn info_plist(version: &str) -> String {
format!(
r#"
CFBundleNameVakyartha
CFBundleDisplayNameVakyartha
CFBundleIdentifier{IDENTIFIER}
CFBundleVersion{version}
CFBundleShortVersionString{version}
CFBundlePackageTypeAPPL
CFBundleExecutablevak-desktop
CFBundleIconFileicon.icns
LSMinimumSystemVersion11.0
NSHighResolutionCapable
"#
)
}
/// Refuse a bundle install that would produce an unlaunchable app.
///
/// Separate from [`write_metadata`] so it can run **before** the install
/// transaction commits. Failing after the commit left the binaries in place
/// with no manifest beside them — a half-install that `status` reads as
/// broken and `verify` cannot even find, which is a worse outcome than
/// either finishing or not starting.
pub fn require_frontend(
root: &InstallRoot,
assets: Option<&Path>,
desktop: bool,
) -> Result<(), String> {
if !root.is_bundle() || !desktop || assets.is_some_and(Path::exists) {
return Ok(());
}
Err(
"the desktop app is being installed into a bundle, but its frontend was not found at \
crates/vak-client-ui/dist. The installed app would open a blank window. Build it \
first:\n cd crates/vak-client-ui && npm ci && npm run build\n(or install without \
the desktop app: scripts/build.sh --no-desktop)"
.to_string(),
)
}
/// Write `Info.plist` and copy the desktop frontend assets into
/// `Contents/Resources`, so a bundle install is launchable from Finder.
///
/// `desktop` says whether the `vak-desktop` binary is part of this install.
/// When it is, the frontend is **required**: `tauri.conf.json` points
/// `frontendDist` at `crates/vak-client-ui/dist`, and the installed app
/// loads that copy out of `Contents/Resources`. Without it the app opens a
/// blank window with nothing in the console to explain why.
///
/// This used to be a silent skip — `if let Some(dist) = assets.filter(...)`
/// with no else — so `vak self install` into the default macOS prefix with
/// no built frontend reported success, wrote a manifest, and produced an
/// app that did not work. `verify` then passed, because it only looked at
/// the binaries. An install that cannot run is a failed install, and it has
/// to say so at the moment it happens.
pub fn write_metadata(
root: &InstallRoot,
version: &str,
assets: Option<&Path>,
desktop: bool,
) -> Result<(), String> {
if !root.is_bundle() {
return Ok(());
}
let contents = root.prefix().join("Contents");
std::fs::create_dir_all(contents.join("Resources"))
.map_err(|e| format!("mkdir {}: {e}", contents.display()))?;
// Plist first: a bundle without one is not launchable as an app.
atomic::write(&contents.join("Info.plist"), info_plist(version).as_bytes())?;
require_frontend(root, assets, desktop)?;
let dist = assets.filter(|p| p.exists());
if let Some(dist) = dist {
// Every frontend rebuild produces new content-hashed filenames
// (Vite), and copy_dir only ever adds files, never removes ones
// absent from the source. Without clearing the destination
// first, every reinstall left the previous build's JS and CSS
// behind alongside the new one — harmless to which file
// actually gets served (index.html always names the current
// hash), but unbounded bloat, and confusing to anyone
// inspecting the bundle who has no way to tell which files are
// live. Only the hashed subtree is cleared, not all of
// Resources: that directory also holds install.json (written
// after this function returns) and Info.plist (written above).
let stale_assets = root.resources_dir().join("assets");
if stale_assets.exists() {
std::fs::remove_dir_all(&stale_assets)
.map_err(|e| format!("clear stale assets at {}: {e}", stale_assets.display()))?;
}
atomic::copy_dir(dist, &root.resources_dir())?;
}
// A bundle whose Info.plist names an icon (above) but never carries
// one shows Finder's generic placeholder — silently, since a missing
// CFBundleIconFile target is not an error macOS surfaces anywhere.
if let Some(icon) = locate_icon() {
std::fs::copy(&icon, contents.join("Resources/icon.icns"))
.map_err(|e| format!("copy icon {}: {e}", icon.display()))?;
}
Ok(())
}
/// Locate the built desktop frontend relative to the running binary,
/// covering both a dev tree (`target/release/vak`) and an installed
/// bundle (`Contents/MacOS/vak`).
pub fn locate_frontend_assets() -> Option {
locate_repo_relative("crates/vak-client-ui/dist")
}
/// Locate the app icon (`.icns`), same search shape as
/// [`locate_frontend_assets`] — both are repo-relative resources a
/// release binary needs to find without knowing where the checkout is.
pub fn locate_icon() -> Option {
locate_repo_relative("crates/vak-desktop/icons/icon.icns")
}
/// Locate the feed runtime bundled beside a release binary or in the source
/// checkout. The server executes these scripts as a subprocess, so an
/// install must carry the complete runtime rather than relying on the user's
/// workspace containing the repository's `scripts/feeds` tree.
pub fn locate_feed_assets() -> Option {
let exe = std::env::current_exe().ok()?;
let bin_dir = exe.parent()?;
let mut candidates = vec![
bin_dir.join("feeds"),
bin_dir.join("..").join("Resources").join("feeds"),
bin_dir.join("..").join("share").join("vak").join("feeds"),
bin_dir
.join("..")
.join("..")
.join("share")
.join("vak")
.join("feeds"),
];
if let Some(repo_assets) = locate_repo_relative("scripts/feeds") {
candidates.push(repo_assets);
}
candidates
.into_iter()
.find(|candidate| candidate.join("feed_ingest.py").exists())
}
fn locate_repo_relative(rel: &str) -> Option {
let exe = std::env::current_exe().ok()?;
let mut dir = exe.parent()?.to_path_buf();
for _ in 0..5 {
let candidate = dir.join(rel);
if candidate.exists() {
return Some(candidate);
}
dir = dir.parent()?.to_path_buf();
}
None
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn plist_carries_the_version_it_was_given() {
let p = info_plist("1.2.3");
assert!(p.contains("CFBundleShortVersionString1.2.3"));
assert!(p.contains(IDENTIFIER));
// The bundle launches the app the user double-clicks, not the
// background menu-bar agent. When it launched the tray under
// LSUIElement, macOS treated the app as already running (the
// tray also runs as a launchd service) and answered a
// double-click by activating that process instead of launching
// anything -- so opening Vak did nothing at all.
assert!(
p.contains("CFBundleExecutablevak-desktop"),
"the bundle must launch the desktop app"
);
assert!(
!p.contains("LSUIElement"),
"a bundle-wide LSUIElement would hide the app this bundle launches; \
the tray sets ActivationPolicy::Accessory on its own event loop instead"
);
}
#[test]
fn metadata_is_a_no_op_for_a_plain_prefix() {
let d = tempfile::tempdir().unwrap();
let root = InstallRoot::at(d.path().to_path_buf());
write_metadata(&root, "1.0.0", None, false).unwrap();
assert!(!d.path().join("Contents").exists());
}
#[test]
fn bundle_metadata_writes_a_launchable_plist() {
let d = tempfile::tempdir().unwrap();
let root = InstallRoot::at(d.path().join("Vak.app"));
write_metadata(&root, "0.8.0", None, false).unwrap();
let plist = std::fs::read_to_string(root.prefix().join("Contents/Info.plist")).unwrap();
assert!(plist.contains("0.8.0"));
assert!(
plist.contains("CFBundleIconFileicon.icns"),
"a bundle with no icon key falls back to Finder's generic placeholder"
);
}
#[test]
fn bundle_metadata_carries_the_real_icon_when_one_is_found_relative_to_the_test_binary() {
// This test binary runs from target/debug, still inside the real
// checkout, so locate_icon() finds the genuine icon the same way
// a release binary would -- proving the copy actually happens,
// not just that the plist names a file that isn't there.
let d = tempfile::tempdir().unwrap();
let root = InstallRoot::at(d.path().join("Vak.app"));
write_metadata(&root, "0.8.0", None, false).unwrap();
if locate_icon().is_some() {
let copied = root.prefix().join("Contents/Resources/icon.icns");
assert!(copied.is_file(), "icon.icns must be copied into the bundle");
assert!(std::fs::metadata(&copied).unwrap().len() > 0);
}
}
#[test]
fn reinstall_removes_the_previous_builds_hashed_assets() {
// Every rebuild of the frontend produces new content-hashed
// filenames; copy_dir only adds, it never removes. Without
// clearing the destination first, a reinstall accumulated every
// prior build's JS and CSS forever.
let d = tempfile::tempdir().unwrap();
let root = InstallRoot::at(d.path().join("Vak.app"));
let first_dist = d.path().join("dist-v1");
std::fs::create_dir_all(first_dist.join("assets")).unwrap();
std::fs::write(first_dist.join("assets/index-OLDHASH.js"), b"old").unwrap();
std::fs::write(
first_dist.join("index.html"),
b"