- 1
//! macOS application-bundle metadata. - 2
//! - 3
//! The managed install and the desktop app are the same `.app`, so the - 4
//! bundle identity here must agree with `crates/vak-desktop/tauri.conf.json`. - 5
//! `scripts/check-version.sh` enforces that they do. - 6
- 7
use std::path::Path; - 8
- 9
use super::atomic; - 10
use super::layout::InstallRoot; - 11
- 12
/// Bundle identifier, matching `tauri.conf.json`. - 13
pub const IDENTIFIER: &str = "dev.vak.desktop"; - 14
- 15
/// The bundle launches `vak-desktop`, the actual application. - 16
/// - 17
/// It used to launch `vak-tray` with `LSUIElement`, which made the - 18
/// whole `.app` a background menu-bar agent. That had a fatal - 19
/// consequence: `com.vak.tray` runs the same binary as a launchd - 20
/// service, so macOS considered the app already running and answered a - 21
/// double-click in Finder by sending an activate event to that existing - 22
/// process rather than launching anything. No new process meant no code - 23
/// of ours ran at all -- double-clicking Vak did nothing visible, - 24
/// and no amount of logic inside the tray's startup could have fixed it, - 25
/// because startup never happened. - 26
/// - 27
/// `vak-desktop` is what a user double-clicking the app is asking for, - 28
/// and it already handles being launched twice: `tauri_plugin_single_instance` - 29
/// refocuses the open window instead of starting a rival instance. The - 30
/// tray keeps its menu-bar-only presence by setting - 31
/// `ActivationPolicy::Accessory` on its own event loop, so it no longer - 32
/// depends on a bundle-wide `LSUIElement` that would also have hidden - 33
/// the app this bundle now launches. - 34
pub fn info_plist(version: &str) -> String { - 35
format!( - 36
r#"<?xml version="1.0" encoding="UTF-8"?> - 37
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> - 38
<plist version="1.0"> - 39
<dict> - 40
<key>CFBundleName</key><string>Vakyartha</string> - 41
<key>CFBundleDisplayName</key><string>Vakyartha</string> - 42
<key>CFBundleIdentifier</key><string>{IDENTIFIER}</string> - 43
<key>CFBundleVersion</key><string>{version}</string> - 44
<key>CFBundleShortVersionString</key><string>{version}</string> - 45
<key>CFBundlePackageType</key><string>APPL</string> - 46
<key>CFBundleExecutable</key><string>vak-desktop</string> - 47
<key>CFBundleIconFile</key><string>icon.icns</string> - 48
<key>LSMinimumSystemVersion</key><string>11.0</string> - 49
<key>NSHighResolutionCapable</key><true/> - 50
</dict> - 51
</plist> - 52
"# - 53
) - 54
} - 55
- 56
/// Refuse a bundle install that would produce an unlaunchable app. - 57
/// - 58
/// Separate from [`write_metadata`] so it can run **before** the install - 59
/// transaction commits. Failing after the commit left the binaries in place - 60
/// with no manifest beside them — a half-install that `status` reads as - 61
/// broken and `verify` cannot even find, which is a worse outcome than - 62
/// either finishing or not starting. - 63
pub fn require_frontend( - 64
root: &InstallRoot, - 65
assets: Option<&Path>, - 66
desktop: bool, - 67
) -> Result<(), String> { - 68
if !root.is_bundle() || !desktop || assets.is_some_and(Path::exists) { - 69
return Ok(()); - 70
} - 71
Err( - 72
"the desktop app is being installed into a bundle, but its frontend was not found at \ - 73
crates/vak-client-ui/dist. The installed app would open a blank window. Build it \ - 74
first:\n cd crates/vak-client-ui && npm ci && npm run build\n(or install without \ - 75
the desktop app: scripts/build.sh --no-desktop)" - 76
.to_string(), - 77
) - 78
} - 79
- 80
/// Write `Info.plist` and copy the desktop frontend assets into - 81
/// `Contents/Resources`, so a bundle install is launchable from Finder. - 82
/// - 83
/// `desktop` says whether the `vak-desktop` binary is part of this install. - 84
/// When it is, the frontend is **required**: `tauri.conf.json` points - 85
/// `frontendDist` at `crates/vak-client-ui/dist`, and the installed app - 86
/// loads that copy out of `Contents/Resources`. Without it the app opens a - 87
/// blank window with nothing in the console to explain why. - 88
/// - 89
/// This used to be a silent skip — `if let Some(dist) = assets.filter(...)` - 90
/// with no else — so `vak self install` into the default macOS prefix with - 91
/// no built frontend reported success, wrote a manifest, and produced an - 92
/// app that did not work. `verify` then passed, because it only looked at - 93
/// the binaries. An install that cannot run is a failed install, and it has - 94
/// to say so at the moment it happens. - 95
pub fn write_metadata( - 96
root: &InstallRoot, - 97
version: &str, - 98
assets: Option<&Path>, - 99
desktop: bool, - 100
) -> Result<(), String> { - 101
if !root.is_bundle() { - 102
return Ok(()); - 103
} - 104
let contents = root.prefix().join("Contents"); - 105
std::fs::create_dir_all(contents.join("Resources")) - 106
.map_err(|e| format!("mkdir {}: {e}", contents.display()))?; - 107
// Plist first: a bundle without one is not launchable as an app. - 108
atomic::write(&contents.join("Info.plist"), info_plist(version).as_bytes())?; - 109
require_frontend(root, assets, desktop)?; - 110
let dist = assets.filter(|p| p.exists()); - 111
if let Some(dist) = dist { - 112
// Every frontend rebuild produces new content-hashed filenames - 113
// (Vite), and copy_dir only ever adds files, never removes ones - 114
// absent from the source. Without clearing the destination - 115
// first, every reinstall left the previous build's JS and CSS - 116
// behind alongside the new one — harmless to which file - 117
// actually gets served (index.html always names the current - 118
// hash), but unbounded bloat, and confusing to anyone - 119
// inspecting the bundle who has no way to tell which files are - 120
// live. Only the hashed subtree is cleared, not all of - 121
// Resources: that directory also holds install.json (written - 122
// after this function returns) and Info.plist (written above). - 123
let stale_assets = root.resources_dir().join("assets"); - 124
if stale_assets.exists() { - 125
std::fs::remove_dir_all(&stale_assets) - 126
.map_err(|e| format!("clear stale assets at {}: {e}", stale_assets.display()))?; - 127
} - 128
atomic::copy_dir(dist, &root.resources_dir())?; - 129
} - 130
// A bundle whose Info.plist names an icon (above) but never carries - 131
// one shows Finder's generic placeholder — silently, since a missing - 132
// CFBundleIconFile target is not an error macOS surfaces anywhere. - 133
if let Some(icon) = locate_icon() { - 134
std::fs::copy(&icon, contents.join("Resources/icon.icns")) - 135
.map_err(|e| format!("copy icon {}: {e}", icon.display()))?; - 136
} - 137
Ok(()) - 138
} - 139
- 140
/// Locate the built desktop frontend relative to the running binary, - 141
/// covering both a dev tree (`target/release/vak`) and an installed - 142
/// bundle (`Contents/MacOS/vak`). - 143
pub fn locate_frontend_assets() -> Option<std::path::PathBuf> { - 144
locate_repo_relative("crates/vak-client-ui/dist") - 145
} - 146
- 147
/// Locate the app icon (`.icns`), same search shape as - 148
/// [`locate_frontend_assets`] — both are repo-relative resources a - 149
/// release binary needs to find without knowing where the checkout is. - 150
pub fn locate_icon() -> Option<std::path::PathBuf> { - 151
locate_repo_relative("crates/vak-desktop/icons/icon.icns") - 152
} - 153
- 154
/// Locate the feed runtime bundled beside a release binary or in the source - 155
/// checkout. The server executes these scripts as a subprocess, so an - 156
/// install must carry the complete runtime rather than relying on the user's - 157
/// workspace containing the repository's `scripts/feeds` tree. - 158
pub fn locate_feed_assets() -> Option<std::path::PathBuf> { - 159
let exe = std::env::current_exe().ok()?; - 160
let bin_dir = exe.parent()?; - 161
let mut candidates = vec![ - 162
bin_dir.join("feeds"), - 163
bin_dir.join("..").join("Resources").join("feeds"), - 164
bin_dir.join("..").join("share").join("vak").join("feeds"), - 165
bin_dir - 166
.join("..") - 167
.join("..") - 168
.join("share") - 169
.join("vak") - 170
.join("feeds"), - 171
]; - 172
if let Some(repo_assets) = locate_repo_relative("scripts/feeds") { - 173
candidates.push(repo_assets); - 174
} - 175
candidates - 176
.into_iter() - 177
.find(|candidate| candidate.join("feed_ingest.py").exists()) - 178
} - 179
- 180
fn locate_repo_relative(rel: &str) -> Option<std::path::PathBuf> { - 181
let exe = std::env::current_exe().ok()?; - 182
let mut dir = exe.parent()?.to_path_buf(); - 183
for _ in 0..5 { - 184
let candidate = dir.join(rel); - 185
if candidate.exists() { - 186
return Some(candidate); - 187
} - 188
dir = dir.parent()?.to_path_buf(); - 189
} - 190
None - 191
} - 192
- 193
#[cfg(test)] - 194
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 195
mod tests { - 196
use super::*; - 197
- 198
#[test] - 199
fn plist_carries_the_version_it_was_given() { - 200
let p = info_plist("1.2.3"); - 201
assert!(p.contains("<key>CFBundleShortVersionString</key><string>1.2.3</string>")); - 202
assert!(p.contains(IDENTIFIER)); - 203
// The bundle launches the app the user double-clicks, not the - 204
// background menu-bar agent. When it launched the tray under - 205
// LSUIElement, macOS treated the app as already running (the - 206
// tray also runs as a launchd service) and answered a - 207
// double-click by activating that process instead of launching - 208
// anything -- so opening Vak did nothing at all. - 209
assert!( - 210
p.contains("<key>CFBundleExecutable</key><string>vak-desktop</string>"), - 211
"the bundle must launch the desktop app" - 212
); - 213
assert!( - 214
!p.contains("LSUIElement"), - 215
"a bundle-wide LSUIElement would hide the app this bundle launches; \ - 216
the tray sets ActivationPolicy::Accessory on its own event loop instead" - 217
); - 218
} - 219
- 220
#[test] - 221
fn metadata_is_a_no_op_for_a_plain_prefix() { - 222
let d = tempfile::tempdir().unwrap(); - 223
let root = InstallRoot::at(d.path().to_path_buf()); - 224
write_metadata(&root, "1.0.0", None, false).unwrap(); - 225
assert!(!d.path().join("Contents").exists()); - 226
} - 227
- 228
#[test] - 229
fn bundle_metadata_writes_a_launchable_plist() { - 230
let d = tempfile::tempdir().unwrap(); - 231
let root = InstallRoot::at(d.path().join("Vak.app")); - 232
write_metadata(&root, "0.8.0", None, false).unwrap(); - 233
let plist = std::fs::read_to_string(root.prefix().join("Contents/Info.plist")).unwrap(); - 234
assert!(plist.contains("0.8.0")); - 235
assert!( - 236
plist.contains("<key>CFBundleIconFile</key><string>icon.icns</string>"), - 237
"a bundle with no icon key falls back to Finder's generic placeholder" - 238
); - 239
} - 240
- 241
#[test] - 242
fn bundle_metadata_carries_the_real_icon_when_one_is_found_relative_to_the_test_binary() { - 243
// This test binary runs from target/debug, still inside the real - 244
// checkout, so locate_icon() finds the genuine icon the same way - 245
// a release binary would -- proving the copy actually happens, - 246
// not just that the plist names a file that isn't there. - 247
let d = tempfile::tempdir().unwrap(); - 248
let root = InstallRoot::at(d.path().join("Vak.app")); - 249
write_metadata(&root, "0.8.0", None, false).unwrap(); - 250
if locate_icon().is_some() { - 251
let copied = root.prefix().join("Contents/Resources/icon.icns"); - 252
assert!(copied.is_file(), "icon.icns must be copied into the bundle"); - 253
assert!(std::fs::metadata(&copied).unwrap().len() > 0); - 254
} - 255
} - 256
- 257
#[test] - 258
fn reinstall_removes_the_previous_builds_hashed_assets() { - 259
// Every rebuild of the frontend produces new content-hashed - 260
// filenames; copy_dir only adds, it never removes. Without - 261
// clearing the destination first, a reinstall accumulated every - 262
// prior build's JS and CSS forever. - 263
let d = tempfile::tempdir().unwrap(); - 264
let root = InstallRoot::at(d.path().join("Vak.app")); - 265
- 266
let first_dist = d.path().join("dist-v1"); - 267
std::fs::create_dir_all(first_dist.join("assets")).unwrap(); - 268
std::fs::write(first_dist.join("assets/index-OLDHASH.js"), b"old").unwrap(); - 269
std::fs::write( - 270
first_dist.join("index.html"), - 271
b"<script src=assets/index-OLDHASH.js>", - 272
) - 273
.unwrap(); - 274
write_metadata(&root, "1.0.0", Some(&first_dist), true).unwrap(); - 275
assert!( - 276
root.resources_dir() - 277
.join("assets/index-OLDHASH.js") - 278
.exists() - 279
); - 280
- 281
let second_dist = d.path().join("dist-v2"); - 282
std::fs::create_dir_all(second_dist.join("assets")).unwrap(); - 283
std::fs::write(second_dist.join("assets/index-NEWHASH.js"), b"new").unwrap(); - 284
std::fs::write( - 285
second_dist.join("index.html"), - 286
b"<script src=assets/index-NEWHASH.js>", - 287
) - 288
.unwrap(); - 289
write_metadata(&root, "1.0.1", Some(&second_dist), true).unwrap(); - 290
- 291
assert!( - 292
!root - 293
.resources_dir() - 294
.join("assets/index-OLDHASH.js") - 295
.exists(), - 296
"the previous build's asset must be gone, not merely superseded" - 297
); - 298
assert!( - 299
root.resources_dir() - 300
.join("assets/index-NEWHASH.js") - 301
.exists() - 302
); - 303
} - 304
- 305
/// The regression this whole change exists for: a bundle install that - 306
/// carries the desktop app but no frontend used to succeed, and the - 307
/// installed app opened a blank window. - 308
#[test] - 309
fn a_bundle_with_the_desktop_app_and_no_frontend_is_refused() { - 310
let dir = tempfile::tempdir().unwrap(); - 311
let prefix = dir.path().join("Vak.app"); - 312
std::fs::create_dir_all(prefix.join("Contents/MacOS")).unwrap(); - 313
let root = InstallRoot::resolve(Some(prefix)); - 314
- 315
let err = write_metadata(&root, "1.0.0", None, true) - 316
.expect_err("a desktop bundle with no frontend must not install"); - 317
assert!( - 318
err.contains("blank window") && err.contains("npm run build"), - 319
"the error must name the consequence and the fix, got: {err}" - 320
); - 321
- 322
// Without the desktop app there is no frontend to require. - 323
write_metadata(&root, "1.0.0", None, false).expect("a CLI-only bundle needs no frontend"); - 324
} - 325
- 326
/// An assets path that is recorded but absent is the same failure as - 327
/// no path at all, and was the likelier of the two: a stale - 328
/// `locate_frontend_assets` hit after someone deleted `dist/`. - 329
#[test] - 330
fn a_frontend_path_that_does_not_exist_is_refused_too() { - 331
let dir = tempfile::tempdir().unwrap(); - 332
let prefix = dir.path().join("Vak.app"); - 333
std::fs::create_dir_all(prefix.join("Contents/MacOS")).unwrap(); - 334
let root = InstallRoot::resolve(Some(prefix)); - 335
- 336
let gone = dir.path().join("was-here"); - 337
assert!(write_metadata(&root, "1.0.0", Some(&gone), true).is_err()); - 338
} - 339
} - 340
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.